quotacap 0.0.50 → 0.0.52

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.
Files changed (69) hide show
  1. package/README.md +2 -2
  2. package/dist/adapters/agy.js +3 -0
  3. package/dist/adapters/claude.js +3 -0
  4. package/dist/adapters/codex.js +4 -2
  5. package/dist/adapters/kimi.js +14 -0
  6. package/dist/adapters/manual.js +4 -0
  7. package/dist/adapters/muse.js +19 -5
  8. package/dist/adapters/opencode-go.d.ts +12 -2
  9. package/dist/adapters/opencode-go.js +156 -31
  10. package/dist/adapters/types.d.ts +2 -0
  11. package/dist/advisory/snapshot.d.ts +1 -0
  12. package/dist/advisory/snapshot.js +7 -1
  13. package/dist/advisory/types.d.ts +3 -0
  14. package/dist/cli/models.js +5 -4
  15. package/dist/format/rows.d.ts +30 -1
  16. package/dist/format/rows.js +109 -24
  17. package/dist/format/terminal.js +5 -3
  18. package/dist/http/server.d.ts +3 -0
  19. package/dist/http/server.js +7 -3
  20. package/dist/runtime/service.js +14 -3
  21. package/dist/service/macos.d.ts +5 -0
  22. package/dist/service/macos.js +6 -2
  23. package/dist/store/db.js +2 -1
  24. package/dist/store/quotas.d.ts +1 -1
  25. package/dist/store/quotas.js +65 -2
  26. package/dist/version.js +1 -1
  27. package/dist/web/src/App.js +10 -4
  28. package/dist/web/src/components/AdviceDrawer.d.ts +2 -1
  29. package/dist/web/src/components/AdviceDrawer.js +27 -16
  30. package/dist/web/src/components/BurnDown.d.ts +37 -0
  31. package/dist/web/src/components/BurnDown.js +182 -0
  32. package/dist/web/src/components/ClosedWeeks.d.ts +6 -5
  33. package/dist/web/src/components/ClosedWeeks.js +25 -37
  34. package/dist/web/src/components/CopyButton.d.ts +4 -0
  35. package/dist/web/src/components/CopyButton.js +27 -0
  36. package/dist/web/src/components/ErrorBoundary.d.ts +37 -0
  37. package/dist/web/src/components/ErrorBoundary.js +32 -0
  38. package/dist/web/src/components/FaultBanner.d.ts +3 -2
  39. package/dist/web/src/components/FaultBanner.js +7 -4
  40. package/dist/web/src/components/OpencodeGoBanner.d.ts +3 -1
  41. package/dist/web/src/components/OpencodeGoBanner.js +16 -10
  42. package/dist/web/src/components/PaceBar.d.ts +80 -3
  43. package/dist/web/src/components/PaceBar.js +225 -34
  44. package/dist/web/src/components/PacingLegend.js +1 -1
  45. package/dist/web/src/components/ProviderCard.js +6 -9
  46. package/dist/web/src/components/ProviderDrawer.d.ts +56 -10
  47. package/dist/web/src/components/ProviderDrawer.js +263 -138
  48. package/dist/web/src/components/ProviderRow.js +3 -3
  49. package/dist/web/src/components/Recommendation.js +11 -7
  50. package/dist/web/src/components/ResetRail.js +13 -9
  51. package/dist/web/src/components/SubscriptionList.js +11 -4
  52. package/dist/web/src/components/WindowsRow.d.ts +13 -3
  53. package/dist/web/src/components/WindowsRow.js +27 -5
  54. package/dist/web/src/components/settings/CliIntegrationPanel.js +7 -45
  55. package/dist/web/src/components/settings/DaemonPanel.js +1 -28
  56. package/dist/web/src/components/settings/ProvidersPanel.d.ts +2 -2
  57. package/dist/web/src/components/settings/ProvidersPanel.js +10 -24
  58. package/dist/web/src/components/settings/SettingsDrawer.d.ts +1 -2
  59. package/dist/web/src/components/settings/SettingsDrawer.js +2 -2
  60. package/dist/web/src/state.d.ts +6 -1
  61. package/dist/web/src/state.js +19 -10
  62. package/dist/webAssets.js +1 -1
  63. package/dist/webHtml.js +1 -1
  64. package/package.json +1 -1
  65. package/web/dist/assets/index-Co33DTl5.css +1 -0
  66. package/web/dist/assets/index-DqK6QVaw.js +13 -0
  67. package/web/dist/index.html +2 -2
  68. package/web/dist/assets/index-BSQW0C_t.css +0 -1
  69. package/web/dist/assets/index-Dq9ZvTUe.js +0 -13
@@ -9,6 +9,85 @@ function msOf(iso) {
9
9
  const ms = new Date(iso).getTime();
10
10
  return Number.isNaN(ms) ? null : ms;
11
11
  }
12
+ /**
13
+ * Quota-window resolution, mirroring the dashboard helpers: the advisory
14
+ * binding window rations the account, so standalone resets, usage,
15
+ * elapsed, and pace read it. Weekly-bound rows fall back to the weekly
16
+ * fields unchanged; rate-limit windows never drive these figures.
17
+ */
18
+ export function isMonthlyBound(ps) {
19
+ return (ps.advisory?.bindingWindow === "monthly" &&
20
+ ps.quota?.monthlyKind === "included" &&
21
+ typeof ps.quota?.monthlyPct === "number" &&
22
+ !!ps.quota?.monthlyResetsAt);
23
+ }
24
+ /** Reset instant of the quota window, or null without a quota reading. */
25
+ export function bindingResetsAt(ps) {
26
+ if (!ps.quota)
27
+ return null;
28
+ if (isMonthlyBound(ps))
29
+ return ps.quota.monthlyResetsAt ?? null;
30
+ return ps.quota.resetsAt ?? null;
31
+ }
32
+ /** Used percent of the quota window. Null without a quota reading. */
33
+ export function bindingUsedPct(ps) {
34
+ if (!ps.quota)
35
+ return null;
36
+ if (isMonthlyBound(ps)) {
37
+ const m = ps.quota.monthlyPct;
38
+ return typeof m === "number" && Number.isFinite(m) ? Math.round(m) : null;
39
+ }
40
+ const u = weeklyPctOf(ps.quota);
41
+ return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
42
+ }
43
+ /** Start of the monthly window: one calendar month before its reported reset. */
44
+ function monthlyStartMs(resetMs) {
45
+ const start = new Date(resetMs);
46
+ start.setMonth(start.getMonth() - 1);
47
+ return start.getTime();
48
+ }
49
+ /**
50
+ * Binding leftover for a raw quota without an advisory (model catalogs):
51
+ * the same scarcer-headroom rule the engine applies, so the catalog
52
+ * leftover and reset name the quota window, not always the week.
53
+ */
54
+ export function bindingLeftover(quota, now) {
55
+ if (!quota || typeof quota.weeklyPct !== "number") {
56
+ return { leftoverPct: null, resetsAt: quota?.resetsAt ?? null };
57
+ }
58
+ const weeklyRemaining = 100 - quota.weeklyPct;
59
+ const weeklyDays = Math.max(0.1, (new Date(quota.resetsAt ?? "").getTime() - now.getTime()) / DAY_MS);
60
+ if (quota.monthlyKind === "included" &&
61
+ typeof quota.monthlyPct === "number" &&
62
+ quota.monthlyResetsAt) {
63
+ const monthlyRemaining = quota.monthlyStatus === "exhausted" ? 0 : Math.max(0, 100 - quota.monthlyPct);
64
+ const monthlyDays = Math.max(0.1, (new Date(quota.monthlyResetsAt).getTime() - now.getTime()) / DAY_MS);
65
+ if (monthlyRemaining / monthlyDays < weeklyRemaining / weeklyDays || monthlyRemaining === 0) {
66
+ return {
67
+ leftoverPct: Math.round(monthlyRemaining * 10) / 10,
68
+ resetsAt: quota.monthlyResetsAt,
69
+ };
70
+ }
71
+ }
72
+ return {
73
+ leftoverPct: Math.round(weeklyRemaining * 10) / 10,
74
+ resetsAt: quota.resetsAt ?? null,
75
+ };
76
+ }
77
+ /** Window-average pace of the quota window; monthly divides monthly used by elapsed monthly days. */
78
+ export function bindingAvgPace(ps, now) {
79
+ if (!isMonthlyBound(ps))
80
+ return ps.advisory?.avgPace ?? null;
81
+ const used = ps.quota?.monthlyPct;
82
+ const resetMs = msOf(ps.quota?.monthlyResetsAt);
83
+ if (typeof used !== "number" || !Number.isFinite(used) || resetMs === null)
84
+ return null;
85
+ const elapsedDays = (now.getTime() - monthlyStartMs(resetMs)) / DAY_MS;
86
+ if (elapsedDays < 0.25)
87
+ return null;
88
+ const pace = used / elapsedDays;
89
+ return Number.isFinite(pace) ? Math.max(0, pace) : null;
90
+ }
12
91
  // The CLI talks HTTP to a daemon that may predate the rename, which sent only
13
92
  // `usedPct`. Read both for one release; drop the fallback with the aliases.
14
93
  export function weeklyPctOf(q) {
@@ -33,7 +112,7 @@ export function dayOf(iso) {
33
112
  return `${when.toLocaleDateString("en-US", { weekday: "short" })} ${when.toLocaleDateString("en-US", { day: "numeric", month: "short" })}`;
34
113
  }
35
114
  export function countdownText(ps, now) {
36
- const resetsMs = msOf(ps.quota?.resetsAt);
115
+ const resetsMs = msOf(bindingResetsAt(ps));
37
116
  let text;
38
117
  if (resetsMs === null)
39
118
  text = "—";
@@ -41,27 +120,30 @@ export function countdownText(ps, now) {
41
120
  text = "passed";
42
121
  else
43
122
  text = fmtDuration(resetsMs - now.getTime());
44
- if (ps.quota?.resetsAtEstimated)
123
+ if (!isMonthlyBound(ps) && ps.quota?.resetsAtEstimated)
45
124
  text += " (est.)";
46
125
  return text;
47
126
  }
48
127
  export function elapsedPct(ps, now) {
49
- const startMs = msOf(ps.quota?.periodStart);
50
- const resetsMs = msOf(ps.quota?.resetsAt);
128
+ const resetsMs = msOf(bindingResetsAt(ps));
129
+ const startMs = isMonthlyBound(ps)
130
+ ? resetsMs === null
131
+ ? null
132
+ : monthlyStartMs(resetsMs)
133
+ : msOf(ps.quota?.periodStart);
51
134
  if (startMs === null || resetsMs === null || resetsMs <= startMs)
52
135
  return null;
53
136
  const pct = ((now.getTime() - startMs) / (resetsMs - startMs)) * 100;
54
137
  return Math.round(Math.min(100, Math.max(0, pct)));
55
138
  }
56
139
  export function usedPctOf(ps) {
57
- const u = weeklyPctOf(ps.quota);
58
- return typeof u === "number" && Number.isFinite(u) ? Math.round(u) : null;
140
+ return bindingUsedPct(ps);
59
141
  }
60
142
  export function railCells(ps, now) {
61
143
  const cells = new Array(RAIL_CELLS).fill("unused");
62
144
  if (ps.exclusionReason !== null)
63
145
  return cells;
64
- const u = weeklyPctOf(ps.quota);
146
+ const u = bindingUsedPct(ps);
65
147
  const used = typeof u === "number" && Number.isFinite(u)
66
148
  ? Math.min(RAIL_CELLS, Math.max(0, Math.round((u / 100) * RAIL_CELLS)))
67
149
  : 0;
@@ -167,7 +249,8 @@ export function forecastText(ps, now) {
167
249
  text = "Exhausts before reset";
168
250
  }
169
251
  // Engine estimate vocabulary, mirroring the snapshot reason transform.
170
- if (ps.quota?.resetsAtEstimated) {
252
+ // A monthly quota forecast never carries the weekly estimate flag.
253
+ if (!isMonthlyBound(ps) && ps.quota?.resetsAtEstimated) {
171
254
  if (text.includes("waste in"))
172
255
  text = text.replace("waste in", "estimated waste in");
173
256
  else if (text.includes("until reset"))
@@ -189,7 +272,7 @@ export function compactSuffix(ps) {
189
272
  return "!";
190
273
  if (adv.status === "watch")
191
274
  return "~";
192
- if (adv.urgency !== "on track" || ps.quota?.resetsAtEstimated)
275
+ if (adv.urgency !== "on track" || (!isMonthlyBound(ps) && ps.quota?.resetsAtEstimated))
193
276
  return "~";
194
277
  return "";
195
278
  }
@@ -203,8 +286,7 @@ function wasteOf(p) {
203
286
  return typeof w === "number" && Number.isFinite(w) ? w : null;
204
287
  }
205
288
  function finiteUsed(p) {
206
- const u = weeklyPctOf(p.quota);
207
- return typeof u === "number" && Number.isFinite(u) ? u : null;
289
+ return bindingUsedPct(p);
208
290
  }
209
291
  export function sortProviders(providers, key, use) {
210
292
  assertValidSortKey(key);
@@ -241,8 +323,8 @@ export function sortProviders(providers, key, use) {
241
323
  case "reset-desc": {
242
324
  const dir = key === "reset-asc" ? 1 : -1;
243
325
  indexed.sort((a, b) => {
244
- const ra = msOf(a.p.quota?.resetsAt);
245
- const rb = msOf(b.p.quota?.resetsAt);
326
+ const ra = msOf(bindingResetsAt(a.p));
327
+ const rb = msOf(bindingResetsAt(b.p));
246
328
  if (ra === null && rb === null)
247
329
  return a.i - b.i;
248
330
  if (ra === null)
@@ -275,21 +357,24 @@ export function sortProviders(providers, key, use) {
275
357
  }
276
358
  }
277
359
  }
278
- // Compact pace pair: window average first, 24h rate alongside. A pair that
279
- // renders identically at display precision collapses to one figure.
280
- export function paceText(ps) {
360
+ // Compact pace pair: quota-window average first, 24h rate alongside. A pair
361
+ // that renders identically at display precision collapses to one figure.
362
+ // The 24h rate is weekly-only: a monthly-bound row shows its monthly
363
+ // average alone.
364
+ export function paceText(ps, now = new Date()) {
281
365
  const adv = ps.advisory;
282
366
  if (!adv)
283
367
  return "—";
284
- // avgPace is absent on advisories from a daemon older than the split.
285
- const avg = adv.avgPace ?? null;
368
+ const avg = bindingAvgPace(ps, now);
286
369
  // The 24h figure is the measured recentRate, never the blended forecast
287
370
  // input; an older daemon omits the field and burnRate was the raw rate then.
288
- const recent = adv.recentRate !== undefined
289
- ? adv.recentRate
290
- : adv.paceSource === "recent"
291
- ? adv.burnRate
292
- : null;
371
+ const recent = isMonthlyBound(ps)
372
+ ? null
373
+ : adv.recentRate !== undefined
374
+ ? adv.recentRate
375
+ : adv.paceSource === "recent"
376
+ ? adv.burnRate
377
+ : null;
293
378
  if (avg !== null && recent !== null && avg.toFixed(1) !== recent.toFixed(1)) {
294
379
  return `${avg.toFixed(1)} avg · ${recent.toFixed(1)} 24h`;
295
380
  }
@@ -308,6 +393,6 @@ export function buildRow(ps, now) {
308
393
  elapsedPct: elapsedPct(ps, now),
309
394
  usedPct: usedPctOf(ps),
310
395
  railCells: railCells(ps, now),
311
- pace: paceText(ps),
396
+ pace: paceText(ps, now),
312
397
  };
313
398
  }
@@ -1,4 +1,4 @@
1
- import { buildRow, compactSuffix, sortProviders, weeklyPctOf, } from "./rows.js";
1
+ import { buildRow, compactSuffix, isMonthlyBound, sortProviders, weeklyPctOf, } from "./rows.js";
2
2
  const GAP = " ";
3
3
  const MIN_PROV_WIDTH = 12;
4
4
  const MAX_PROV_WIDTH = 22;
@@ -83,7 +83,8 @@ export function renderWide(snapshot, opts) {
83
83
  const close = p.lastCloses?.[0];
84
84
  if (!close)
85
85
  return null;
86
- const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
86
+ const unit = isMonthlyBound(p) ? "month" : "week";
87
+ const tail = `last ${unit} ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
87
88
  return `${resetsIndent}${paint(tail, DIM)}`;
88
89
  };
89
90
  const header = [
@@ -144,7 +145,8 @@ export function renderNarrow(snapshot, opts) {
144
145
  const close = p.lastCloses?.[0];
145
146
  if (close) {
146
147
  const indent = " ".repeat(row.railCells.length + " resets ".length);
147
- const tail = `last week ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
148
+ const unit = isMonthlyBound(p) ? "month" : "week";
149
+ const tail = `last ${unit} ${close.leftoverPct}% leftover${close.resetsAtEstimated ? " (est.)" : ""}`;
148
150
  lines.push(`${indent}${color ? `${DIM}${tail}${RESET}` : tail}`);
149
151
  }
150
152
  }
@@ -12,6 +12,9 @@ export interface RuntimeContext {
12
12
  providerNames?: Record<string, string>;
13
13
  /** Detected-but-not-enabled opt-in providers, computed once at start. */
14
14
  detectedProviders?: string[];
15
+ /** Login command for the detected OpenCode CLI version, filled in
16
+ * asynchronously after start; null until known. */
17
+ opencodeLoginCommand?: string | null;
15
18
  version: string;
16
19
  exec: string;
17
20
  now?: () => Date;
@@ -19,6 +19,7 @@ import { providerIdentity } from "../advisory/provider-names.js";
19
19
  import { setProviderNameOverride, setProviderEnabled } from "../config.js";
20
20
  import { ensureCatalogs, catalogProviders, CATALOG_FAIL_COOLDOWN_MS } from "../catalog/index.js";
21
21
  import { getAllLatest } from "../store/quotas.js";
22
+ import { bindingLeftover } from "../format/rows.js";
22
23
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
23
24
  export function hostnameFromHostHeader(hostHeader) {
24
25
  if (typeof hostHeader !== "string" || !hostHeader.trim())
@@ -122,6 +123,7 @@ function snapshotOf(ctx) {
122
123
  checkedAt: cache?.checkedAt ?? null,
123
124
  },
124
125
  detectedProviders: ctx.detectedProviders ?? [],
126
+ opencodeLoginCommand: ctx.opencodeLoginCommand ?? null,
125
127
  },
126
128
  });
127
129
  }
@@ -217,17 +219,18 @@ export function buildApp(ctx) {
217
219
  const catalogTtl = ctx.catalogTtlHours ?? 6;
218
220
  function projectModels(catalogs, quotaMap) {
219
221
  const out = [];
222
+ const now = ctx.now?.() ?? new Date();
220
223
  for (const [id, catalog] of catalogs) {
221
224
  const identity = providerIdentity(id, ctx.providerNames);
222
225
  const quota = quotaMap.get(id);
223
- const leftoverPct = quota?.weeklyPct != null ? Math.round((100 - quota.weeklyPct) * 10) / 10 : null;
226
+ const binding = bindingLeftover(quota, now);
224
227
  out.push({
225
228
  id,
226
229
  displayName: identity.displayName,
227
230
  harness: identity.harness,
228
231
  vendor: identity.vendor,
229
- leftoverPct,
230
- resetsAt: quota?.resetsAt ?? null,
232
+ leftoverPct: binding.leftoverPct,
233
+ resetsAt: binding.resetsAt,
231
234
  catalog,
232
235
  });
233
236
  }
@@ -454,6 +457,7 @@ export function buildApp(ctx) {
454
457
  plan: parsed.plan,
455
458
  weeklyPct: parsed.weeklyPct,
456
459
  fiveHourPct: parsed.fiveHourPct,
460
+ fiveHourResetsAt: parsed.fiveHourResetsAt,
457
461
  resetsAt: parsed.resetsAt,
458
462
  periodStart: parsed.periodStart,
459
463
  source: "manual",
@@ -16,7 +16,7 @@ import { buildApp } from "../http/server.js";
16
16
  import { detectChannel, refreshUpdateCache } from "./updates.js";
17
17
  import { VERSION } from "../version.js";
18
18
  import { claudeAdapter } from "../adapters/claude.js";
19
- import { opencodeGoDetected } from "../adapters/opencode-go.js";
19
+ import { detectOpenCodeLoginCommand, opencodeGoDetected } from "../adapters/opencode-go.js";
20
20
  import { classifyFailure } from "../diagnostics/failure.js";
21
21
  import { ensureCatalogs, catalogProviders } from "../catalog/index.js";
22
22
  export function resolveClaudeExecPath() {
@@ -196,12 +196,13 @@ export async function startService(opts) {
196
196
  }).catch(() => { });
197
197
  },
198
198
  });
199
- app = buildApp({
199
+ const appCtx = {
200
200
  db,
201
201
  token,
202
202
  coordinator,
203
203
  enabledProviders: config.enabledProviders,
204
204
  detectedProviders: opencodeGoDetected() ? ["opencode-go"] : [],
205
+ opencodeLoginCommand: null,
205
206
  providerNames: config.providerNames,
206
207
  catalogTtlHours: config.catalogTtlHours,
207
208
  version: VERSION,
@@ -213,7 +214,17 @@ export async function startService(opts) {
213
214
  // hoisted declaration below; it can only fire after listen, so the
214
215
  // shutdown bindings it closes over are initialized by then).
215
216
  onRestart: () => onSignal(),
216
- });
217
+ };
218
+ app = buildApp(appCtx);
219
+ // Version-specific OpenCode login command for the fault banner:
220
+ // detected once, in the background, never blocking readiness.
221
+ if (config.enabledProviders.includes("opencode-go") || appCtx.detectedProviders.length > 0) {
222
+ void detectOpenCodeLoginCommand()
223
+ .then((cmd) => {
224
+ appCtx.opencodeLoginCommand = cmd;
225
+ })
226
+ .catch(() => { });
227
+ }
217
228
  // 6. Bind; unwind on failure without polling.
218
229
  await app.listen({ port, host: "127.0.0.1" });
219
230
  const bound = app.server.address();
@@ -16,6 +16,11 @@ export interface ServiceDeps {
16
16
  lintPlist?: (plistFile: string) => void;
17
17
  waitReady?: (port: number) => Promise<boolean>;
18
18
  waitReleased?: (port: number) => Promise<boolean>;
19
+ /** Internal: the caller just stopped this job and is replacing it, so a
20
+ * still-loaded record at bootstrap time is the dying one — keep
21
+ * retrying until it clears instead of treating it as already loaded.
22
+ * Only restart sets this; a plain start keeps the idempotent shortcut. */
23
+ expectReplace?: boolean;
19
24
  print?: (msg: string) => void;
20
25
  error?: (msg: string) => void;
21
26
  now?: () => Date;
@@ -422,7 +422,7 @@ export async function start(deps = {}) {
422
422
  const { home, uid, dataDir, print, run, sleep, bootstrapTimeoutMs } = resolved(deps);
423
423
  const plistFile = plistFileFor(home);
424
424
  await bootstrapWithRetry(run, uid, plistFile, {
425
- expectReplace: false,
425
+ expectReplace: deps.expectReplace ?? false,
426
426
  sleep,
427
427
  timeoutMs: bootstrapTimeoutMs,
428
428
  });
@@ -452,7 +452,11 @@ export async function restart(deps = {}) {
452
452
  if (!(await waitReleased(port))) {
453
453
  (deps.print ?? console.log)(`port ${port} is still in use after stopping; starting anyway`);
454
454
  }
455
- await start(deps);
455
+ // The record we just booted out can still be clearing when start
456
+ // bootstraps: error 5 plus a loaded record would read as an idempotent
457
+ // double-start and skip the bootstrap entirely, leaving nothing running
458
+ // once the old record finishes clearing. Replace, don't shortcut.
459
+ await start({ ...deps, expectReplace: true });
456
460
  }
457
461
  function parsePrintOutput(out) {
458
462
  const pidMatch = out.match(/^\s*pid\s*=\s*(\d+)/m);
package/dist/store/db.js CHANGED
@@ -36,7 +36,7 @@ export function openDb(p) {
36
36
  return db;
37
37
  }
38
38
  export function migrate(db) {
39
- db.exec(`CREATE TABLE IF NOT EXISTS quotas(id INTEGER PRIMARY KEY, provider TEXT, plan TEXT, used_pct REAL, resets_at TEXT, period_start TEXT, source TEXT, fetched_at TEXT, credits_usd REAL, resets_at_estimated INTEGER, session_pct REAL, monthly_pct REAL, monthly_resets_at TEXT, monthly_status TEXT, monthly_kind TEXT);
39
+ db.exec(`CREATE TABLE IF NOT EXISTS quotas(id INTEGER PRIMARY KEY, provider TEXT, plan TEXT, used_pct REAL, resets_at TEXT, period_start TEXT, source TEXT, fetched_at TEXT, credits_usd REAL, resets_at_estimated INTEGER, session_pct REAL, monthly_pct REAL, monthly_resets_at TEXT, monthly_status TEXT, monthly_kind TEXT, five_hour_resets_at TEXT);
40
40
  CREATE TABLE IF NOT EXISTS snapshots(day TEXT, provider TEXT, used_pct REAL, burn_rate REAL, ideal_rate REAL, PRIMARY KEY(day, provider));
41
41
  CREATE TABLE IF NOT EXISTS adapter_attempts(provider TEXT PRIMARY KEY, attempted_at TEXT NOT NULL, completed_at TEXT, succeeded_at TEXT, success INTEGER NOT NULL, failure_category TEXT, diagnostic_code TEXT, summary TEXT, action TEXT, error_detail TEXT);
42
42
  CREATE INDEX IF NOT EXISTS idx_quotas_provider ON quotas(provider);
@@ -104,6 +104,7 @@ export function migrate(db) {
104
104
  ["monthly_resets_at", "TEXT"],
105
105
  ["monthly_status", "TEXT"],
106
106
  ["monthly_kind", "TEXT"],
107
+ ["five_hour_resets_at", "TEXT"],
107
108
  ]) {
108
109
  if (!quotaCols.has(column))
109
110
  db.exec(`ALTER TABLE quotas ADD COLUMN ${column} ${type}`);
@@ -8,7 +8,7 @@ export declare function isWeeklyCycleBreak(prev: {
8
8
  resetsAt: string | null;
9
9
  }): boolean;
10
10
  export declare function upsertQuota(db: any, q: any): void;
11
- export declare function getWindowCloses(db: any, provider: string, limit?: number): Array<{
11
+ export declare function getWindowCloses(db: any, provider: string, limit?: number, window?: "weekly" | "monthly"): Array<{
12
12
  provider: string;
13
13
  plan: string | null;
14
14
  usedPct: number;
@@ -30,6 +30,10 @@ function mapRow(row) {
30
30
  delete out.fiveHourPct;
31
31
  delete out.sessionPct;
32
32
  }
33
+ out.fiveHourResetsAt = row.five_hour_resets_at ?? row.fiveHourResetsAt ?? undefined;
34
+ delete out.five_hour_resets_at;
35
+ if (out.fiveHourResetsAt == null)
36
+ delete out.fiveHourResetsAt;
33
37
  // A junk monthly percent means no usable monthly reading. It never
34
38
  // invalidates the provider; only a bad weekly percent does (snapshot.ts).
35
39
  const m = row.monthly_pct;
@@ -85,7 +89,7 @@ export function upsertQuota(db, q) {
85
89
  .run(prev.provider, prev.plan ?? null, prev.weeklyPct, Math.max(0, 100 - prev.weeklyPct), prev.fetchedAt, prev.id, prev.periodStart ?? null, prev.resetsAt ?? null, prev.resetsAtEstimated ? 1 : null, q.fetchedAt, reason);
86
90
  }
87
91
  }
88
- db.prepare(`INSERT INTO quotas(provider, plan, used_pct, resets_at, period_start, source, fetched_at, credits_usd, resets_at_estimated, session_pct, monthly_pct, monthly_resets_at, monthly_status, monthly_kind) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(q.provider, q.plan, weeklyPct, q.resetsAt, q.periodStart, q.source, q.fetchedAt, credits, estimated, fiveHourPct, q.monthlyPct ?? null, q.monthlyResetsAt ?? null, q.monthlyStatus ?? null, q.monthlyKind ?? null);
92
+ db.prepare(`INSERT INTO quotas(provider, plan, used_pct, resets_at, period_start, source, fetched_at, credits_usd, resets_at_estimated, session_pct, monthly_pct, monthly_resets_at, monthly_status, monthly_kind, five_hour_resets_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(q.provider, q.plan, weeklyPct, q.resetsAt, q.periodStart, q.source, q.fetchedAt, credits, estimated, fiveHourPct, q.monthlyPct ?? null, q.monthlyResetsAt ?? null, q.monthlyStatus ?? null, q.monthlyKind ?? null, q.fiveHourResetsAt ?? null);
89
93
  const day = new Date().toISOString().slice(0, 10);
90
94
  db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES(?,?,?) ON CONFLICT(day, provider) DO UPDATE SET used_pct=excluded.used_pct`).run(day, q.provider, weeklyPct);
91
95
  db.exec("COMMIT");
@@ -98,7 +102,66 @@ export function upsertQuota(db, q) {
98
102
  throw e;
99
103
  }
100
104
  }
101
- export function getWindowCloses(db, provider, limit = 4) {
105
+ function monthlyPeriodStart(resetIso) {
106
+ if (!resetIso)
107
+ return null;
108
+ const resetMs = Date.parse(resetIso);
109
+ if (!Number.isFinite(resetMs))
110
+ return null;
111
+ const start = new Date(resetMs);
112
+ start.setMonth(start.getMonth() - 1);
113
+ return start.toISOString();
114
+ }
115
+ export function getWindowCloses(db, provider, limit = 4, window = "weekly") {
116
+ if (window === "monthly") {
117
+ let qRows = [];
118
+ try {
119
+ qRows = db
120
+ .prepare(`SELECT id, provider, plan, monthly_pct, monthly_resets_at, monthly_kind, fetched_at FROM quotas WHERE provider=? ORDER BY fetched_at ASC, id ASC`)
121
+ .all(provider);
122
+ }
123
+ catch (e) {
124
+ if (!/no such table|no such column/i.test(String(e?.message ?? "")))
125
+ throw e;
126
+ }
127
+ const valid = qRows.filter((r) => r.monthly_kind === "included" &&
128
+ typeof r.monthly_pct === "number" &&
129
+ Number.isFinite(r.monthly_pct) &&
130
+ r.monthly_pct >= 0 &&
131
+ r.monthly_pct <= 100 &&
132
+ typeof r.monthly_resets_at === "string" &&
133
+ Number.isFinite(Date.parse(r.monthly_resets_at)) &&
134
+ Number.isFinite(Date.parse(r.fetched_at)));
135
+ const closes = [];
136
+ for (let i = 1; i < valid.length; i++) {
137
+ const prev = valid[i - 1];
138
+ const cur = valid[i];
139
+ const reason = cycleBreakReason({
140
+ usedPct: prev.monthly_pct,
141
+ t: Date.parse(prev.fetched_at),
142
+ resetsAt: prev.monthly_resets_at ?? null,
143
+ }, {
144
+ usedPct: cur.monthly_pct,
145
+ t: Date.parse(cur.fetched_at),
146
+ resetsAt: cur.monthly_resets_at ?? null,
147
+ });
148
+ if (reason) {
149
+ closes.push({
150
+ provider: prev.provider,
151
+ plan: prev.plan ?? null,
152
+ usedPct: prev.monthly_pct,
153
+ leftoverPct: Math.max(0, 100 - prev.monthly_pct),
154
+ sampledAt: prev.fetched_at,
155
+ periodStart: monthlyPeriodStart(prev.monthly_resets_at),
156
+ resetsAt: prev.monthly_resets_at ?? null,
157
+ resetsAtEstimated: false,
158
+ detectedAt: cur.fetched_at,
159
+ reason,
160
+ });
161
+ }
162
+ }
163
+ return closes.reverse().slice(0, limit);
164
+ }
102
165
  let rows = [];
103
166
  try {
104
167
  rows = db.prepare(`SELECT provider, plan, used_pct, leftover_pct, sampled_at, period_start, resets_at, resets_at_estimated, detected_at, reason FROM window_closes WHERE provider=? ORDER BY detected_at DESC, id DESC LIMIT ?`).all(provider, limit);
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.50";
2
+ export const VERSION = "0.0.52";
@@ -8,6 +8,7 @@ import { Recommendation } from "./components/Recommendation.js";
8
8
  import { SubscriptionList } from "./components/SubscriptionList.js";
9
9
  import { ResetRail } from "./components/ResetRail.js";
10
10
  import { ProviderDrawer } from "./components/ProviderDrawer.js";
11
+ import { DrawerErrorFallback, ErrorBoundary } from "./components/ErrorBoundary.js";
11
12
  import { AdviceDrawer } from "./components/AdviceDrawer.js";
12
13
  import { SettingsDrawer } from "./components/settings/SettingsDrawer.js";
13
14
  import { navigate, routeFor, useRoute } from "./router.js";
@@ -127,7 +128,7 @@ function App() {
127
128
  : null;
128
129
  const header = (_jsx(Header, { runtime: snapshot?.runtime ?? null, unreachable: error?.kind === "service-unavailable", refreshing: refreshing, onRefresh: () => void refresh(), onSettings: () => setSettingsOpen(true) }));
129
130
  if (snapshot && setupMode) {
130
- return (_jsxs(_Fragment, { children: [header, _jsxs("main", { children: [notice && (_jsx("div", { "data-testid": "refresh-notice", role: "status", children: notice })), showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), _jsx(Onboarding, { snapshot: snapshot, onFirstPoll: () => void refresh(), polling: refreshing, pollError: pollErrorText })] }), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot.providers, onRefresh: () => void refresh(), refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), consentOpen && (_jsx(ConsentModal, { busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
131
+ return (_jsxs(_Fragment, { children: [header, _jsxs("main", { children: [notice && (_jsx("div", { "data-testid": "refresh-notice", role: "status", children: notice })), showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), _jsx(Onboarding, { snapshot: snapshot, onFirstPoll: () => void refresh(), polling: refreshing, pollError: pollErrorText })] }), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot.providers, refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), consentOpen && (_jsx(ConsentModal, { loginCommand: snapshot?.runtime.opencodeLoginCommand, busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
131
132
  setConsentBusy(true);
132
133
  try {
133
134
  await setProviderEnabled("opencode-go", true, true);
@@ -149,13 +150,15 @@ function App() {
149
150
  border: i === 0 ? "1px solid var(--rec)" : "1px solid var(--line)",
150
151
  borderRadius: 8,
151
152
  marginBottom: 8,
152
- } }, i))) })), error?.kind === "service-unavailable" && !snapshot && (_jsx(UnavailablePanel, { endpoint: daemonEndpoint(), lastGood: snapshot, onRetry: () => void load() })), error?.kind === "http-error" && !snapshot && (_jsx(HttpErrorPanel, { status: error.status, message: error.message, lastGood: snapshot, onRetry: () => void load() })), error && snapshot && (_jsxs("div", { "data-testid": "state-stale-error", role: "alert", children: [error.kind === "http-error" ? `Request failed (HTTP ${error.status}): ` : "Service unavailable: ", error.message, " Showing last readings (", ageLabel(snapshot.asOf), ").", " ", _jsx("button", { type: "button", onClick: () => void load(), children: "Retry" })] })), snapshot && (_jsx(FaultBanner, { providers: snapshot.providers, onRepoll: () => void refresh(), repolling: refreshing, polling: snapshot.runtime.polling })), snapshot && showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
153
+ } }, i))) })), error?.kind === "service-unavailable" && !snapshot && (_jsx(UnavailablePanel, { endpoint: daemonEndpoint(), lastGood: snapshot, onRetry: () => void load() })), error?.kind === "http-error" && !snapshot && (_jsx(HttpErrorPanel, { status: error.status, message: error.message, lastGood: snapshot, onRetry: () => void load() })), error && snapshot && (_jsxs("div", { "data-testid": "state-stale-error", role: "alert", children: [error.kind === "http-error" ? `Request failed (HTTP ${error.status}): ` : "Service unavailable: ", error.message, " Showing last readings (", ageLabel(snapshot.asOf), ").", " ", _jsx("button", { type: "button", onClick: () => void load(), children: "Retry" })] })), snapshot && (_jsx(FaultBanner, { providers: snapshot.providers, onRepoll: () => void refresh(), repolling: refreshing, polling: snapshot.runtime.polling, opencodeLoginCommand: snapshot.runtime.opencodeLoginCommand })), snapshot && showOpenCodeGoBanner(snapshot.runtime, snapshot.providers, goSuppressed) && (_jsx(OpencodeGoBanner, { onEnable: () => setConsentOpen(true) })), snapshot && (_jsx(ReadyView, { snapshot: snapshot, onSelectProvider: (id) => {
153
154
  setAdviceOpen(false);
154
155
  setSelectedProvider(id);
155
156
  }, onViewAdvice: () => {
156
157
  setSelectedProvider(null);
157
158
  setAdviceOpen(true);
158
- } }))] }), _jsx(ProviderDrawer, { provider: snapshot?.providers.find((p) => p.id === selectedProvider) ?? null, asOf: snapshot?.asOf ?? "", recommendation: snapshot?.recommendation ?? null, onClose: () => setSelectedProvider(null), onRenamed: () => void load() }), consentOpen && (_jsx(ConsentModal, { busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
159
+ } }))] }), _jsx(ErrorBoundary, { resetKey: selectedProvider, fallback: _jsx(DrawerErrorFallback, { name: snapshot?.providers.find((p) => p.id === selectedProvider)?.displayName ??
160
+ selectedProvider ??
161
+ "provider", onClose: () => setSelectedProvider(null) }), children: _jsx(ProviderDrawer, { provider: snapshot?.providers.find((p) => p.id === selectedProvider) ?? null, asOf: snapshot?.asOf ?? "", recommendation: snapshot?.recommendation ?? null, onClose: () => setSelectedProvider(null), onRenamed: () => void load() }) }), consentOpen && (_jsx(ConsentModal, { loginCommand: snapshot?.runtime.opencodeLoginCommand, busy: consentBusy, onCancel: () => setConsentOpen(false), onConfirm: () => void (async () => {
159
162
  setConsentBusy(true);
160
163
  try {
161
164
  await setProviderEnabled("opencode-go", true, true);
@@ -169,7 +172,10 @@ function App() {
169
172
  finally {
170
173
  setConsentBusy(false);
171
174
  }
172
- })() })), snapshot && (_jsx(AdviceDrawer, { open: adviceOpen, recommendation: snapshot.recommendation, providers: snapshot.providers, asOf: snapshot.asOf, onClose: () => setAdviceOpen(false) })), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot?.providers ?? [], onRefresh: () => void refresh(), refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot?.runtime.version })] }));
175
+ })() })), snapshot && (_jsx(AdviceDrawer, { open: adviceOpen, recommendation: snapshot.recommendation, providers: snapshot.providers, asOf: snapshot.asOf, onClose: () => setAdviceOpen(false), onSelectProvider: (id) => {
176
+ setAdviceOpen(false);
177
+ setSelectedProvider(id);
178
+ } })), _jsx(SettingsDrawer, { open: settingsOpen, providers: snapshot?.providers ?? [], refreshing: refreshing, onSetProviderEnabled: handleSetProviderEnabled, onClose: () => setSettingsOpen(false) }), _jsx(SiteFooter, { version: snapshot?.runtime.version })] }));
173
179
  }
174
180
  const el = typeof document === "undefined" ? null : document.getElementById("app");
175
181
  if (el)
@@ -1,9 +1,10 @@
1
1
  import React from "react";
2
2
  import type { ProviderView, RecommendationView } from "../state.js";
3
- export declare function AdviceDrawer({ open, recommendation, providers, asOf, onClose, }: {
3
+ export declare function AdviceDrawer({ open, recommendation, providers, asOf, onClose, onSelectProvider, }: {
4
4
  open: boolean;
5
5
  recommendation: RecommendationView;
6
6
  providers: ProviderView[];
7
7
  asOf: string;
8
8
  onClose: () => void;
9
+ onSelectProvider?: (id: string) => void;
9
10
  }): React.JSX.Element | null;