quotacap 0.0.53 → 0.0.55

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.
@@ -9,10 +9,13 @@ import type { Advisory, RecommendationBasis, BindingWindow } from "./types.js";
9
9
  * when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
10
10
  * denominator fabricates verdicts).
11
11
  */
12
- export declare function averagePace(q: Quota, now?: Date): number | null;
12
+ export declare function averagePace(q: Quota, now?: Date, exhaustedAt?: string | null): number | null;
13
13
  export declare function blendForecast(recent: number | null, baseline: number | null): number | null;
14
14
  export declare function baselineFor(historyAvg: number | null, q: Quota, now: Date): number | null;
15
- export declare function computeAdvisory(q: Quota, recent: number | null, baseline: number | null, now?: Date): Advisory;
15
+ export declare function computeAdvisory(q: Quota, recent: number | null, baseline: number | null, now?: Date, exhaustedTimestamps?: string | {
16
+ weekly?: string | null;
17
+ monthly?: string | null;
18
+ } | null): Advisory;
16
19
  export declare function recommend(quotas: Quota[], _task: string, paceByProvider?: Map<string, {
17
20
  recent: number | null;
18
21
  baseline: number | null;
@@ -9,16 +9,22 @@ const DAY_MS = 86400000;
9
9
  * when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
10
10
  * denominator fabricates verdicts).
11
11
  */
12
- export function averagePace(q, now = new Date()) {
12
+ export function averagePace(q, now = new Date(), exhaustedAt) {
13
13
  if (!q.periodStart)
14
14
  return null;
15
15
  const start = new Date(q.periodStart).getTime();
16
16
  if (Number.isNaN(start))
17
17
  return null;
18
- const elapsedDays = (now.getTime() - start) / DAY_MS;
19
- if (elapsedDays < MIN_PACE_SPAN_DAYS)
18
+ const endMs = exhaustedAt && q.weeklyPct >= 100
19
+ ? new Date(exhaustedAt).getTime()
20
+ : now.getTime();
21
+ if (Number.isNaN(endMs))
20
22
  return null;
21
- const pace = q.weeklyPct / elapsedDays;
23
+ const elapsedDays = (endMs - start) / DAY_MS;
24
+ const effectiveDays = q.weeklyPct >= 100 ? Math.max(MIN_PACE_SPAN_DAYS, elapsedDays) : elapsedDays;
25
+ if (effectiveDays < MIN_PACE_SPAN_DAYS)
26
+ return null;
27
+ const pace = q.weeklyPct / effectiveDays;
22
28
  if (!Number.isFinite(pace))
23
29
  return null;
24
30
  return Math.max(0, pace);
@@ -57,15 +63,15 @@ function elapsedPctOf(q, now) {
57
63
  return null;
58
64
  return Math.min(100, Math.max(0, (elapsed / span) * 100));
59
65
  }
60
- export function computeAdvisory(q, recent, baseline, now = new Date()) {
66
+ export function computeAdvisory(q, recent, baseline, now = new Date(), exhaustedTimestamps) {
61
67
  const resets = new Date(q.resetsAt);
62
68
  const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / DAY_MS);
63
69
  const remaining = 100 - q.weeklyPct;
64
70
  const idealRate = remaining / daysLeft;
65
- const avgPace = averagePace(q, now);
66
- // Nothing left to burn: force the capped verdict even when the recent
67
- // window is flat (burn 0 at 100% used must not read "on track").
68
- const exhausted = remaining <= 0;
71
+ const weeklyExhaustedAt = typeof exhaustedTimestamps === "string"
72
+ ? exhaustedTimestamps
73
+ : exhaustedTimestamps?.weekly ?? (q.weeklyPct >= 100 ? (q.fetchedAt ?? null) : null);
74
+ const avgPace = averagePace(q, now, weeklyExhaustedAt);
69
75
  const burnRate = blendForecast(recent, baseline);
70
76
  // Above every return: the unknown-pace path below is exactly what pool 2
71
77
  // ranks, so a binding computed later would leave it on weekly numbers.
@@ -83,6 +89,16 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
83
89
  bindingDaysLeft = monthlyDaysLeft;
84
90
  }
85
91
  }
92
+ const monthlyExhaustedAt = typeof exhaustedTimestamps === "object" && exhaustedTimestamps !== null
93
+ ? exhaustedTimestamps.monthly ?? (bindingRemaining <= 0 ? (q.fetchedAt ?? null) : null)
94
+ : null;
95
+ const monthlyExhausted = bindingWindow === "monthly" && bindingRemaining <= 0;
96
+ const exhausted = remaining <= 0;
97
+ const exhaustedAt = monthlyExhausted
98
+ ? monthlyExhaustedAt
99
+ : exhausted
100
+ ? weeklyExhaustedAt
101
+ : null;
86
102
  // paceSource describes recent availability; the blend's composition is
87
103
  // carried by recentRate and baselineRate.
88
104
  const paceSource = recent !== null ? "recent" : burnRate !== null ? "window-average" : "unknown";
@@ -107,6 +123,7 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
107
123
  bindingWindow,
108
124
  bindingRemaining,
109
125
  bindingDaysLeft,
126
+ exhaustedAt,
110
127
  };
111
128
  }
112
129
  if (exhausted) {
@@ -129,6 +146,7 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
129
146
  bindingWindow,
130
147
  bindingRemaining,
131
148
  bindingDaysLeft,
149
+ exhaustedAt,
132
150
  };
133
151
  }
134
152
  // Red needs a deadband margin and a cumulatively-ahead position on a
@@ -159,7 +177,7 @@ export function computeAdvisory(q, recent, baseline, now = new Date()) {
159
177
  urgency = "save";
160
178
  if (estimated && urgency === "burn now")
161
179
  urgency = "use soon";
162
- return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency, bindingWindow, bindingRemaining, bindingDaysLeft };
180
+ return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, recentRate: recent, baselineRate: baseline, aheadOfElapsed, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency, bindingWindow, bindingRemaining, bindingDaysLeft, exhaustedAt };
163
181
  }
164
182
  /**
165
183
  * Known-waste sort key. `wastePct` is a flow (budget forecast to go unspent
@@ -1,4 +1,4 @@
1
- import { getAllLatest, getBurnRates, getHistoryBaseline, getWindowCloses } from "../store/quotas.js";
1
+ import { getAllLatest, getBurnRates, getExhaustedTimestamps, getHistoryBaseline, getWindowCloses } from "../store/quotas.js";
2
2
  import { getAttempts } from "../store/attempts.js";
3
3
  import { getCatalogs } from "../store/catalogs.js";
4
4
  import { emptyCatalog } from "../catalog/types.js";
@@ -27,6 +27,7 @@ function normalizeAdvisory(adv) {
27
27
  bindingDaysLeft: Number.isFinite(adv.bindingDaysLeft)
28
28
  ? adv.bindingDaysLeft
29
29
  : Number.isFinite(adv.daysLeft) ? adv.daysLeft : 0,
30
+ exhaustedAt: typeof adv.exhaustedAt === "string" ? adv.exhaustedAt : null,
30
31
  };
31
32
  }
32
33
  export function buildSnapshot(db, opts) {
@@ -49,6 +50,7 @@ export function buildSnapshot(db, opts) {
49
50
  }
50
51
  const unionIds = Array.from(new Set([...opts.enabledProviders, ...storedIds])).sort((a, b) => a.localeCompare(b));
51
52
  const burnRates = getBurnRates(db, asOfMs);
53
+ const exhaustedTimestamps = getExhaustedTimestamps(db, asOfMs);
52
54
  const history = getHistoryBaseline(db);
53
55
  const catalogMap = getCatalogs(db);
54
56
  const providerSnapshots = [];
@@ -128,7 +130,11 @@ export function buildSnapshot(db, opts) {
128
130
  const recent = burnRates.get(id) ?? null;
129
131
  const baseline = baselineFor(history.get(id) ?? null, quota, now);
130
132
  pace = { recent, baseline };
131
- rawAdvisory = computeAdvisory(quota, recent, baseline, now);
133
+ const exhausted = {
134
+ weekly: exhaustedTimestamps.weekly.get(id) ?? null,
135
+ monthly: exhaustedTimestamps.monthly.get(id) ?? null,
136
+ };
137
+ rawAdvisory = computeAdvisory(quota, recent, baseline, now, exhausted);
132
138
  }
133
139
  const advisory = normalizeAdvisory(rawAdvisory);
134
140
  // Controlled evidence vocabulary
@@ -46,6 +46,8 @@ export interface Advisory {
46
46
  bindingRemaining: number;
47
47
  /** Days until the binding window resets. Equals `daysLeft` when weekly binds. */
48
48
  bindingDaysLeft: number;
49
+ /** ISO timestamp when the binding quota reached 100% in this window, or null if not exhausted. */
50
+ exhaustedAt?: string | null;
49
51
  }
50
52
  export interface Recommendation {
51
53
  use: string;
package/dist/config.js CHANGED
@@ -32,7 +32,7 @@ export const ProviderDisplayNameSchema = z
32
32
  .transform((val) => val.trim());
33
33
  const ConfigSchema = z.object({
34
34
  port: z.number().default(8787),
35
- pollMinutes: z.number().default(15),
35
+ pollMinutes: z.number().default(10),
36
36
  catalogTtlHours: z.number().finite().positive().default(6),
37
37
  enabledProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
38
38
  knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
@@ -1,5 +1,5 @@
1
1
  import type { ProviderSnapshot } from "../advisory/types.js";
2
- export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting" | "Monthly exhausted";
2
+ export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Watch" | "Measuring" | "Not reporting" | "Monthly exhausted" | "Quota exhausted";
3
3
  export type RailCell = "used" | "tick" | "unused";
4
4
  export type SortKey = "recommended" | "reset-asc" | "reset-desc" | "used-desc" | "used-asc";
5
5
  export type CompactSuffix = "" | "!" | "~" | "?";
@@ -171,6 +171,12 @@ export function stateWord(ps) {
171
171
  // spent until the month resets.
172
172
  if (adv.bindingWindow === "monthly" && adv.bindingRemaining === 0)
173
173
  return "Monthly exhausted";
174
+ const u = bindingUsedPct(ps);
175
+ const isExhausted = (typeof adv.bindingRemaining === "number" && adv.bindingRemaining <= 0) ||
176
+ (typeof adv.remaining === "number" && adv.remaining <= 0) ||
177
+ (typeof u === "number" && u >= 100);
178
+ if (isExhausted)
179
+ return "Quota exhausted";
174
180
  if (adv.status === "at risk")
175
181
  return "Cap risk";
176
182
  if (adv.status === "watch")
@@ -30,6 +30,7 @@ const STATE_COLORS = {
30
30
  "Ahead of pace": "\x1b[93m",
31
31
  "Watch": "\x1b[38;5;214m",
32
32
  "Cap risk": "\x1b[31m",
33
+ "Quota exhausted": "\x1b[31m",
33
34
  "Measuring": "\x1b[90m",
34
35
  "Not reporting": "\x1b[90m",
35
36
  "Monthly exhausted": "\x1b[31m",
@@ -25,4 +25,8 @@ export declare function getAllLatest(db: any): any;
25
25
  export declare const getQuotas: typeof getAllLatest;
26
26
  export declare function getSnapshots(db: any): any;
27
27
  export declare function getBurnRates(db: any, now?: number): Map<string, number>;
28
+ export declare function getExhaustedTimestamps(db: any, now?: number): {
29
+ weekly: Map<string, string>;
30
+ monthly: Map<string, string>;
31
+ };
28
32
  export declare function getHistoryBaseline(db: any, limit?: number): Map<string, number>;
@@ -239,6 +239,58 @@ export function getBurnRates(db, now = Date.now()) {
239
239
  }
240
240
  return out;
241
241
  }
242
+ export function getExhaustedTimestamps(db, now = Date.now()) {
243
+ let rows = [];
244
+ try {
245
+ rows = db
246
+ .prepare(`SELECT provider, used_pct, fetched_at, resets_at, monthly_pct, monthly_status FROM quotas`)
247
+ .all();
248
+ }
249
+ catch (e) {
250
+ if (!/no such column/i.test(String(e?.message ?? "")))
251
+ throw e;
252
+ rows = db
253
+ .prepare(`SELECT provider, used_pct, fetched_at, resets_at FROM quotas`)
254
+ .all();
255
+ }
256
+ const byProvider = new Map();
257
+ for (const r of rows) {
258
+ const t = new Date(r.fetched_at).getTime();
259
+ if (Number.isNaN(t) || t > now)
260
+ continue;
261
+ const pts = byProvider.get(r.provider) ?? [];
262
+ pts.push({
263
+ usedPct: r.used_pct,
264
+ t,
265
+ resetsAt: r.resets_at,
266
+ monthlyPct: r.monthly_pct,
267
+ monthlyStatus: r.monthly_status,
268
+ });
269
+ byProvider.set(r.provider, pts);
270
+ }
271
+ const weekly = new Map();
272
+ const monthly = new Map();
273
+ for (const [provider, all] of byProvider) {
274
+ const sorted = currentCycle(all.sort((a, b) => a.t - b.t));
275
+ if (sorted.length === 0)
276
+ continue;
277
+ const latest = sorted[sorted.length - 1];
278
+ if (latest.usedPct >= 100) {
279
+ const first = sorted.find((p) => p.usedPct >= 100);
280
+ if (first)
281
+ weekly.set(provider, new Date(first.t).toISOString());
282
+ }
283
+ const latestMonthlyExhausted = latest.monthlyStatus === "exhausted" ||
284
+ (typeof latest.monthlyPct === "number" && latest.monthlyPct >= 100);
285
+ if (latestMonthlyExhausted) {
286
+ const firstMonthly = sorted.find((p) => p.monthlyStatus === "exhausted" ||
287
+ (typeof p.monthlyPct === "number" && p.monthlyPct >= 100));
288
+ if (firstMonthly)
289
+ monthly.set(provider, new Date(firstMonthly.t).toISOString());
290
+ }
291
+ }
292
+ return { weekly, monthly };
293
+ }
242
294
  export function getHistoryBaseline(db, limit = 4) {
243
295
  let rows = [];
244
296
  try {
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.53";
2
+ export const VERSION = "0.0.55";
@@ -11,6 +11,7 @@ export interface BurnGeometry {
11
11
  projYPct: number;
12
12
  exhaustsBeforeReset: boolean;
13
13
  tone: BurnTone;
14
+ exhaustedElapsedPct: number | null;
14
15
  }
15
16
  /**
16
17
  * Burn-down geometry in quota-window data space (x = elapsed %, y = used %).
@@ -5,6 +5,7 @@ import { PACE_TIPS, PaceStats, barGeometry, isMonthlyBound, paceBadge, paceFigur
5
5
  export function burnTone(provider) {
6
6
  const badge = paceBadge(provider);
7
7
  switch (badge) {
8
+ case "Quota exhausted":
8
9
  case "Cap risk":
9
10
  case "Monthly exhausted":
10
11
  case "Watch":
@@ -51,38 +52,60 @@ export function burnGeometry(provider, asOfMs) {
51
52
  const { usedPct, elapsedPct } = barGeometry(provider, asOfMs);
52
53
  if (elapsedPct === null || !Number.isFinite(elapsedPct))
53
54
  return null;
55
+ let exhaustedElapsedPct = null;
56
+ const isExhausted = usedPct >= 100 ||
57
+ (monthly && advisory.bindingRemaining <= 0) ||
58
+ advisory.remaining <= 0;
59
+ if (isExhausted) {
60
+ if (advisory.exhaustedAt) {
61
+ const exMs = Date.parse(advisory.exhaustedAt);
62
+ const startIso = monthly ? provider.quota.periodStart : quotaWindowStartIso(provider);
63
+ const resetIso = monthly ? provider.quota.monthlyResetsAt : quotaResetsAt(provider);
64
+ const startMs = startIso ? Date.parse(startIso) : NaN;
65
+ const resetMs = resetIso ? Date.parse(resetIso) : NaN;
66
+ if (Number.isFinite(exMs) &&
67
+ Number.isFinite(startMs) &&
68
+ Number.isFinite(resetMs) &&
69
+ resetMs > startMs) {
70
+ exhaustedElapsedPct = Math.min(elapsedPct, Math.max(0, ((exMs - startMs) / (resetMs - startMs)) * 100));
71
+ }
72
+ }
73
+ if (exhaustedElapsedPct === null) {
74
+ exhaustedElapsedPct = elapsedPct;
75
+ }
76
+ }
54
77
  let projXPct;
55
78
  let projYPct;
56
79
  let exhaustsBeforeReset = false;
57
- if (daysToExhaust !== null &&
80
+ if (isExhausted) {
81
+ exhaustsBeforeReset = true;
82
+ projYPct = 100;
83
+ projXPct = 100;
84
+ }
85
+ else if (daysToExhaust !== null &&
58
86
  Number.isFinite(daysToExhaust) &&
59
87
  daysToExhaust < daysLeft) {
60
88
  exhaustsBeforeReset = true;
61
89
  projYPct = 100;
62
- if (usedPct >= 100) {
63
- projXPct = elapsedPct;
90
+ const startIso = quotaWindowStartIso(provider);
91
+ const resetIso = quotaResetsAt(provider);
92
+ const startMs = startIso ? Date.parse(startIso) : NaN;
93
+ const resetMs = resetIso ? Date.parse(resetIso) : NaN;
94
+ if (Number.isFinite(startMs) &&
95
+ Number.isFinite(resetMs) &&
96
+ resetMs > startMs &&
97
+ Number.isFinite(asOfMs)) {
98
+ const totalDays = (resetMs - startMs) / 86_400_000;
99
+ const elapsedDays = (asOfMs - startMs) / 86_400_000;
100
+ projXPct = ((elapsedDays + daysToExhaust) / totalDays) * 100;
101
+ }
102
+ else if (elapsedPct < 100 && daysLeft > 0) {
103
+ projXPct = elapsedPct + (daysToExhaust * (100 - elapsedPct)) / daysLeft;
64
104
  }
65
105
  else {
66
- const startIso = quotaWindowStartIso(provider);
67
- const resetIso = quotaResetsAt(provider);
68
- const startMs = startIso ? Date.parse(startIso) : NaN;
69
- const resetMs = resetIso ? Date.parse(resetIso) : NaN;
70
- if (Number.isFinite(startMs) &&
71
- Number.isFinite(resetMs) &&
72
- resetMs > startMs &&
73
- Number.isFinite(asOfMs)) {
74
- const totalDays = (resetMs - startMs) / 86_400_000;
75
- const elapsedDays = (asOfMs - startMs) / 86_400_000;
76
- projXPct = ((elapsedDays + daysToExhaust) / totalDays) * 100;
77
- }
78
- else if (elapsedPct < 100 && daysLeft > 0) {
79
- projXPct = elapsedPct + (daysToExhaust * (100 - elapsedPct)) / daysLeft;
80
- }
81
- else {
82
- projXPct = 100;
83
- }
84
- projXPct = Math.min(100, Math.max(elapsedPct, projXPct));
106
+ projXPct = 100;
85
107
  }
108
+ projXPct = Math.min(100, Math.max(elapsedPct, projXPct));
86
109
  }
87
110
  else {
88
111
  projXPct = 100;
@@ -98,12 +121,17 @@ export function burnGeometry(provider, asOfMs) {
98
121
  projYPct,
99
122
  exhaustsBeforeReset,
100
123
  tone: burnTone(provider),
124
+ exhaustedElapsedPct,
101
125
  };
102
126
  }
103
127
  export function burnAriaLabel(provider, geo) {
104
128
  const used = Math.round(geo.usedPct);
105
129
  const elapsed = Math.round(geo.elapsedPct);
106
130
  const window = `${quotaWindowName(provider).toLowerCase()} quota window`;
131
+ if (geo.exhaustedElapsedPct !== null && geo.usedPct >= 100) {
132
+ const exElapsed = Math.round(geo.exhaustedElapsedPct);
133
+ return `Burn-down: 100 percent used at ${exElapsed} percent elapsed, currently ${elapsed} percent elapsed (${window})`;
134
+ }
107
135
  if (geo.exhaustsBeforeReset) {
108
136
  return `Burn-down: ${used} percent used at ${elapsed} percent elapsed, forecast exhausts the quota before reset (${window})`;
109
137
  }
@@ -125,10 +153,17 @@ function fallbackBasis(provider, asOfMs) {
125
153
  return PACE_TIPS.recent;
126
154
  const startIso = quotaWindowStartIso(provider);
127
155
  const start = startIso ? Date.parse(startIso) : NaN;
128
- if (Number.isFinite(start) && Number.isFinite(asOfMs) && asOfMs > start) {
129
- const days = (asOfMs - start) / 86_400_000;
130
- if (days >= 0.1)
156
+ const isExhausted = (provider.quota && provider.quota.weeklyPct >= 100) ||
157
+ (advisory.bindingRemaining <= 0);
158
+ const endMs = isExhausted && advisory.exhaustedAt ? Date.parse(advisory.exhaustedAt) : asOfMs;
159
+ if (Number.isFinite(start) && Number.isFinite(endMs) && endMs > start) {
160
+ const days = (endMs - start) / 86_400_000;
161
+ if (days >= 0.1) {
162
+ if (isExhausted && advisory.exhaustedAt) {
163
+ return `averaged over ${days.toFixed(1)} days to reach 100%`;
164
+ }
131
165
  return `averaged over ${days.toFixed(1)} days of this window`;
166
+ }
132
167
  }
133
168
  if (provider.evidence.includes("measured") || advisory.burnMeasured)
134
169
  return "Measured";
@@ -166,9 +201,13 @@ export function BurnDown({ provider, asOf, basis, }) {
166
201
  const tone = geo.tone;
167
202
  const nowX = sx(geo.elapsedPct);
168
203
  const nowY = sy(geo.usedPct);
204
+ const isExhausted = geo.exhaustedElapsedPct !== null && geo.usedPct >= 100;
205
+ const exhaustX = isExhausted ? sx(geo.exhaustedElapsedPct) : nowX;
206
+ const dotX = isExhausted ? exhaustX : nowX;
207
+ const dotY = isExhausted ? PLOT.top : nowY;
169
208
  const projX = sx(geo.projXPct);
170
209
  const projY = sy(geo.projYPct);
171
- const showProjDot = Math.hypot(projX - nowX, projY - nowY) >= 3;
210
+ const showProjDot = !isExhausted && Math.hypot(projX - nowX, projY - nowY) >= 3;
172
211
  const label = burnAriaLabel(provider, geo);
173
212
  const windowName = quotaWindowName(provider);
174
213
  const elapsedLabel = `${Math.round(geo.elapsedPct)}% elapsed`;
@@ -178,5 +217,5 @@ export function BurnDown({ provider, asOf, basis, }) {
178
217
  const idealPct = geo.elapsedPct > 52 ? 35 : 66;
179
218
  const idealX = sx(idealPct);
180
219
  const idealY = sy(idealPct) - 5;
181
- return (_jsxs("section", { className: "dsec", "aria-label": "Pace", "data-testid": "burn-down-section", children: [_jsxs("div", { className: "burn-head", children: [_jsx("h3", { children: "Pace" }), _jsxs("span", { className: "burn-win", children: [windowName, " quota window"] })] }), _jsxs("svg", { className: "burn", viewBox: "0 0 400 184", role: "img", "aria-label": label, "data-testid": "burn-down", children: [_jsx("text", { x: 11, y: PLOT_MID_Y, className: "axis-y", transform: `rotate(-90 11 ${PLOT_MID_Y})`, children: "Quota used (%)" }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: PLOT.right, y2: PLOT.bottom, className: "grid" }), _jsx("line", { x1: PLOT.left, y1: PLOT_MID_Y, x2: PLOT.right, y2: PLOT_MID_Y, className: "grid" }), _jsx("line", { x1: PLOT.left, y1: PLOT.top, x2: PLOT.right, y2: PLOT.top, className: "grid" }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: PLOT.left, y2: PLOT.bottom + 4, className: "grid-tick" }), _jsx("line", { x1: PLOT_MID_X, y1: PLOT.bottom, x2: PLOT_MID_X, y2: PLOT.bottom + 4, className: "grid-tick" }), _jsx("line", { x1: PLOT.right, y1: PLOT.bottom, x2: PLOT.right, y2: PLOT.bottom + 4, className: "grid-tick" }), _jsx("text", { x: 40, y: PLOT.bottom + 3.5, className: "ylab", children: "0%" }), _jsx("text", { x: 40, y: PLOT_MID_Y + 3.5, className: "ylab", children: "50%" }), _jsx("text", { x: 40, y: PLOT.top + 3.5, className: "ylab", children: "100%" }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: PLOT.right, y2: PLOT.top, className: "ideal" }), _jsx("text", { x: n(idealX), y: n(idealY), className: "ideal-lab", textAnchor: "middle", transform: `rotate(-18.5 ${n(idealX)} ${n(idealY)})`, children: "ideal pace" }), _jsx("line", { x1: n(nowX), y1: PLOT.top - 8, x2: n(nowX), y2: PLOT.bottom, className: "now" }), _jsxs("text", { x: n(nowTextX), y: 12, className: "now-lab", textAnchor: "middle", children: ["now \u00B7 ", elapsedLabel] }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: n(nowX), y2: n(nowY), className: `actual ${tone}`, "data-testid": "burn-actual" }), _jsx("line", { x1: n(nowX), y1: n(nowY), x2: n(projX), y2: n(projY), className: `proj ${tone}`, "data-testid": "burn-proj", "data-proj-x": n(geo.projXPct), "data-proj-y": n(geo.projYPct) }), _jsx("circle", { cx: n(nowX), cy: n(nowY), r: 4.5, className: `dot ${tone}` }), showProjDot && (_jsx("circle", { cx: n(projX), cy: n(projY), r: 3.5, className: `dot ${tone} hollow` })), _jsx("text", { x: PLOT.left, y: 160, className: "xlab", children: "start \u00B7 0%" }), _jsx("text", { x: PLOT_MID_X, y: 160, className: "xlab mid", children: "50%" }), _jsx("text", { x: PLOT.right, y: 160, className: "xlab end", children: "quota reset \u00B7 100%" }), _jsx("text", { x: PLOT_MID_X, y: 178, className: "axis-x", children: "Time elapsed in window (%)" })] }), _jsxs("p", { className: "burn-legend", children: [_jsx("span", { className: "legend-label", children: "Legend" }), _jsxs("span", { className: "legend-item", children: [_jsx("span", { className: `burn-swatch-dot ${tone}`, "aria-hidden": "true" }), "Dot is now"] }), _jsxs("span", { className: "legend-item", children: [_jsx("span", { className: `burn-swatch-proj ${tone}`, "aria-hidden": "true" }), "Dashed is forecast"] })] }), _jsx(PaceStats, { provider: provider, asOf: asOf }), _jsx("p", { className: "pacecap", children: _jsx("span", { className: "basis", children: basisLine }) })] }));
220
+ return (_jsxs("section", { className: "dsec", "aria-label": "Pace", "data-testid": "burn-down-section", children: [_jsxs("div", { className: "burn-head", children: [_jsx("h3", { children: "Pace" }), _jsxs("span", { className: "burn-win", children: [windowName, " quota window"] })] }), _jsxs("svg", { className: "burn", viewBox: "0 0 400 184", role: "img", "aria-label": label, "data-testid": "burn-down", children: [_jsx("text", { x: 11, y: PLOT_MID_Y, className: "axis-y", transform: `rotate(-90 11 ${PLOT_MID_Y})`, children: "Quota used (%)" }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: PLOT.right, y2: PLOT.bottom, className: "grid" }), _jsx("line", { x1: PLOT.left, y1: PLOT_MID_Y, x2: PLOT.right, y2: PLOT_MID_Y, className: "grid" }), _jsx("line", { x1: PLOT.left, y1: PLOT.top, x2: PLOT.right, y2: PLOT.top, className: "grid" }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: PLOT.left, y2: PLOT.bottom + 4, className: "grid-tick" }), _jsx("line", { x1: PLOT_MID_X, y1: PLOT.bottom, x2: PLOT_MID_X, y2: PLOT.bottom + 4, className: "grid-tick" }), _jsx("line", { x1: PLOT.right, y1: PLOT.bottom, x2: PLOT.right, y2: PLOT.bottom + 4, className: "grid-tick" }), _jsx("text", { x: 40, y: PLOT.bottom + 3.5, className: "ylab", children: "0%" }), _jsx("text", { x: 40, y: PLOT_MID_Y + 3.5, className: "ylab", children: "50%" }), _jsx("text", { x: 40, y: PLOT.top + 3.5, className: "ylab", children: "100%" }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: PLOT.right, y2: PLOT.top, className: "ideal" }), _jsx("text", { x: n(idealX), y: n(idealY), className: "ideal-lab", textAnchor: "middle", transform: `rotate(-18.5 ${n(idealX)} ${n(idealY)})`, children: "ideal pace" }), _jsx("line", { x1: n(nowX), y1: PLOT.top - 8, x2: n(nowX), y2: PLOT.bottom, className: "now" }), _jsxs("text", { x: n(nowTextX), y: 12, className: "now-lab", textAnchor: "middle", children: ["now \u00B7 ", elapsedLabel] }), _jsx("line", { x1: PLOT.left, y1: PLOT.bottom, x2: n(isExhausted ? exhaustX : nowX), y2: n(isExhausted ? PLOT.top : nowY), className: `actual ${tone}`, "data-testid": "burn-actual" }), isExhausted && nowX > exhaustX && (_jsx("line", { x1: n(exhaustX), y1: PLOT.top, x2: n(nowX), y2: PLOT.top, className: `actual ${tone}`, "data-testid": "burn-exhausted-plateau" })), _jsx("line", { x1: n(nowX), y1: n(isExhausted ? PLOT.top : nowY), x2: n(projX), y2: n(projY), className: `proj ${tone}`, "data-testid": "burn-proj", "data-proj-x": n(geo.projXPct), "data-proj-y": n(geo.projYPct) }), _jsx("circle", { cx: n(dotX), cy: n(dotY), r: 4.5, className: `dot ${tone}`, "data-testid": "burn-dot" }), showProjDot && (_jsx("circle", { cx: n(projX), cy: n(projY), r: 3.5, className: `dot ${tone} hollow` })), _jsx("text", { x: PLOT.left, y: 160, className: "xlab", children: "start \u00B7 0%" }), _jsx("text", { x: PLOT_MID_X, y: 160, className: "xlab mid", children: "50%" }), _jsx("text", { x: PLOT.right, y: 160, className: "xlab end", children: "quota reset \u00B7 100%" }), _jsx("text", { x: PLOT_MID_X, y: 178, className: "axis-x", children: "Time elapsed in window (%)" })] }), _jsxs("p", { className: "burn-legend", children: [_jsx("span", { className: "legend-label", children: "Legend" }), _jsxs("span", { className: "legend-item", children: [_jsx("span", { className: `burn-swatch-dot ${tone}`, "aria-hidden": "true" }), isExhausted ? "Dot is quota exhausted" : "Dot is now"] }), _jsxs("span", { className: "legend-item", children: [_jsx("span", { className: `burn-swatch-proj ${tone}`, "aria-hidden": "true" }), "Dashed is forecast"] })] }), _jsx(PaceStats, { provider: provider, asOf: asOf }), _jsx("p", { className: "pacecap", children: _jsx("span", { className: "basis", children: basisLine }) })] }));
182
221
  }
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import type { ExclusionReason, ProviderView } from "../state.js";
3
- export type PaceBadge = "Not reporting" | "Cap risk" | "Watch" | "Behind pace" | "Ahead of pace" | "On track" | "Measuring" | "Monthly exhausted";
3
+ export type PaceBadge = "Not reporting" | "Cap risk" | "Watch" | "Behind pace" | "Ahead of pace" | "On track" | "Measuring" | "Monthly exhausted" | "Quota exhausted";
4
4
  /**
5
5
  * Pace-state badge from the mapping table. Exclusion always wins; a blocked
6
6
  * month next; otherwise status "at risk" wins, then "watch", then "unknown";
@@ -14,6 +14,12 @@ export function paceBadge(provider) {
14
14
  return "Not reporting";
15
15
  if (advisory.bindingWindow === "monthly" && advisory.bindingRemaining === 0)
16
16
  return "Monthly exhausted";
17
+ const quotaUsed = quotaUsedPct(provider);
18
+ const isExhausted = (typeof advisory.bindingRemaining === "number" && advisory.bindingRemaining <= 0) ||
19
+ (typeof advisory.remaining === "number" && advisory.remaining <= 0) ||
20
+ (quotaUsed !== null && quotaUsed >= 100);
21
+ if (isExhausted)
22
+ return "Quota exhausted";
17
23
  if (advisory.status === "at risk")
18
24
  return "Cap risk";
19
25
  if (advisory.status === "watch")
@@ -134,7 +140,11 @@ export function quotaAvgPace(provider, asOfMs) {
134
140
  const startMs = Date.parse(startIso);
135
141
  if (!Number.isFinite(startMs) || !Number.isFinite(asOfMs))
136
142
  return null;
137
- const elapsedDays = (asOfMs - startMs) / 86_400_000;
143
+ const isExhausted = provider.advisory.bindingRemaining <= 0 || used >= 100;
144
+ const endMs = isExhausted && provider.advisory.exhaustedAt
145
+ ? Date.parse(provider.advisory.exhaustedAt)
146
+ : asOfMs;
147
+ const elapsedDays = (endMs - startMs) / 86_400_000;
138
148
  if (elapsedDays < 0.25)
139
149
  return null;
140
150
  const pace = used / elapsedDays;
@@ -399,6 +409,7 @@ export function badgeColor(badge) {
399
409
  return "var(--ahead)";
400
410
  case "Watch":
401
411
  return "var(--watch)";
412
+ case "Quota exhausted":
402
413
  case "Monthly exhausted":
403
414
  case "Cap risk":
404
415
  return "var(--danger)";
@@ -418,6 +429,7 @@ export function fillToken(badge) {
418
429
  return "var(--fill-ahead)";
419
430
  case "Watch":
420
431
  return "var(--fill-watch)";
432
+ case "Quota exhausted":
421
433
  case "Monthly exhausted":
422
434
  case "Cap risk":
423
435
  return "var(--fill-cap)";
@@ -437,6 +449,7 @@ export function edgeToken(badge) {
437
449
  return "var(--edge-ahead)";
438
450
  case "Watch":
439
451
  return "var(--edge-watch)";
452
+ case "Quota exhausted":
440
453
  case "Monthly exhausted":
441
454
  case "Cap risk":
442
455
  return "var(--edge-cap)";
@@ -452,6 +465,7 @@ export function outKind(badge) {
452
465
  return "behind";
453
466
  case "Watch":
454
467
  return "watch";
468
+ case "Quota exhausted":
455
469
  case "Monthly exhausted":
456
470
  case "Cap risk":
457
471
  return "cap";
@@ -474,7 +488,13 @@ export function hatchGradient(_badge) {
474
488
  */
475
489
  export function badgeTitle(badge, estimated) {
476
490
  let title;
477
- if (badge === "Cap risk") {
491
+ if (badge === "Quota exhausted") {
492
+ title = "Quota fully used until reset";
493
+ }
494
+ else if (badge === "Monthly exhausted") {
495
+ title = "Monthly quota fully used until reset";
496
+ }
497
+ else if (badge === "Cap risk") {
478
498
  title = "Burning faster than the window allows and ahead of elapsed time";
479
499
  }
480
500
  else if (badge === "Watch") {
@@ -495,7 +515,7 @@ export function Badge({ provider, compact = false, }) {
495
515
  ? "pace-ahead"
496
516
  : badge === "Watch"
497
517
  ? "pace-watch"
498
- : badge === "Cap risk" || badge === "Monthly exhausted"
518
+ : badge === "Cap risk" || badge === "Monthly exhausted" || badge === "Quota exhausted"
499
519
  ? "pace-cap"
500
520
  : "pace-out"; // Not reporting and Measuring share the neutral style
501
521
  const title = compact && detail
@@ -5,6 +5,7 @@ const STATES = [
5
5
  { label: "Behind pace", cls: "pace-behind", meaning: "Burning slow; risk of unused quota waste" },
6
6
  { label: "Ahead of pace", cls: "pace-ahead", meaning: "Burning faster than time; monitor usage" },
7
7
  { label: "Cap risk", cls: "pace-cap", meaning: "Will hit cap before reset window closes" },
8
+ { label: "Quota exhausted", cls: "pace-cap", meaning: "Quota is 100% consumed until next reset" },
8
9
  { label: "Watch", cls: "pace-watch", meaning: "Pace points at the cap, but position or the reset clock is uncertain" },
9
10
  { label: "Measuring", cls: "pace-out", meaning: "Not enough data yet; pace appears after 6h" },
10
11
  { label: "Not reporting", cls: "pace-out", meaning: "No usable reading; excluded from ranking" },
@@ -116,10 +116,17 @@ export function paceBasis(provider, asOfMs) {
116
116
  return "No current reading";
117
117
  const startIso = quotaWindowStartIso(provider);
118
118
  const start = startIso ? Date.parse(startIso) : NaN;
119
- if (Number.isFinite(start) && Number.isFinite(asOfMs) && asOfMs > start) {
120
- const days = (asOfMs - start) / 86_400_000;
121
- if (days >= 0.1)
119
+ const isExhausted = (provider.quota && provider.quota.weeklyPct >= 100) ||
120
+ (advisory.bindingRemaining <= 0);
121
+ const endMs = isExhausted && advisory.exhaustedAt ? Date.parse(advisory.exhaustedAt) : asOfMs;
122
+ if (Number.isFinite(start) && Number.isFinite(endMs) && endMs > start) {
123
+ const days = (endMs - start) / 86_400_000;
124
+ if (days >= 0.1) {
125
+ if (isExhausted && advisory.exhaustedAt) {
126
+ return `averaged over ${days.toFixed(1)} days to reach 100%`;
127
+ }
122
128
  return `averaged over ${days.toFixed(1)} days of this window`;
129
+ }
123
130
  }
124
131
  if (provider.evidence.includes("measured") || advisory.burnMeasured)
125
132
  return "Measured";
@@ -312,9 +319,11 @@ export function verdictFor(provider, asOfMs) {
312
319
  return { tone: "use-more", label: "↑ Use more", detail };
313
320
  }
314
321
  if (lane === "ease-off" || advisory.status === "at risk" || advisory.status === "watch") {
315
- const detail = advisory.daysToExhaust !== null && Number.isFinite(advisory.daysToExhaust)
316
- ? `exhausts in ${daysToDuration(advisory.daysToExhaust)} at current pace`
317
- : paceBadge(provider);
322
+ const detail = advisory.remaining <= 0 || paceBadge(provider) === "Quota exhausted"
323
+ ? "quota fully used"
324
+ : advisory.daysToExhaust !== null && Number.isFinite(advisory.daysToExhaust)
325
+ ? `exhausts in ${daysToDuration(advisory.daysToExhaust)} at current pace`
326
+ : paceBadge(provider);
318
327
  return { tone: "ease-off", label: "↓ Ease off", detail };
319
328
  }
320
329
  return { tone: "neutral", label: "• On track", detail: forecastLine(provider) };
@@ -239,6 +239,7 @@ function pinPace(provider) {
239
239
  return { cls: "pace-ahead", color: "var(--ahead)" };
240
240
  case "Watch":
241
241
  return { cls: "pace-watch", color: "var(--watch)" };
242
+ case "Quota exhausted":
242
243
  case "Monthly exhausted":
243
244
  case "Cap risk":
244
245
  return { cls: "pace-cap", color: "var(--danger)" };
@@ -14,5 +14,5 @@ function servingPort() {
14
14
  */
15
15
  export function DaemonPanel() {
16
16
  const port = servingPort();
17
- return (_jsxs("div", { children: [_jsx("h3", { className: "settings-h3", children: "Local Daemon Process" }), _jsx("p", { className: "settings-desc", children: "Background process managing provider adapters and SQLite store." }), _jsxs("dl", { className: "dkv", children: [_jsx("dt", { children: "HTTP Service" }), _jsx("dd", { children: _jsxs("code", { children: ["127.0.0.1:", port ?? "8787"] }) }), _jsx("dt", { children: "Polling Cadence" }), _jsx("dd", { children: "Every 15 minutes (default)" }), _jsx("dt", { children: "Database File" }), _jsx("dd", { children: _jsx("code", { children: "~/.quotacap/quotacap.db" }) })] }), _jsxs("p", { className: "settings-note", children: ["Change the port and polling cadence in ", _jsx("code", { children: "~/.quotacap/config.json" }), " (", _jsx("code", { children: "port" }), ",", " ", _jsx("code", { children: "pollMinutes" }), "; defaults 8787 and 15), then restart the daemon. These controls stay read-only until the server offers a settings API."] })] }));
17
+ return (_jsxs("div", { children: [_jsx("h3", { className: "settings-h3", children: "Local Daemon Process" }), _jsx("p", { className: "settings-desc", children: "Background process managing provider adapters and SQLite store." }), _jsxs("dl", { className: "dkv", children: [_jsx("dt", { children: "HTTP Service" }), _jsx("dd", { children: _jsxs("code", { children: ["127.0.0.1:", port ?? "8787"] }) }), _jsx("dt", { children: "Polling Cadence" }), _jsx("dd", { children: "Every 15 minutes (default)" }), _jsx("dt", { children: "Database File" }), _jsx("dd", { children: _jsx("code", { children: "~/.quotacap/quotacap.db" }) })] }), _jsxs("p", { className: "settings-note", children: ["Change the port and polling cadence in ", _jsx("code", { children: "~/.quotacap/config.json" }), " (", _jsx("code", { children: "port" }), ",", " ", _jsx("code", { children: "pollMinutes" }), "; defaults 8787 and 10), then restart the daemon. These controls stay read-only until the server offers a settings API."] })] }));
18
18
  }
@@ -26,6 +26,7 @@ export interface AdvisoryView {
26
26
  bindingWindow: "weekly" | "monthly";
27
27
  bindingRemaining: number;
28
28
  bindingDaysLeft: number;
29
+ exhaustedAt?: string | null;
29
30
  }
30
31
  export interface RecommendationView {
31
32
  use: string;
@@ -96,7 +96,7 @@ export function ageLabel(asOf, nowMs = Date.now()) {
96
96
  return `read ${Math.floor(hours / 24)}d ago`;
97
97
  }
98
98
  /** Default poll interval; matches `pollMinutes` in `src/config.ts`. */
99
- export const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000;
99
+ export const DEFAULT_POLL_INTERVAL_MS = 10 * 60 * 1000;
100
100
  /** Health-strip copy for the next scheduled poll. */
101
101
  export function nextRefreshLabel(lastCompletedPollAt, polling, nowMs = Date.now(), intervalMs = DEFAULT_POLL_INTERVAL_MS) {
102
102
  if (polling === "in-progress")