@goodandready/dsh-subscriptions 0.6.71 → 0.6.82
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.
- package/README.md +13 -0
- package/README.ru.md +13 -0
- package/README.zh.md +13 -0
- package/lib/accounts-view.js +101 -5
- package/lib/accounts.js +150 -11
- package/lib/adapter.js +3 -0
- package/lib/alerts.js +35 -0
- package/lib/analytics.js +31 -2
- package/lib/billing.js +99 -0
- package/lib/blob.js +18 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +944 -828
- package/lib/config-schema.js +46 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/doctor.js +163 -0
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +379 -0
- package/lib/history.js +36 -0
- package/lib/http.js +26 -4
- package/lib/index.js +98 -11
- package/lib/instructions.js +138 -0
- package/lib/jwt.js +84 -17
- package/lib/loopback.js +3 -2
- package/lib/mask.js +16 -0
- package/lib/oauth-pending.js +12 -0
- package/lib/pricing.js +89 -0
- package/lib/refs.js +2 -0
- package/lib/reset-clock.js +55 -0
- package/lib/rotate.js +18 -4
- package/lib/routes/accounts.js +203 -0
- package/lib/routes/oauth.js +44 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/routes/status.js +99 -5
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +412 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +11 -2
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +8 -1
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +133 -13
- package/lib/vendors/perplexity.js +148 -17
- package/package.json +1 -1
package/lib/config-schema.js
CHANGED
|
@@ -20,6 +20,8 @@ export const Slot = z.object({
|
|
|
20
20
|
.description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
|
|
21
21
|
proxyUrl: z.string().default('')
|
|
22
22
|
.description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
|
|
23
|
+
maxConcurrency: z.number().default(1)
|
|
24
|
+
.description('#555 Per-slot max concurrent requests before queuing. Defaults to 1 for subscription safety.'),
|
|
23
25
|
})
|
|
24
26
|
|
|
25
27
|
export const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
|
|
@@ -86,6 +88,10 @@ export const Config = z.object({
|
|
|
86
88
|
antigravityClientSecret: z.string().role('secret').default('')
|
|
87
89
|
.description('Google OAuth client secret for custom confidential clients or CLI tokens requiring secret refresh.'),
|
|
88
90
|
antigravityRedirectUri: z.string().volatile().default(''),
|
|
91
|
+
perplexityClientId: z.string().volatile().default(''),
|
|
92
|
+
perplexityRedirectUri: z.string().volatile().default(''),
|
|
93
|
+
jetbrainsClientId: z.string().volatile().default(''),
|
|
94
|
+
jetbrainsRedirectUri: z.string().volatile().default(''),
|
|
89
95
|
antigravityModels: z.array(z.any()).volatile().default([])
|
|
90
96
|
.description('Custom model catalog for Antigravity.'),
|
|
91
97
|
codexOriginator: z.string().volatile().default('')
|
|
@@ -130,12 +136,48 @@ export const Config = z.object({
|
|
|
130
136
|
.description('#485 Safety margin percent: avoid draining accounts below this threshold when alternatives exist.'),
|
|
131
137
|
enableQueue: z.boolean().volatile().default(true)
|
|
132
138
|
.description('#518 Bounded concurrency queue to prevent bursts of 429 errors.'),
|
|
133
|
-
accountMaxConcurrency: z.number().volatile().default(
|
|
134
|
-
.description('#518
|
|
139
|
+
accountMaxConcurrency: z.number().volatile().default(1)
|
|
140
|
+
.description('#518 #555 Default max concurrent requests per account before queuing (1 for OAuth subscription safety).'),
|
|
135
141
|
maxQueueSize: z.number().volatile().default(20)
|
|
136
142
|
.description('#518 Max requests waiting in queue before reject.'),
|
|
137
143
|
queueTimeoutMs: z.number().volatile().default(30000)
|
|
138
144
|
.description('#518 Max queue wait time in ms before timeout.'),
|
|
145
|
+
masterKey: z.string().role('secret').default('')
|
|
146
|
+
.description('#550 Optional master passphrase for encrypting OAuth credentials at rest (defaults to DSH_SUBSCRIPTIONS_MASTER_KEY env).'),
|
|
147
|
+
vaultSyncEnabled: z.boolean().volatile().default(false)
|
|
148
|
+
.description('#562 Enable automated encrypted vault sync to remote storage.'),
|
|
149
|
+
vaultSyncBackend: z.string().volatile().default('webdav')
|
|
150
|
+
.description('#562 Remote storage backend: webdav, gist, or s3.'),
|
|
151
|
+
vaultSyncPassphrase: z.string().role('secret').default('')
|
|
152
|
+
.description('#562 Passphrase used to encrypt/decrypt the remote vault archive.'),
|
|
153
|
+
vaultSyncIntervalHours: z.number().volatile().default(24)
|
|
154
|
+
.description('#562 Automated periodic sync interval in hours (0 disables periodic pull).'),
|
|
155
|
+
vaultSyncAutoPush: z.boolean().volatile().default(true)
|
|
156
|
+
.description('#562 Automatically push encrypted vault after successful OAuth login/refresh.'),
|
|
157
|
+
vaultSyncWebdavUrl: z.string().volatile().default('')
|
|
158
|
+
.description('#562 WebDAV target URL.'),
|
|
159
|
+
vaultSyncWebdavUsername: z.string().volatile().default('')
|
|
160
|
+
.description('#562 WebDAV username.'),
|
|
161
|
+
vaultSyncWebdavPassword: z.string().role('secret').default('')
|
|
162
|
+
.description('#562 WebDAV password or app token.'),
|
|
163
|
+
vaultSyncGistId: z.string().volatile().default('')
|
|
164
|
+
.description('#562 GitHub or Gitea Gist ID.'),
|
|
165
|
+
vaultSyncGistToken: z.string().role('secret').default('')
|
|
166
|
+
.description('#562 Personal access token with gist permission.'),
|
|
167
|
+
vaultSyncGistBaseUrl: z.string().volatile().default('https://api.github.com')
|
|
168
|
+
.description('#562 Gist API base URL.'),
|
|
169
|
+
vaultSyncS3Endpoint: z.string().volatile().default('')
|
|
170
|
+
.description('#562 S3 endpoint URL.'),
|
|
171
|
+
vaultSyncS3Bucket: z.string().volatile().default('')
|
|
172
|
+
.description('#562 S3 bucket name.'),
|
|
173
|
+
vaultSyncS3Key: z.string().volatile().default('dsh-subscriptions-vault.enc')
|
|
174
|
+
.description('#562 S3 object key.'),
|
|
175
|
+
vaultSyncS3Region: z.string().volatile().default('us-east-1')
|
|
176
|
+
.description('#562 S3 region.'),
|
|
177
|
+
vaultSyncS3AccessKey: z.string().volatile().default('')
|
|
178
|
+
.description('#562 S3 Access Key ID.'),
|
|
179
|
+
vaultSyncS3SecretKey: z.string().role('secret').default('')
|
|
180
|
+
.description('#562 S3 Secret Access Key.'),
|
|
139
181
|
customVendors: z.array(z.any()).volatile().default([])
|
|
140
182
|
.description('Declarative OpenAI-Responses-compatible providers. See README.'),
|
|
141
183
|
})
|
|
@@ -145,7 +187,7 @@ export function publicConfig(cfg) {
|
|
|
145
187
|
const clone = { ...cfg }
|
|
146
188
|
// #242, #252: redact secret fields before exposing config via GET /config.
|
|
147
189
|
for (const key of Object.keys(clone)) {
|
|
148
|
-
if (key.endsWith("ClientSecret") || key.endsWith("Secret")) {
|
|
190
|
+
if (key.endsWith("ClientSecret") || key.endsWith("Secret") || key.endsWith("SecretKey") || key.endsWith("Password") || key.endsWith("Token") || key.endsWith("Passphrase") || key === "masterKey") {
|
|
149
191
|
clone[key] = clone[key] ? "••••••" : ""
|
|
150
192
|
}
|
|
151
193
|
}
|
|
@@ -199,7 +241,7 @@ export function restoreMaskedSecrets(incoming, existing) {
|
|
|
199
241
|
const out = { ...incoming }
|
|
200
242
|
|
|
201
243
|
for (const key of Object.keys(out)) {
|
|
202
|
-
if ((key.endsWith("ClientSecret") || key.endsWith("Secret")) && typeof out[key] === "string") {
|
|
244
|
+
if ((key.endsWith("ClientSecret") || key.endsWith("Secret") || key.endsWith("SecretKey") || key.endsWith("Password") || key.endsWith("Token") || key.endsWith("Passphrase") || key === "masterKey") && typeof out[key] === "string") {
|
|
203
245
|
if (out[key].includes("••••")) {
|
|
204
246
|
out[key] = existing[key] || ""
|
|
205
247
|
}
|
package/lib/diagnostic-wizard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Guided Diagnostic Wizard for accounts, credentials, and network connectivity (#519).
|
|
2
2
|
// Enforces 0 Cyrillic characters.
|
|
3
3
|
|
|
4
|
-
import { maskEmail,
|
|
4
|
+
import { maskEmail, maskSecrets, cleanErrorMessage } from './mask.js'
|
|
5
5
|
import { getVendor } from './vendors/index.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -11,10 +11,7 @@ import { getVendor } from './vendors/index.js'
|
|
|
11
11
|
*/
|
|
12
12
|
export function sanitizeDiagnosticData(val) {
|
|
13
13
|
if (typeof val === 'string') {
|
|
14
|
-
return
|
|
15
|
-
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [REDACTED]')
|
|
16
|
-
.replace(/key=[A-Za-z0-9._~+/-]+/gi, 'key=[REDACTED]')
|
|
17
|
-
.replace(/token=[A-Za-z0-9._~+/-]+/gi, 'token=[REDACTED]')
|
|
14
|
+
return maskSecrets(val)
|
|
18
15
|
}
|
|
19
16
|
if (Array.isArray(val)) {
|
|
20
17
|
return val.map(sanitizeDiagnosticData)
|
package/lib/diagnostics.js
CHANGED
|
@@ -3,13 +3,13 @@ import { getSessionReasoningStats } from './reasoning-effort.js'
|
|
|
3
3
|
import { defaultConcurrencyQueue } from './concurrency-queue.js'
|
|
4
4
|
import { calculateCacheMetrics } from './analytics.js'
|
|
5
5
|
import { getSessionPinStats } from './session-pin.js'
|
|
6
|
-
import {
|
|
6
|
+
import { maskSecrets } from './mask.js'
|
|
7
7
|
import { normalizeSlots } from './accounts.js'
|
|
8
8
|
import { PROVIDERS } from './refs.js'
|
|
9
9
|
import { defaultErrorBuffer } from './error-buffer.js'
|
|
10
10
|
|
|
11
11
|
function scrubReport(v) {
|
|
12
|
-
if (typeof v === 'string') return
|
|
12
|
+
if (typeof v === 'string') return maskSecrets(v)
|
|
13
13
|
if (Array.isArray(v)) return v.map(scrubReport)
|
|
14
14
|
if (v && typeof v === 'object') {
|
|
15
15
|
const o = {}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor Auth Doctor: Diagnose authentication, authorization, quota, and network errors
|
|
3
|
+
* into structured, human-understandable recommendations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DOCTOR_CODES = {
|
|
7
|
+
GEO_BLOCKED: 'GEO_BLOCKED',
|
|
8
|
+
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
|
|
9
|
+
TOKEN_REVOKED: 'TOKEN_REVOKED',
|
|
10
|
+
MISSING_SCOPE: 'MISSING_SCOPE',
|
|
11
|
+
QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
|
|
12
|
+
RATE_LIMIT: 'RATE_LIMIT',
|
|
13
|
+
PROXY_ERROR: 'PROXY_ERROR',
|
|
14
|
+
UNKNOWN: 'UNKNOWN',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Classifies vendor errors and generates actionable recommendations.
|
|
19
|
+
* @param {any} err
|
|
20
|
+
* @param {string} [provider]
|
|
21
|
+
* @param {any} [_account]
|
|
22
|
+
* @returns {{ code: string, title: string, details: string, recommendation: string } | null}
|
|
23
|
+
*/
|
|
24
|
+
export function diagnoseVendorError(err, provider = '', _account = null) {
|
|
25
|
+
if (!err) return null
|
|
26
|
+
|
|
27
|
+
const msg = String(err.message || err.error || err || '').toLowerCase()
|
|
28
|
+
const code = String(err.code || err.status || err.statusCode || '').toUpperCase()
|
|
29
|
+
const p = String(provider || '').toLowerCase()
|
|
30
|
+
|
|
31
|
+
// 1. Geo-blocking / Cloudflare Firewall (403, 1020, region forbidden)
|
|
32
|
+
if (
|
|
33
|
+
msg.includes('country') ||
|
|
34
|
+
msg.includes('location') ||
|
|
35
|
+
msg.includes('region') ||
|
|
36
|
+
msg.includes('not supported in your country') ||
|
|
37
|
+
msg.includes('cloudflare') ||
|
|
38
|
+
msg.includes('cf-ray') ||
|
|
39
|
+
msg.includes('error 1020') ||
|
|
40
|
+
msg.includes('access denied') ||
|
|
41
|
+
(code === '403' && (msg.includes('forbidden') || msg.includes('blocked') || msg.includes('geoblock')))
|
|
42
|
+
) {
|
|
43
|
+
return {
|
|
44
|
+
code: DOCTOR_CODES.GEO_BLOCKED,
|
|
45
|
+
title: 'Region or IP Blocked by Vendor Firewall',
|
|
46
|
+
details: `The vendor (${p || 'provider'}) returned a geo-restriction or Cloudflare block.`,
|
|
47
|
+
recommendation: 'Configure a dedicated SOCKS5 or HTTP proxy located in a supported region (US, EU, JP) in proxy settings.',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Token Expired / Session Expired
|
|
52
|
+
if (
|
|
53
|
+
msg.includes('token_expired') ||
|
|
54
|
+
msg.includes('jwt expired') ||
|
|
55
|
+
msg.includes('invalid_grant') ||
|
|
56
|
+
msg.includes('session expired') ||
|
|
57
|
+
msg.includes('refresh token expired') ||
|
|
58
|
+
msg.includes('token has expired') ||
|
|
59
|
+
msg.includes('login_required')
|
|
60
|
+
) {
|
|
61
|
+
return {
|
|
62
|
+
code: DOCTOR_CODES.TOKEN_EXPIRED,
|
|
63
|
+
title: 'OAuth Access or Refresh Token Expired',
|
|
64
|
+
details: 'The stored OAuth credentials have expired and could not be refreshed automatically.',
|
|
65
|
+
recommendation: 'Click "Connect" or "Device Login" to re-authorize the account and establish a fresh session.',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 3. Token Revoked / Bad Credentials
|
|
70
|
+
if (
|
|
71
|
+
msg.includes('token_revoked') ||
|
|
72
|
+
msg.includes('revoked') ||
|
|
73
|
+
msg.includes('account_deactivated') ||
|
|
74
|
+
msg.includes('invalid_token') ||
|
|
75
|
+
msg.includes('bad_credentials') ||
|
|
76
|
+
code === 'TOKEN_REVOKED' ||
|
|
77
|
+
(code === '401' && (msg.includes('unauthorized') || msg.includes('invalid api key') || msg.includes('incorrect api key')))
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
code: DOCTOR_CODES.TOKEN_REVOKED,
|
|
81
|
+
title: 'Token Invalid or Revoked',
|
|
82
|
+
details: 'The vendor rejected the API key or OAuth token as invalid or revoked.',
|
|
83
|
+
recommendation: 'Re-issue a fresh session token, API key, or CLI credentials in the vendor dashboard and save it.',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. Missing Scope / Permissions
|
|
88
|
+
if (
|
|
89
|
+
msg.includes('insufficient_scope') ||
|
|
90
|
+
msg.includes('missing_scope') ||
|
|
91
|
+
msg.includes('scope required') ||
|
|
92
|
+
msg.includes('permission_denied') ||
|
|
93
|
+
msg.includes('model_not_found') ||
|
|
94
|
+
msg.includes('not have access') ||
|
|
95
|
+
msg.includes('license_required')
|
|
96
|
+
) {
|
|
97
|
+
return {
|
|
98
|
+
code: DOCTOR_CODES.MISSING_SCOPE,
|
|
99
|
+
title: 'Missing Scope or Model Permissions',
|
|
100
|
+
details: 'The account does not have sufficient entitlements or required scope permissions for this model.',
|
|
101
|
+
recommendation: 'Check your organization role or subscription tier to ensure permissions and access to requested models is granted.',
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 5. Quota Exhausted / Balance Zero
|
|
106
|
+
if (
|
|
107
|
+
msg.includes('insufficient_quota') ||
|
|
108
|
+
msg.includes('exceeded your current quota') ||
|
|
109
|
+
msg.includes('credit_balance_zero') ||
|
|
110
|
+
msg.includes('billing_not_active') ||
|
|
111
|
+
msg.includes('quota exceeded') ||
|
|
112
|
+
msg.includes('run out of credits') ||
|
|
113
|
+
code === 'QUOTA'
|
|
114
|
+
) {
|
|
115
|
+
return {
|
|
116
|
+
code: DOCTOR_CODES.QUOTA_EXHAUSTED,
|
|
117
|
+
title: 'Subscription Quota or Credits Exhausted',
|
|
118
|
+
details: 'The account has consumed 100% of its current billing cycle or token usage quota.',
|
|
119
|
+
recommendation: 'Check your subscription billing dashboard or wait until the quota window resets.',
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 6. Rate Limit / 429
|
|
124
|
+
if (
|
|
125
|
+
code === '429' ||
|
|
126
|
+
code === 'RATE_LIMIT' ||
|
|
127
|
+
msg.includes('too many requests') ||
|
|
128
|
+
msg.includes('rate_limit') ||
|
|
129
|
+
msg.includes('rate limit')
|
|
130
|
+
) {
|
|
131
|
+
return {
|
|
132
|
+
code: DOCTOR_CODES.RATE_LIMIT,
|
|
133
|
+
title: 'Temporary Rate Limit Reached',
|
|
134
|
+
details: 'The vendor is throttling requests (RPM/TPM limit reached) for this account.',
|
|
135
|
+
recommendation: 'Account is placed in temporary cooldown. Requests will automatically route to another account.',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 7. Network / Proxy Connectivity
|
|
140
|
+
if (
|
|
141
|
+
msg.includes('econnrefused') ||
|
|
142
|
+
msg.includes('etimedout') ||
|
|
143
|
+
msg.includes('enotfound') ||
|
|
144
|
+
msg.includes('fetch failed') ||
|
|
145
|
+
msg.includes('socks') ||
|
|
146
|
+
code === 'ENOTFOUND' ||
|
|
147
|
+
code === 'ETIMEDOUT'
|
|
148
|
+
) {
|
|
149
|
+
return {
|
|
150
|
+
code: DOCTOR_CODES.PROXY_ERROR,
|
|
151
|
+
title: 'Network or Proxy Connection Failure',
|
|
152
|
+
details: 'Failed to establish TCP/TLS connection with the provider API or configured proxy server.',
|
|
153
|
+
recommendation: 'Check server network access, DNS resolution, and verify the configured SOCKS/HTTP proxy is online.',
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
code: DOCTOR_CODES.UNKNOWN,
|
|
159
|
+
title: 'Unexpected Vendor Error',
|
|
160
|
+
details: msg.slice(0, 200) || 'Unknown error occurred during vendor request.',
|
|
161
|
+
recommendation: 'Review vendor API status or re-check account credentials.',
|
|
162
|
+
}
|
|
163
|
+
}
|
package/lib/error-buffer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cleanErrorMessage,
|
|
1
|
+
import { cleanErrorMessage, maskSecrets } from './mask.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Ring buffer holding the most recent runtime errors across vendors and streams.
|
|
@@ -19,7 +19,7 @@ export class ErrorRingBuffer {
|
|
|
19
19
|
* @param {{ provider?: string, ref?: string, model?: string, status?: number|null, code?: string, message?: string }} [param0]
|
|
20
20
|
*/
|
|
21
21
|
record({ provider = 'unknown', ref = '', model = '', status = null, code = '', message = '' } = {}) {
|
|
22
|
-
const rawMsg = cleanErrorMessage(
|
|
22
|
+
const rawMsg = cleanErrorMessage(maskSecrets(message))
|
|
23
23
|
const entry = {
|
|
24
24
|
id: String(this._nextId++),
|
|
25
25
|
ts: new Date().toISOString(),
|
package/lib/forecast.js
CHANGED
|
@@ -141,3 +141,59 @@ export function calculateBurnUrgency(remainingPercent, resetAt, nowMs = Date.now
|
|
|
141
141
|
const clampedHours = Math.max(hoursUntilReset, 0.05)
|
|
142
142
|
return rem / clampedHours
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* #559: Calculates remaining runway duration and pacing status based on burn rate.
|
|
147
|
+
* @param {number|null} remainingPercent
|
|
148
|
+
* @param {number} pacePerHour
|
|
149
|
+
* @param {number|null} [_resetAt]
|
|
150
|
+
* @param {number} [_nowMs]
|
|
151
|
+
* @returns {{ runwayMs: number|null, runwayHours: number|null, runwayText: string, pacingStatus: string }}
|
|
152
|
+
*/
|
|
153
|
+
export function calculatePacingRunway(remainingPercent, pacePerHour, _resetAt = null, _nowMs = Date.now()) {
|
|
154
|
+
if (remainingPercent === null || remainingPercent === undefined) {
|
|
155
|
+
return { runwayMs: null, runwayHours: null, runwayText: '', pacingStatus: 'unknown' }
|
|
156
|
+
}
|
|
157
|
+
const rem = Number(remainingPercent)
|
|
158
|
+
if (!Number.isFinite(rem)) {
|
|
159
|
+
return { runwayMs: null, runwayHours: null, runwayText: '', pacingStatus: 'unknown' }
|
|
160
|
+
}
|
|
161
|
+
if (rem <= 0) {
|
|
162
|
+
return { runwayMs: 0, runwayHours: 0, runwayText: '0m (exhausted)', pacingStatus: 'critical' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const pace = Number(pacePerHour) || 0
|
|
166
|
+
if (pace <= 0.02) {
|
|
167
|
+
return { runwayMs: Infinity, runwayHours: Infinity, runwayText: 'Sustainable', pacingStatus: 'sustainable' }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const runwayHours = rem / pace
|
|
171
|
+
const runwayMs = Math.round(runwayHours * HOUR_MS)
|
|
172
|
+
|
|
173
|
+
let runwayText = ''
|
|
174
|
+
if (runwayHours >= 24) {
|
|
175
|
+
const days = Math.round(runwayHours / 24)
|
|
176
|
+
runwayText = `~${days}d`
|
|
177
|
+
} else if (runwayHours >= 1) {
|
|
178
|
+
const h = Math.floor(runwayHours)
|
|
179
|
+
const m = Math.round((runwayHours % 1) * 60)
|
|
180
|
+
runwayText = m > 0 ? `~${h}h ${m}m` : `~${h}h`
|
|
181
|
+
} else {
|
|
182
|
+
const m = Math.max(1, Math.round(runwayHours * 60))
|
|
183
|
+
runwayText = m <= 15 ? '< 15m' : `~${m}m`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let pacingStatus = 'sustainable'
|
|
187
|
+
if (runwayHours < 0.5) {
|
|
188
|
+
pacingStatus = 'critical'
|
|
189
|
+
} else if (runwayHours <= 2.5) {
|
|
190
|
+
pacingStatus = 'elevated'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
runwayMs,
|
|
195
|
+
runwayHours: Math.round(runwayHours * 10) / 10,
|
|
196
|
+
runwayText,
|
|
197
|
+
pacingStatus,
|
|
198
|
+
}
|
|
199
|
+
}
|