@jeffusion/bungee-core 6.9.0 → 6.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -242,6 +242,15 @@
242
242
  "capability": "config.write"
243
243
  }
244
244
  ],
245
+ "navigation": [
246
+ {
247
+ "label": "ui.accountsPageTitle",
248
+ "path": "/accounts",
249
+ "component": "ChatgptAccountsPage",
250
+ "icon": "manage_accounts",
251
+ "target": "header"
252
+ }
253
+ ],
245
254
  "upstreamSources": [
246
255
  {
247
256
  "id": "chatgpt",
@@ -406,9 +415,7 @@
406
415
  ]
407
416
  }
408
417
  }
409
- ],
410
- "settings": "/accounts",
411
- "nativeSettingsComponent": "ChatgptAccountsPage"
418
+ ]
412
419
  },
413
420
  "metadata": {
414
421
  "name": "ChatGPT OAuth",
@@ -454,6 +461,8 @@
454
461
  "ui.accountsFailed": "Unable to load accounts. Refresh to try again.",
455
462
  "ui.noAccounts": "No accounts yet. Add and sign in to use an account with a new or existing service.",
456
463
  "ui.available": "Available",
464
+ "ui.accountStateAvailable": "Account state · available",
465
+ "ui.accountAvailabilityHelp": "Based on local account state and stored credentials; this does not verify upstream access.",
457
466
  "ui.account": "Account",
458
467
  "ui.noEmail": "No email provided",
459
468
  "ui.accountType": "Account type: {plan}",
@@ -486,8 +495,22 @@
486
495
  "ui.routeReloadConfirm": "This will discard your unsaved route draft. Continue?",
487
496
  "ui.usageLabel": "Usage",
488
497
  "ui.refreshUsage": "Refresh quota usage",
498
+ "ui.refreshUsageAndCards": "Refresh usage + reset-card data",
489
499
  "ui.resolveReset": "Resolve",
490
500
  "ui.used": "used",
501
+ "ui.usageStatus.failed": "Load failed",
502
+ "ui.usageStatus.refreshFailed": "Refresh failed",
503
+ "ui.usageStatus.stale": "Possibly stale",
504
+ "ui.usageStatus.expired": "Window expired",
505
+ "ui.usageStatus.partialExpired": "Some windows expired",
506
+ "ui.usageLoadFailed": "Usage and reset-card data could not be loaded. Retry with the refresh button.",
507
+ "ui.usageRefreshFailed": "Refresh failed; previously received data may be out of date. Retry with the refresh button.",
508
+ "ui.usageStale": "This usage snapshot may be out of date. Refresh to check again.",
509
+ "ui.usageUnavailable": "Usage data is unavailable. Refresh to check again.",
510
+ "ui.accountUsageSkipped": "Usage was not requested for this account state; shown values may be from an earlier request.",
511
+ "ui.usageAllWindowsExpired": "All returned windows have expired; current usage is not shown.",
512
+ "ui.usageSomeWindowsExpired": "Some windows have expired; each window is labeled.",
513
+ "ui.usageWindowExpired": "Window expired: {value}",
491
514
  "ui.usage.loading": "Loading",
492
515
  "ui.usage.fresh": "Fresh",
493
516
  "ui.usage.stale": "Stale",
@@ -496,6 +519,15 @@
496
519
  "ui.usage.zero": "Zero",
497
520
  "ui.noResetCredits": "No reset credits available",
498
521
  "ui.creditDetailsUnavailable": "Credit details unavailable",
522
+ "ui.resetCards": "Reset cards",
523
+ "ui.resetCardCountUnavailable": "Count unknown",
524
+ "ui.resetCardCountLoading": "Loading count…",
525
+ "ui.resetCardCountAvailable": "{count} available",
526
+ "ui.resetCardCountLastKnown": "Last known: {count} available",
527
+ "ui.resetCardDataStale": "Reset-card data may be out of date. Refresh to check again.",
528
+ "ui.resetCardDetailsUnavailable": "Reset-card details were not returned.",
529
+ "ui.resetCardPartialDetails": "Only part of the reset-card list was returned.",
530
+ "ui.noResetCards": "No reset cards are available.",
499
531
  "ui.resetPending": "Reset result pending confirmation",
500
532
  "ui.creditStatusUnknown": "Credit status unknown",
501
533
  "ui.resolveUnknownReset": "Resolve unknown reset",
@@ -522,13 +554,26 @@
522
554
  "ui.credit.redeemed": "Redeemed",
523
555
  "ui.credit.unknown": "Unknown",
524
556
  "ui.useCredit": "Use",
525
- "ui.autoResetEnabled": "Automatic reset enabled (30 minutes before expiry)",
526
- "ui.autoResetDisabled": "Automatic reset disabled",
527
- "ui.autoResetLabel": "Auto reset",
528
- "ui.enableAutoReset": "Enable automatic reset",
529
- "ui.disableAutoReset": "Disable automatic reset",
530
- "ui.autoResetHelp": "The server refreshes the credit list every 5 minutes and schedules available credits for 30 minutes before expiry. Credits already in their final 30 minutes are processed immediately. Expired credits and credits without an expiry are skipped. Unknown results pause automation and require explicit resolution. Enable for this account?",
531
- "ui.disableAutoResetHelp": "Stop scheduling automatic resets for this account. Requests already sent may still finish.",
557
+ "ui.accountsPageTitle": "ChatGPT accounts",
558
+ "ui.autoResetEnabled": "Near-expiry reset enabled · {minutes} min before expiry",
559
+ "ui.autoResetDisabled": "Near-expiry reset disabled",
560
+ "ui.autoResetLowBalanceEnabled": "Low-balance reset enabled · {quota} remaining ≤ {percent}%",
561
+ "ui.autoResetLowBalanceDisabled": "Low-balance reset disabled",
562
+ "ui.autoResetSettingsTitle": "Automatic reset settings",
563
+ "ui.configureAutoReset": "Reset strategies",
564
+ "ui.nearExpiryStrategy": "Reset before expiry",
565
+ "ui.lowBalanceStrategy": "Auto reset when {quota} is low",
566
+ "ui.leadMinutes": "Minutes before expiry",
567
+ "ui.remainingPercent": "{quota} remaining (%)",
568
+ "ui.quotaCycle.week": "weekly quota",
569
+ "ui.quotaCycle.month": "monthly quota",
570
+ "ui.quotaCycle.weekOrMonth": "weekly/monthly quota",
571
+ "ui.autoResetSettingsHelp": "The server refreshes the credit list every 5 minutes. The balance strategy checks only the {quota}; the 5-hour limit does not trigger it. Near-expiry resets skip expired credits and credits without an expiry. Unknown results pause automation and require explicit resolution.",
572
+ "ui.autoResetConsumptionWarning": "When either strategy triggers, an available reset card may be used. Card use consumes it and cannot be undone. Review these settings before saving.",
573
+ "ui.saveAutoResetSettings": "Save strategies",
574
+ "ui.numberIncrease": "Increase value",
575
+ "ui.numberDecrease": "Decrease value",
576
+ "ui.invalidNumber": "Enter a whole number within the allowed range.",
532
577
  "ui.resetConfirmTitle": "Use reset credit",
533
578
  "ui.resetRetryTitle": "Retry reset credit",
534
579
  "ui.resetConfirmHelp": "This uses one reset credit for the account. Confirm only once.",
@@ -666,6 +711,8 @@
666
711
  "ui.accountsFailed": "无法加载账号,请刷新重试。",
667
712
  "ui.noAccounts": "尚未添加账号。添加并登录后,可将账号用于新建或已有服务。",
668
713
  "ui.available": "可用",
714
+ "ui.accountStateAvailable": "账号状态 · 可用",
715
+ "ui.accountAvailabilityHelp": "根据本地账号状态和已保存凭据判断,不代表已通过上游验证。",
669
716
  "ui.account": "账号",
670
717
  "ui.noEmail": "未提供邮箱",
671
718
  "ui.accountType": "账号类型:{plan}",
@@ -698,8 +745,22 @@
698
745
  "ui.routeReloadConfirm": "这会丢弃当前未保存的路由草稿,是否继续?",
699
746
  "ui.usageLabel": "用量",
700
747
  "ui.refreshUsage": "刷新额度用量",
748
+ "ui.refreshUsageAndCards": "刷新用量和重置卡数据",
701
749
  "ui.resolveReset": "确认结果",
702
750
  "ui.used": "已使用",
751
+ "ui.usageStatus.failed": "加载失败",
752
+ "ui.usageStatus.refreshFailed": "刷新失败",
753
+ "ui.usageStatus.stale": "数据可能过期",
754
+ "ui.usageStatus.expired": "窗口已过期",
755
+ "ui.usageStatus.partialExpired": "部分窗口已过期",
756
+ "ui.usageLoadFailed": "无法加载用量和重置卡数据。点击刷新按钮重试。",
757
+ "ui.usageRefreshFailed": "刷新失败;仍显示先前返回的数据,可能已过期。点击刷新按钮重试。",
758
+ "ui.usageStale": "此用量数据可能已过期,请刷新后重新查询。",
759
+ "ui.usageUnavailable": "用量数据不可用,请刷新后重新查询。",
760
+ "ui.accountUsageSkipped": "当前账号状态未请求用量;现有数据可能来自先前查询。",
761
+ "ui.usageAllWindowsExpired": "返回的额度窗口均已结束,暂不显示当前用量。",
762
+ "ui.usageSomeWindowsExpired": "部分额度窗口已结束,具体状态见各窗口。",
763
+ "ui.usageWindowExpired": "额度窗口已结束:{value}",
703
764
  "ui.usage.loading": "加载中",
704
765
  "ui.usage.fresh": "最新",
705
766
  "ui.usage.stale": "过期",
@@ -708,6 +769,15 @@
708
769
  "ui.usage.zero": "零额度",
709
770
  "ui.noResetCredits": "没有可用的重置额度",
710
771
  "ui.creditDetailsUnavailable": "额度详情不可用",
772
+ "ui.resetCards": "重置卡",
773
+ "ui.resetCardCountUnavailable": "数量未知",
774
+ "ui.resetCardCountLoading": "正在读取数量…",
775
+ "ui.resetCardCountAvailable": "{count} 张可用",
776
+ "ui.resetCardCountLastKnown": "上次记录:{count} 张可用",
777
+ "ui.resetCardDataStale": "重置卡数据可能已过期,请刷新后重新查询。",
778
+ "ui.resetCardDetailsUnavailable": "暂未返回重置卡详情。",
779
+ "ui.resetCardPartialDetails": "重置卡列表只返回了部分详情。",
780
+ "ui.noResetCards": "当前没有可用的重置卡。",
711
781
  "ui.resetPending": "重置结果待确认",
712
782
  "ui.creditStatusUnknown": "额度状态未知",
713
783
  "ui.resolveUnknownReset": "处理未知重置结果",
@@ -734,13 +804,26 @@
734
804
  "ui.credit.redeemed": "已兑换",
735
805
  "ui.credit.unknown": "未知",
736
806
  "ui.useCredit": "使用",
737
- "ui.autoResetEnabled": "自动重置已开启(到期前 30 分钟)",
738
- "ui.autoResetDisabled": "自动重置已关闭",
739
- "ui.autoResetLabel": "自动重置",
740
- "ui.enableAutoReset": "开启自动重置",
741
- "ui.disableAutoReset": "关闭自动重置",
742
- "ui.autoResetHelp": "服务端每 5 分钟刷新卡列表,并定时在重置卡到期前 30 分钟自动使用;已进入最后 30 分钟的卡立即处理。跳过已过期或到期时间未知的卡;结果未知时暂停自动兑换,需手动处理。是否为此账号开启?",
743
- "ui.disableAutoResetHelp": "停止为此账号安排自动重置。已发送的兑换请求仍可能完成。",
807
+ "ui.accountsPageTitle": "ChatGPT 账号管理",
808
+ "ui.autoResetEnabled": "临期自动重置已开启 · 到期前 {minutes} 分钟",
809
+ "ui.autoResetDisabled": "临期自动重置已关闭",
810
+ "ui.autoResetLowBalanceEnabled": "低余额自动重置已开启 · {quota}剩余≤{percent}%",
811
+ "ui.autoResetLowBalanceDisabled": "低余额自动重置已关闭",
812
+ "ui.autoResetSettingsTitle": "自动重置策略",
813
+ "ui.configureAutoReset": "重置策略",
814
+ "ui.nearExpiryStrategy": "到期前自动重置",
815
+ "ui.lowBalanceStrategy": "{quota}偏低时自动重置",
816
+ "ui.leadMinutes": "提前分钟数",
817
+ "ui.remainingPercent": "{quota}剩余百分比(%)",
818
+ "ui.quotaCycle.week": "周额度",
819
+ "ui.quotaCycle.month": "月额度",
820
+ "ui.quotaCycle.weekOrMonth": "周/月额度",
821
+ "ui.autoResetSettingsHelp": "服务端每 5 分钟刷新额度卡列表。余额策略只检查{quota},5 小时额度不会触发该策略。临期策略会跳过已过期或到期时间未知的卡;结果未知时暂停自动兑换,需明确处理。",
822
+ "ui.autoResetConsumptionWarning": "任一策略触发时,都可能使用一张可用重置卡。卡片使用后会被消耗且无法撤销,请检查设置后再保存。",
823
+ "ui.saveAutoResetSettings": "保存策略",
824
+ "ui.numberIncrease": "增加数值",
825
+ "ui.numberDecrease": "减少数值",
826
+ "ui.invalidNumber": "请输入范围内的整数。",
744
827
  "ui.resetConfirmTitle": "使用重置额度",
745
828
  "ui.resetRetryTitle": "重试重置额度",
746
829
  "ui.resetConfirmHelp": "这会为该账号使用一份重置额度,请只确认一次。",
@@ -7,7 +7,8 @@
7
7
  import { accountReferences } from '$api/upstream-sources';
8
8
  import { sourceHandoffUrl, routeSourceHandoffUrl } from '$api/source-handoff';
9
9
  import { RoutesAPI, type Route } from '$api/routes';
10
- import { PanelCard, MetricBar, StatusBadge, LoadingIndicator, IndustrialDialog } from '$components/industrial';
10
+ import { PanelCard, MetricBar, StatusBadge, LoadingIndicator, IndustrialDialog, IndustrialToggle } from '$components/industrial';
11
+ import { NumberInput } from '$components/ui/number-input';
11
12
  import RefreshCw from 'lucide-svelte/icons/refresh-cw';
12
13
  import Plus from 'lucide-svelte/icons/plus';
13
14
  import Server from 'lucide-svelte/icons/server';
@@ -34,7 +35,7 @@
34
35
  import { Input } from '$components/ui/input';
35
36
  import * as DropdownMenu from '$components/ui/dropdown-menu';
36
37
  import { Close as ResetClose } from '$components/ui/dialog';
37
- import { loginStates, accountStates, terminal, verificationUrl, loginStatus, parseLoginStart, accountSummary, accountUsage, resetOutcome, errorText, errorCode, accountSourceId, canQueryUsage, loginMethods } from './account-model.js';
38
+ import { loginStates, accountStates, terminal, verificationUrl, loginStatus, parseLoginStart, accountSummary, accountUsage, usageDisplayState, resetCardDisplayState, resetOutcome, errorText, errorCode, accountSourceId, canQueryUsage, loginMethods } from './account-model.js';
38
39
 
39
40
  type Account = ReturnType<typeof accountSummary>;
40
41
  type AccountUsage = ReturnType<typeof accountUsage>;
@@ -60,6 +61,8 @@
60
61
  let url = $derived(active ? verificationUrl(session?.verificationUri ?? session?.authorizationUrl, kind) : null);
61
62
  let action = $state(''), actionAccount = $state<Account | null>(null), actionOpen = $state(false);
62
63
  let actionBusy = $state(false), label = $state(''), actionNotice = $state(''), referenceNotice = $state('');
64
+ let autoResetLeadEnabled = $state(false), autoResetLeadMinutes = $state(30);
65
+ let autoResetLowBalanceEnabled = $state(false), autoResetRemainingPercent = $state(10);
63
66
  let references = $state<(ReturnType<typeof accountReferences> & { revision: number }) | null>(null);
64
67
  let referenceGeneration = 0;
65
68
  let usageByAccount = $state<Record<string, UsageSnapshot>>({});
@@ -91,20 +94,30 @@
91
94
  return usageByAccount[account.id] ?? { state: 'loading' };
92
95
  }
93
96
  function usageStatus(account: Account) {
97
+ if (!eligibleForUsage(account)) return 'skipped';
98
+ return usageDisplayState(usageFor(account), !!usageErrors[account.id], clock);
99
+ }
100
+ function resetCardStatus(account: Account) {
94
101
  const snapshot = usageFor(account);
95
- if ('state' in snapshot && snapshot.state === 'loading') return 'loading';
96
- if ('state' in snapshot && snapshot.state === 'unavailable') return 'unavailable';
97
- const usage = snapshot.usage, credits = snapshot.resetCredits;
98
- if (usage.state === 'unavailable' && credits.state === 'unavailable') return 'unavailable';
99
- const count = authoritativeCount(snapshot);
100
- if (count === 0) return 'zero';
101
- if (usage.state === 'stale' || credits.state === 'stale') return 'stale';
102
- if (usage.state === 'fresh' && (!usage.value?.primary && !usage.value?.secondary)) return 'unavailable';
103
- if (usage.state === 'fresh' && credits.state === 'fresh' && usage.value && credits.value) {
104
- if (credits.value.credits.length < credits.value.availableCount) return 'partial';
105
- if (usage.value.primary) return 'fresh';
106
- }
107
- return 'partial';
102
+ return !eligibleForUsage(account) && 'usage' in snapshot && (snapshot.usage.value || snapshot.resetCredits.value)
103
+ ? 'stale' : resetCardDisplayState(snapshot, !!usageErrors[account.id]);
104
+ }
105
+ function countIsStale(snapshot: UsageSnapshot, account?: Account) {
106
+ if (!('usage' in snapshot)) return false;
107
+ if (account && !eligibleForUsage(account) && (snapshot.usage.value || snapshot.resetCredits.value)) return true;
108
+ if (snapshot.resetCredits.state === 'fresh' && snapshot.resetCredits.value?.availableCount !== undefined) return false;
109
+ if (snapshot.usage.state === 'fresh' && snapshot.usage.value?.availableCount !== undefined) return false;
110
+ if (snapshot.resetCredits.state === 'stale' && snapshot.resetCredits.value?.availableCount !== undefined) return true;
111
+ return snapshot.usage.state === 'stale' && snapshot.usage.value?.availableCount !== undefined;
112
+ }
113
+ function windowExpired(window: { resetAt?: unknown }) {
114
+ void clock;
115
+ return typeof window.resetAt === 'number' && window.resetAt <= clock;
116
+ }
117
+ function usageStatusVariant(status: string): 'fault' | 'standby' | 'muted' {
118
+ if (status === 'failed' || status === 'refreshFailed') return 'fault';
119
+ if (status === 'stale' || status === 'expired' || status === 'partialExpired') return 'standby';
120
+ return 'muted';
108
121
  }
109
122
  function authoritativeCount(snapshot: UsageSnapshot) {
110
123
  if (!('usage' in snapshot)) return undefined;
@@ -313,7 +326,14 @@
313
326
  if (actionBusy) return;
314
327
  const generation = ++referenceGeneration;
315
328
  action = value; actionAccount = account; label = account.label; actionNotice = ''; references = null; referenceNotice = ''; actionOpen = true;
316
- if (value === 'rename' || value === 'auto-reset') return;
329
+ if (value === 'auto-reset') {
330
+ autoResetLeadEnabled = account.autoResetCredits;
331
+ autoResetLeadMinutes = account.autoResetLeadMinutes;
332
+ autoResetLowBalanceEnabled = account.autoResetOnLowBalance;
333
+ autoResetRemainingPercent = account.autoResetRemainingPercent;
334
+ return;
335
+ }
336
+ if (value === 'rename') return;
317
337
  referenceNotice = 'ui.referencesLoading';
318
338
  try {
319
339
  const snapshot = await getConfigSnapshot();
@@ -362,11 +382,16 @@
362
382
  event.preventDefault();
363
383
  if (!actionAccount || actionBusy || action === 'references' || (action === 'auto-reset' && actionAccount.authType === 'siwc')) return;
364
384
  if (action === 'rename' && !label.trim()) { actionNotice = 'ui.nameRequired'; return; }
385
+ if (action === 'auto-reset' && (!Number.isSafeInteger(autoResetLeadMinutes) || autoResetLeadMinutes < 1 || autoResetLeadMinutes > 10080
386
+ || !Number.isSafeInteger(autoResetRemainingPercent) || autoResetRemainingPercent < 0 || autoResetRemainingPercent > 100)) {
387
+ actionNotice = 'ui.invalidNumber'; return;
388
+ }
365
389
  actionBusy = true;
366
390
  const generation = referenceGeneration;
367
391
  try {
368
392
  await control('POST', `/accounts/${action}`, { accountRef: actionAccount.id, ...(action === 'rename' ? { label: label.trim() } : {}),
369
- ...(action === 'auto-reset' ? { enabled: !actionAccount.autoResetCredits } : {}) });
393
+ ...(action === 'auto-reset' ? { enabled: autoResetLeadEnabled, leadMinutes: autoResetLeadMinutes,
394
+ lowBalanceEnabled: autoResetLowBalanceEnabled, remainingPercent: autoResetRemainingPercent } : {}) });
370
395
  if (generation === referenceGeneration) actionOpen = false;
371
396
  await refreshAccounts();
372
397
  } catch (error) { actionNotice = errorText(error); }
@@ -438,6 +463,10 @@
438
463
  {/snippet}
439
464
 
440
465
  <div class="p-3 sm:p-4 space-y-3" data-testid="chatgpt-accounts-page">
466
+ <header class="flex items-center gap-3">
467
+ <span class="nx-stripe" aria-hidden="true"></span>
468
+ <div><p class="nx-label">// CHATGPT OAUTH</p><h1 class="nx-display text-xl text-zinc-50 tracking-[0.02em]">{t('ui.accountsPageTitle')}</h1></div>
469
+ </header>
441
470
  <div class="flex flex-wrap items-center justify-between gap-3">
442
471
  <p class="text-sm text-zinc-400">{t('ui.accountCount', { available: accounts.filter(account => account.available).length, total: accounts.length })}</p>
443
472
  <div class="flex gap-2"><Button variant="outline" disabled={refreshing} aria-busy={refreshing} onclick={refreshAccounts}>{@render actionIcon(RefreshCw, refreshing)}{t('ui.refresh')}</Button><Button onclick={() => openLogin()}>{@render actionIcon(Plus)}{t('ui.addAccount')}</Button></div>
@@ -450,67 +479,94 @@
450
479
  {#each accounts as account (account.id)}
451
480
  {@const snapshot = usageFor(account)}
452
481
  {@const status = usageStatus(account)}
482
+ {@const creditStatus = resetCardStatus(account)}
453
483
  {@const credits = sortedCredits(account)}
454
484
  {@const pending = pendingReset(account)}
455
485
  {@const count = authoritativeCount(snapshot)}
456
- <PanelCard title={account.label} corners={false} class={`account-card min-w-0 ${count !== undefined || pending ? 'self-stretch' : ''}`} stripe={status === 'unavailable' ? 'zinc' : status === 'stale' || status === 'partial' ? 'amber' : 'orange'}>
457
- <span slot="tag" data-testid="account-status"><StatusBadge variant={account.available ? 'active' : account.status === 'revoked' ? 'muted' : 'standby'}>{t(account.available ? 'ui.available' : account.status === 'active' ? 'account.reauth_required' : accountStates[account.status as keyof typeof accountStates])}</StatusBadge></span>
486
+ {@const staleCount = countIsStale(snapshot, account)}
487
+ {@const quota = t(`ui.quotaCycle.${account.quotaCycle}`)}
488
+ <PanelCard title={account.label} corners={false} class={`account-card min-w-0 ${count !== undefined || pending ? 'self-stretch' : ''}`} stripe={account.available ? 'orange' : account.status === 'revoked' ? 'red' : account.status === 'disabled' ? 'zinc' : 'amber'}>
489
+ <span slot="tag" data-testid="account-status" title={account.available ? t('ui.accountAvailabilityHelp') : undefined}><StatusBadge variant={account.available ? 'active' : account.status === 'revoked' ? 'muted' : 'standby'}>{t(account.available ? 'ui.accountStateAvailable' : account.status === 'active' ? 'account.reauth_required' : accountStates[account.status as keyof typeof accountStates])}</StatusBadge></span>
458
490
  <div class="flex flex-1 flex-col gap-3">
459
- <div class="space-y-2" data-testid="account-identity">
460
- {#if account.email && account.email !== account.label}<p class="break-all text-sm text-zinc-300">{account.email}</p>{/if}
461
- {#if !account.email}<p class="text-sm text-zinc-400">{t('ui.noEmail')}</p>{/if}
462
- {#if account.plan}<p class="text-sm text-zinc-300" data-testid="account-type">{t('ui.accountType', { plan: account.plan })}</p>{/if}
463
- {#if typeof account.expiresAt === 'number'}<p class="tabular-nums text-xs text-zinc-400">{t('ui.credentialExpiry', { date: dateText(account.expiresAt) })}</p>{/if}
464
- <p class="text-xs text-zinc-400">{t('ui.authMethod', { method: account.authType === 'siwc' ? t('ui.siwc') : 'Codex' })}</p>
465
- {#if account.authType !== 'siwc'}<p class="text-xs text-zinc-400" data-testid="auto-reset-status">{t(account.autoResetCredits ? 'ui.autoResetEnabled' : 'ui.autoResetDisabled')}</p>{/if}
491
+ <div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400" data-testid="account-identity">
492
+ {#if account.email && account.email !== account.label}<span class="break-all text-sm text-zinc-300">{account.email}</span>{/if}
493
+ {#if !account.email}<span class="text-sm text-zinc-400">{t('ui.noEmail')}</span>{/if}
494
+ {#if account.plan}<span data-testid="account-type">{t('ui.accountType', { plan: account.plan })}</span>{/if}
495
+ {#if typeof account.expiresAt === 'number'}<span class="tabular-nums">{t('ui.credentialExpiry', { date: dateText(account.expiresAt) })}</span>{/if}
496
+ <span>{t('ui.authMethod', { method: account.authType === 'siwc' ? t('ui.siwc') : 'Codex' })}</span>
466
497
  </div>
467
498
  {#if account.authType === 'siwc'}
468
- <div class="space-y-2" data-testid="siwc-usage">
499
+ <section class="space-y-2" data-testid="siwc-usage">
500
+ <span class="nx-field-label">{t('ui.usageLabel')}</span>
469
501
  <p class="text-sm text-zinc-400">{t('ui.siwcUsageHelp')}</p>
470
502
  <Button href="https://chatgpt.com" target="_blank" rel="noopener noreferrer" variant="outline" size="sm">{@render actionIcon(ExternalLink)}{t('ui.siwcUsage')}</Button>
471
- </div>
503
+ </section>
472
504
  {:else}
473
- <div class="flex flex-wrap items-center gap-2"><span class="nx-field-label">{t('ui.usageLabel')}</span><Button variant="ghost" size="sm" class="aspect-square px-0 [&>span]:mr-0" disabled={!eligibleForUsage(account) || usageRefreshing[account.id]} aria-busy={!!usageRefreshing[account.id]} aria-label={t('ui.refreshUsage')} title={t('ui.refreshUsage')} onclick={() => { if (!usageRefreshing[account.id]) void refreshUsage(account); }}>{@render actionIcon(RefreshCw, usageRefreshing[account.id])}</Button>{#if status !== 'fresh'}<span role="status" class={`text-xs ${status === 'stale' || status === 'partial' ? 'text-amber-300' : 'text-zinc-400'}`}>{t(`ui.usage.${status}`)}</span>{/if}</div>
474
- {#if 'usage' in snapshot}
475
- {#if usageErrors[account.id]}<p role="status" class="text-sm text-amber-300">{t('ui.usageFailed')}</p>{/if}
476
- {#if snapshot.usage.value?.primary?.usedPercent !== undefined}<MetricBar label={windowLabel(snapshot.usage.value.primary.windowSeconds)} value={snapshot.usage.value.primary.usedPercent} valueLabel={`${snapshot.usage.value.primary.usedPercent}% ${t('ui.used')}`} /><p class="tabular-nums text-xs text-zinc-400">{#if snapshot.usage.value.primary.windowSeconds !== undefined}{t('ui.window', { duration: formatWindow(snapshot.usage.value.primary.windowSeconds) })}{/if}{#if snapshot.usage.value.primary.resetAt !== undefined} · {t('ui.resetAt', { value: resetTime(snapshot.usage.value.primary.resetAt) })}{/if}</p>{/if}
477
- {#if snapshot.usage.value?.secondary?.usedPercent !== undefined}<MetricBar label={windowLabel(snapshot.usage.value.secondary.windowSeconds)} value={snapshot.usage.value.secondary.usedPercent} valueLabel={`${snapshot.usage.value.secondary.usedPercent}% ${t('ui.used')}`} /><p class="tabular-nums text-xs text-zinc-400">{#if snapshot.usage.value.secondary.windowSeconds !== undefined}{t('ui.window', { duration: formatWindow(snapshot.usage.value.secondary.windowSeconds) })}{/if}{#if snapshot.usage.value.secondary.resetAt !== undefined} · {t('ui.resetAt', { value: resetTime(snapshot.usage.value.secondary.resetAt) })}{/if}</p>{/if}
478
- {#if !snapshot.usage.value?.primary && !snapshot.usage.value?.secondary}<p class="text-sm text-zinc-400">{t('ui.noUsageWindows')}</p>{/if}
479
- {:else if snapshot.state === 'loading'}<LoadingIndicator label={t('ui.usageLoading')} size="sm" height="none" />
480
- {:else}<p class="text-sm text-zinc-400">{t(snapshot.error ? 'ui.usageFailed' : 'ui.usageSkipped')}</p>{/if}
481
- <div class="mt-auto border-t border-carbon-600 pt-3 space-y-2">
482
- <div class="flex items-center justify-between gap-2" data-testid="reset-heading">
483
- <div class="flex min-w-0 flex-wrap items-center gap-2"><span class="nx-field-label">{t('ui.resetCredits')}</span><span aria-hidden="true" class="text-zinc-500">·</span><span class="text-sm text-zinc-400"><span class="nx-display tabular-nums text-zinc-100">{count ?? '—'}</span> {t('ui.creditsAvailable')}</span></div>
484
- <button type="button" role="switch" aria-checked={account.autoResetCredits}
485
- aria-label={`${t('ui.autoResetLabel')}: ${account.label}`}
486
- title={t(account.autoResetCredits ? 'ui.autoResetEnabled' : 'ui.autoResetHelp')}
487
- disabled={actionBusy || account.status === 'revoked'} data-testid="auto-reset-toggle"
488
- class="inline-flex h-[24px] shrink-0 items-center gap-1.5 whitespace-nowrap text-xs text-zinc-400 transition-colors hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-nexus-500 focus-visible:ring-offset-2 focus-visible:ring-offset-carbon-950 disabled:cursor-not-allowed disabled:opacity-50"
489
- onclick={() => openAction('auto-reset', account)}>
490
- <span>{t('ui.autoResetLabel')}</span>
491
- <span aria-hidden="true" data-testid="auto-reset-track"
492
- class={`inline-flex h-[16px] w-[30px] shrink-0 items-center border bg-carbon-900 transition-colors ${account.autoResetCredits ? 'border-nexus-500' : 'border-carbon-600'}`}>
493
- <span class={`block h-[12px] w-[12px] transition-transform ${account.autoResetCredits ? 'translate-x-[15px] bg-nexus-500' : 'translate-x-px bg-carbon-500'}`}></span>
494
- </span>
495
- </button>
496
- </div>
497
- {#if count !== undefined || ('usage' in snapshot && snapshot.resetCredits.state !== 'unavailable') || pending}
505
+ <section class="space-y-3" data-testid="account-usage">
506
+ <div class="flex flex-wrap items-center justify-between gap-2">
507
+ <span class="nx-field-label">{t('ui.usageLabel')}</span>
508
+ <div class="flex flex-wrap items-center gap-2">
509
+ {#if ['failed', 'refreshFailed', 'stale', 'expired', 'partialExpired'].includes(status)}<StatusBadge variant={usageStatusVariant(status)}>{t(`ui.usageStatus.${status}`)}</StatusBadge>{/if}
510
+ <Button variant="ghost" size="sm" class="shrink-0" disabled={!eligibleForUsage(account) || usageRefreshing[account.id]} aria-busy={!!usageRefreshing[account.id]} aria-label={t('ui.refreshUsageAndCards')} title={t('ui.refreshUsageAndCards')} onclick={() => { if (!usageRefreshing[account.id]) void refreshUsage(account); }}>{@render actionIcon(RefreshCw, usageRefreshing[account.id])}{t('ui.refresh')}</Button>
511
+ </div>
512
+ </div>
513
+ {#if status === 'loading'}<LoadingIndicator label={t('ui.usageLoading')} size="sm" height="none" />
514
+ {:else if status === 'failed'}<p role="alert" class="text-sm text-red-300">{t('ui.usageLoadFailed')}</p>
515
+ {:else if status === 'refreshFailed'}<p role="alert" class="text-sm text-amber-300">{t('ui.usageRefreshFailed')}</p>
516
+ {:else if status === 'stale'}<p role="status" class="text-sm text-amber-300">{t('ui.usageStale')}</p>
517
+ {:else if status === 'unavailable'}<p role="status" class="text-sm text-zinc-400">{t('ui.usageUnavailable')}</p>
518
+ {:else if status === 'skipped'}<p role="status" class="text-sm text-zinc-400">{t('ui.accountUsageSkipped')}</p>
519
+ {:else if status === 'noWindows'}<p role="status" class="text-sm text-zinc-400">{t('ui.noUsageWindows')}</p>
520
+ {:else if status === 'expired'}<p role="status" class="text-sm text-amber-300">{t('ui.usageAllWindowsExpired')}</p>
521
+ {:else if status === 'partialExpired'}<p role="status" class="text-sm text-amber-300">{t('ui.usageSomeWindowsExpired')}</p>{/if}
522
+ {#if 'usage' in snapshot}
523
+ {#if snapshot.usage.value?.primary?.usedPercent !== undefined}
524
+ {@const primary = snapshot.usage.value.primary}
525
+ {#if windowExpired(primary)}<p role="status" class="text-sm text-amber-300">{t('ui.usageWindowExpired', { value: resetTime(primary.resetAt!) })}</p>
526
+ {:else}<MetricBar label={windowLabel(primary.windowSeconds)} value={primary.usedPercent} valueLabel={`${primary.usedPercent}% ${t('ui.used')}`} /><p class="tabular-nums text-xs text-zinc-400">{#if primary.windowSeconds !== undefined}{t('ui.window', { duration: formatWindow(primary.windowSeconds) })}{/if}{#if primary.resetAt !== undefined} · {t('ui.resetAt', { value: resetTime(primary.resetAt) })}{/if}</p>{/if}
527
+ {/if}
528
+ {#if snapshot.usage.value?.secondary?.usedPercent !== undefined}
529
+ {@const secondary = snapshot.usage.value.secondary}
530
+ {#if windowExpired(secondary)}<p role="status" class="text-sm text-amber-300">{t('ui.usageWindowExpired', { value: resetTime(secondary.resetAt!) })}</p>
531
+ {:else}<MetricBar label={windowLabel(secondary.windowSeconds)} value={secondary.usedPercent} valueLabel={`${secondary.usedPercent}% ${t('ui.used')}`} /><p class="tabular-nums text-xs text-zinc-400">{#if secondary.windowSeconds !== undefined}{t('ui.window', { duration: formatWindow(secondary.windowSeconds) })}{/if}{#if secondary.resetAt !== undefined} · {t('ui.resetAt', { value: resetTime(secondary.resetAt) })}{/if}</p>{/if}
532
+ {/if}
533
+ {/if}
534
+ </section>
535
+ <section class="space-y-2 border-t border-carbon-600 pt-3" data-testid="account-reset-cards">
536
+ <div class="flex flex-wrap items-center gap-2" data-testid="reset-heading">
537
+ <span class="nx-field-label">{t('ui.resetCards')}</span><span aria-hidden="true" class="text-zinc-500">·</span>
538
+ {#if count !== undefined}<span class="text-sm text-zinc-400">{t(staleCount ? 'ui.resetCardCountLastKnown' : 'ui.resetCardCountAvailable', { count })}</span>
539
+ {:else}<span role="status" class="text-sm text-zinc-400">{t(creditStatus === 'loading' ? 'ui.resetCardCountLoading' : 'ui.resetCardCountUnavailable')}</span>{/if}
540
+ </div>
541
+ {#if creditStatus === 'stale' || creditStatus === 'refreshFailed' || staleCount}<p role="status" class="text-sm text-amber-300">{t('ui.resetCardDataStale')}</p>
542
+ {:else if count === 0}<p class="text-sm text-zinc-400">{t('ui.noResetCards')}</p>
543
+ {:else if count !== undefined && credits.length < count && creditStatus === 'fresh'}<p class="text-sm text-amber-300">{t('ui.resetCardPartialDetails')}</p>
544
+ {:else if count !== undefined && credits.length === 0}<p class="text-sm text-zinc-400">{t('ui.resetCardDetailsUnavailable')}</p>{/if}
545
+ {#if pending || credits.length > 0}
498
546
  <!-- Native keyboard scrolling must also work when no credit action is available. -->
499
547
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
500
- <div class="credit-viewport h-32 overscroll-contain space-y-1 pr-1" data-testid="credit-viewport" role="region" aria-label={t('ui.resetCredits')} tabindex="0">
501
- {#if pending}<div class="border border-amber-700/60 bg-carbon-900 p-2 space-y-2"><p class="text-sm text-amber-300">{t('ui.resetPending')}</p><p class="text-sm text-zinc-300">{resetTypeLabel(pending.credit.resetType)}</p><Button variant="outline" size="sm" title={t('ui.resolveUnknownReset')} aria-label={t('ui.resolveUnknownReset')} onclick={() => openPendingReset(account)}>{@render actionIcon(RefreshCw)}{t('ui.resolveReset')}</Button></div>{/if}
502
- {#if count === 0}<p class="text-sm text-zinc-400">{t('ui.noResetCredits')}</p>{/if}
503
- {#if !('usage' in snapshot) || snapshot.resetCredits.state !== 'fresh' || !snapshot.resetCredits.value}<p class="text-sm text-zinc-400">{t('ui.creditDetailsUnavailable')}</p>
504
- {:else if snapshot.resetCredits.value && snapshot.resetCredits.value.credits.length < snapshot.resetCredits.value.availableCount}<p class="text-sm text-amber-300">{t('ui.creditsPartial')}</p>{/if}
505
- {#each credits as credit (credit.creditId)}
506
- <div class="flex items-center gap-3 py-1.5 border-b border-carbon-600/50 last:border-b-0" data-testid="reset-credit">
507
- <div class="min-w-0 flex-1 space-y-1 text-sm text-zinc-300"><div class="flex flex-wrap items-baseline gap-x-2"><span>{resetTypeLabel(credit.resetType)}</span><span class={`text-xs ${credit.status === 'available' ? 'text-emerald-400' : 'text-zinc-400'}`}>{t(`ui.credit.${credit.status}`)}</span></div><p class="break-words tabular-nums text-xs text-zinc-400">{#if credit.expiresAt !== undefined}{t('ui.creditExpiry', { date: dateText(credit.expiresAt) })}{:else}{t('ui.creditExpiryUnknown')}{/if}</p></div>
508
- {#if canReset(account, credit)}<Button variant="outline" size="sm" class="shrink-0 border-carbon-500" title={t('ui.resetConfirmTitle')} onclick={() => openReset(account, credit)}>{@render actionIcon(RefreshCw)}{t('ui.useCredit')}</Button>{/if}
509
- </div>
510
- {/each}
548
+ <div class="credit-viewport max-h-32 overscroll-contain space-y-1 pr-1" data-testid="credit-viewport" role="region" aria-label={t('ui.resetCards')} tabindex="0">
549
+ {#if pending}<div class="border border-amber-700/60 bg-carbon-900 p-2 space-y-2"><p class="text-sm text-amber-300">{t('ui.resetPending')}</p><p class="text-sm text-zinc-300">{resetTypeLabel(pending.credit.resetType)}</p><Button variant="outline" size="sm" title={t('ui.resolveUnknownReset')} aria-label={t('ui.resolveUnknownReset')} onclick={() => openPendingReset(account)}>{@render actionIcon(RefreshCw)}{t('ui.resolveReset')}</Button></div>{/if}
550
+ {#each credits as credit (credit.creditId)}
551
+ <div class="flex items-center gap-3 py-1.5 border-b border-carbon-600/50 last:border-b-0" data-testid="reset-credit">
552
+ <div class="min-w-0 flex-1 space-y-1 text-sm text-zinc-300"><div class="flex flex-wrap items-baseline gap-x-2"><span>{resetTypeLabel(credit.resetType)}</span><span class={`text-xs ${credit.status === 'available' ? 'text-emerald-400' : 'text-zinc-400'}`}>{t(`ui.credit.${credit.status}`)}</span></div><p class="break-words tabular-nums text-xs text-zinc-400">{#if credit.expiresAt !== undefined}{t('ui.creditExpiry', { date: dateText(credit.expiresAt) })}{:else}{t('ui.creditExpiryUnknown')}{/if}</p></div>
553
+ {#if canReset(account, credit)}<Button variant="outline" size="sm" class="shrink-0 border-carbon-500" title={t('ui.resetConfirmTitle')} onclick={() => openReset(account, credit)}>{@render actionIcon(RefreshCw)}{t('ui.useCredit')}</Button>{/if}
554
+ </div>
555
+ {/each}
511
556
  </div>
512
- {:else}<p class="text-sm text-zinc-400">{t('ui.creditDetailsUnavailable')}</p>{/if}
513
- </div>
557
+ {/if}
558
+ </section>
559
+ <section class="space-y-2 border-t border-carbon-600 pt-3" data-testid="account-reset-strategies">
560
+ <div class="flex flex-wrap items-center justify-between gap-2">
561
+ <span class="nx-field-label">{t('ui.autoResetSettingsTitle')}</span>
562
+ <Button variant="outline" size="sm" class="shrink-0" disabled={actionBusy || account.status === 'revoked'} data-testid="auto-reset-settings"
563
+ onclick={() => openAction('auto-reset', account)}>{@render actionIcon(Pencil)}{t('ui.configureAutoReset')}</Button>
564
+ </div>
565
+ <div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
566
+ <p class="text-xs text-zinc-300" data-testid="auto-reset-status">{t(account.autoResetCredits ? 'ui.autoResetEnabled' : 'ui.autoResetDisabled', { minutes: account.autoResetLeadMinutes })}</p>
567
+ <p class="text-xs text-zinc-300">{t(account.autoResetOnLowBalance ? 'ui.autoResetLowBalanceEnabled' : 'ui.autoResetLowBalanceDisabled', { quota, percent: account.autoResetRemainingPercent })}</p>
568
+ </div>
569
+ </section>
514
570
  {/if}
515
571
  <div class="flex flex-wrap items-center justify-end gap-2" data-testid="account-actions">
516
572
  <Button variant="secondary" size="sm" disabled={!account.available} onclick={() => openUse(account)}>{@render actionIcon(Server)}{t('ui.useService')}</Button>
@@ -620,15 +676,27 @@
620
676
  </IndustrialDialog>
621
677
 
622
678
  {#snippet accountActionFooter()}
623
- <Button form={`${id}-account-action`} type="submit" variant={action === 'delete' ? 'destructive' : 'default'} disabled={actionBusy} aria-busy={actionBusy}>{@render actionIcon(actionIcons[action] ?? Check, actionBusy)}{t(action === 'delete' ? 'ui.confirmDelete' : 'ui.confirm')}</Button>
679
+ <Button form={`${id}-account-action`} type="submit" variant={action === 'delete' ? 'destructive' : 'default'} disabled={actionBusy} aria-busy={actionBusy}>{@render actionIcon(actionIcons[action] ?? Check, actionBusy)}{t(action === 'delete' ? 'ui.confirmDelete' : action === 'auto-reset' ? 'ui.saveAutoResetSettings' : 'ui.confirm')}</Button>
624
680
  {/snippet}
625
681
 
626
682
  <IndustrialDialog bind:open={actionOpen} busy={actionBusy} scrollBody closeLabel={t('ui.close')} footer={action === 'references' ? undefined : accountActionFooter}
627
- title={t(action === 'auto-reset' ? (actionAccount?.autoResetCredits ? 'ui.disableAutoReset' : 'ui.enableAutoReset') : actionTitles[action] ?? 'ui.more')} description={actionAccount?.label ?? ''}>
683
+ title={t(action === 'auto-reset' ? 'ui.autoResetSettingsTitle' : actionTitles[action] ?? 'ui.more')} description={actionAccount?.label ?? ''}>
628
684
  {#snippet body()}
685
+ {@const quota = t(`ui.quotaCycle.${actionAccount?.quotaCycle ?? 'weekOrMonth'}`)}
629
686
  <form id={`${id}-account-action`} onsubmit={confirmAction} class="space-y-4">
630
687
  {#if action === 'rename'}<label class="block space-y-1.5"><span class="nx-field-label">{t('ui.accountName')}</span><Input bind:value={label} maxlength={128} required /></label>{/if}
631
- {#if action === 'auto-reset'}<p class="text-sm text-zinc-300">{t(actionAccount?.autoResetCredits ? 'ui.disableAutoResetHelp' : 'ui.autoResetHelp')}</p>{/if}
688
+ {#if action === 'auto-reset'}
689
+ <div class="space-y-3">
690
+ <div class="flex items-center justify-between gap-3"><span class="nx-field-label">{t('ui.nearExpiryStrategy')}</span><IndustrialToggle bind:checked={autoResetLeadEnabled} label={t('ui.nearExpiryStrategy')} /></div>
691
+ <label class="block space-y-1.5"><span class="nx-field-label">{t('ui.leadMinutes')}</span><NumberInput bind:value={autoResetLeadMinutes} min={1} max={10080} required
692
+ increaseLabel={t('ui.numberIncrease')} decreaseLabel={t('ui.numberDecrease')} invalidMessage={t('ui.invalidNumber')} /></label>
693
+ <div class="flex items-center justify-between gap-3"><span class="nx-field-label">{t('ui.lowBalanceStrategy', { quota })}</span><IndustrialToggle bind:checked={autoResetLowBalanceEnabled} label={t('ui.lowBalanceStrategy', { quota })} /></div>
694
+ <label class="block space-y-1.5"><span class="nx-field-label">{t('ui.remainingPercent', { quota })}</span><NumberInput bind:value={autoResetRemainingPercent} min={0} max={100} required
695
+ increaseLabel={t('ui.numberIncrease')} decreaseLabel={t('ui.numberDecrease')} invalidMessage={t('ui.invalidNumber')} /></label>
696
+ <p class="text-sm text-zinc-400">{t('ui.autoResetSettingsHelp', { quota })}</p>
697
+ <p class="border border-amber-700/60 bg-carbon-900 p-3 text-sm text-amber-300">{t('ui.autoResetConsumptionWarning')}</p>
698
+ </div>
699
+ {/if}
632
700
  {#if ['delete', 'disable'].includes(action)}<p role="alert" class="text-sm text-amber-300">{t('ui.dangerHelp')}</p>{/if}
633
701
  {#if referenceNotice}<p role="status" class="text-sm text-zinc-400">{t(referenceNotice)}</p>{/if}
634
702
  {#if references}
@@ -38,6 +38,12 @@ function record(value) {
38
38
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid_response');
39
39
  return /** @type {Record<string, unknown>} */ (value);
40
40
  }
41
+ function quotaCycle(plan) {
42
+ const value = typeof plan === 'string' ? plan.trim().toLowerCase() : '';
43
+ if (value === 'free') return 'month';
44
+ if (value === 'plus' || value === 'pro') return 'week';
45
+ return 'weekOrMonth';
46
+ }
41
47
  /** @param {unknown} input @param {string} sessionId @param {string} [expectedKind] */
42
48
  export function loginStatus(input, sessionId, expectedKind) {
43
49
  const value = record(input);
@@ -54,6 +60,12 @@ export function accountSummary(input) {
54
60
  const authType = value.authType === undefined ? 'codex' : value.authType;
55
61
  if (authType !== 'codex' && authType !== 'siwc') throw new Error('invalid_response');
56
62
  if (value.autoResetCredits !== undefined && typeof value.autoResetCredits !== 'boolean') throw new Error('invalid_response');
63
+ const autoResetLeadMinutes = value.autoResetLeadMinutes === undefined ? 30 : value.autoResetLeadMinutes;
64
+ const autoResetOnLowBalance = value.autoResetOnLowBalance === undefined ? false : value.autoResetOnLowBalance;
65
+ const autoResetRemainingPercent = value.autoResetRemainingPercent === undefined ? 10 : value.autoResetRemainingPercent;
66
+ if (!Number.isSafeInteger(autoResetLeadMinutes) || autoResetLeadMinutes < 1 || autoResetLeadMinutes > 10080
67
+ || typeof autoResetOnLowBalance !== 'boolean'
68
+ || !Number.isSafeInteger(autoResetRemainingPercent) || autoResetRemainingPercent < 0 || autoResetRemainingPercent > 100) throw new Error('invalid_response');
57
69
  let pendingAutoReset;
58
70
  if (value.pendingAutoReset !== undefined) {
59
71
  const pending = record(value.pendingAutoReset);
@@ -64,11 +76,14 @@ export function accountSummary(input) {
64
76
  || pending.completed !== false) throw new Error('invalid_response');
65
77
  pendingAutoReset = { creditId, redeemRequestId, expiresAt };
66
78
  }
79
+ const plan = typeof identity.planType === 'string' ? identity.planType : undefined;
67
80
  return { id: value.id, label: value.label, status: value.status, available: value.available, authType,
68
- autoResetCredits: authType === 'codex' && value.autoResetCredits === true, pendingAutoReset: authType === 'codex' ? pendingAutoReset : undefined,
81
+ autoResetCredits: authType === 'codex' && value.autoResetCredits === true,
82
+ autoResetLeadMinutes, autoResetOnLowBalance: authType === 'codex' && autoResetOnLowBalance, autoResetRemainingPercent,
83
+ pendingAutoReset: authType === 'codex' ? pendingAutoReset : undefined,
69
84
  expiresAt: Number.isFinite(value.expiresAt) ? value.expiresAt : undefined,
70
85
  email: typeof identity.email === 'string' ? identity.email : undefined,
71
- plan: typeof identity.planType === 'string' ? identity.planType : undefined };
86
+ plan, quotaCycle: quotaCycle(plan) };
72
87
  }
73
88
  /** @param {{authType?: string}} account */
74
89
  export const accountSourceId = account => account.authType === 'siwc' ? 'chatgpt-siwc' : 'chatgpt';
@@ -153,6 +168,25 @@ export function accountUsage(input) {
153
168
  const value = record(input);
154
169
  return { usage: usageResource(value.usage), resetCredits: resetCreditsResource(value.resetCredits) };
155
170
  }
171
+ /** @param {ReturnType<typeof accountUsage>|{state:'loading'|'unavailable'}} snapshot @param {boolean} [requestFailed] @param {number} [now] */
172
+ export function usageDisplayState(snapshot, requestFailed = false, now = Date.now()) {
173
+ if (requestFailed) return 'usage' in snapshot && (snapshot.usage.value || snapshot.resetCredits.value) ? 'refreshFailed' : 'failed';
174
+ if (!('usage' in snapshot)) return snapshot.state;
175
+ if (snapshot.usage.state === 'stale') return 'stale';
176
+ if (snapshot.usage.state === 'unavailable') return 'unavailable';
177
+ const windows = [snapshot.usage.value?.primary, snapshot.usage.value?.secondary]
178
+ .filter(window => typeof window?.usedPercent === 'number' && Number.isFinite(window.usedPercent));
179
+ if (!windows.length) return 'noWindows';
180
+ const expired = windows.filter(window => typeof window.resetAt === 'number' && window.resetAt <= now).length;
181
+ if (expired === windows.length) return 'expired';
182
+ return expired ? 'partialExpired' : 'fresh';
183
+ }
184
+ /** @param {ReturnType<typeof accountUsage>|{state:'loading'|'unavailable'}} snapshot @param {boolean} [requestFailed] */
185
+ export function resetCardDisplayState(snapshot, requestFailed = false) {
186
+ if (requestFailed) return 'usage' in snapshot && snapshot.resetCredits.value ? 'refreshFailed' : 'failed';
187
+ if (!('usage' in snapshot)) return snapshot.state;
188
+ return snapshot.resetCredits.state === 'fresh' && !snapshot.resetCredits.value ? 'unavailable' : snapshot.resetCredits.state;
189
+ }
156
190
  /** @param {unknown} input */
157
191
  export function resetOutcome(input) {
158
192
  const value = record(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffusion/bungee-core",
3
- "version": "6.9.0",
3
+ "version": "6.10.0",
4
4
  "description": "Bungee reverse proxy core engine",
5
5
  "main": "./dist/main.js",
6
6
  "types": "./dist/main.d.ts",
@@ -16,7 +16,7 @@
16
16
  "@iconify-json/lucide": "^1.2.145",
17
17
  "@iconify-json/tabler": "^1.2.42",
18
18
  "@iconify/utils": "^3.1.7",
19
- "@jeffusion/bungee-types": "^6.9.0",
19
+ "@jeffusion/bungee-types": "^6.10.0",
20
20
  "@types/lodash-es": "^4.17.12",
21
21
  "@types/ws": "^8.18.1",
22
22
  "acorn": "^8.15.0",