quotacap 0.0.54 → 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"]),
@@ -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.54";
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 %).
@@ -52,38 +52,60 @@ export function burnGeometry(provider, asOfMs) {
52
52
  const { usedPct, elapsedPct } = barGeometry(provider, asOfMs);
53
53
  if (elapsedPct === null || !Number.isFinite(elapsedPct))
54
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
+ }
55
77
  let projXPct;
56
78
  let projYPct;
57
79
  let exhaustsBeforeReset = false;
58
- if (daysToExhaust !== null &&
80
+ if (isExhausted) {
81
+ exhaustsBeforeReset = true;
82
+ projYPct = 100;
83
+ projXPct = 100;
84
+ }
85
+ else if (daysToExhaust !== null &&
59
86
  Number.isFinite(daysToExhaust) &&
60
87
  daysToExhaust < daysLeft) {
61
88
  exhaustsBeforeReset = true;
62
89
  projYPct = 100;
63
- if (usedPct >= 100) {
64
- 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;
65
104
  }
66
105
  else {
67
- const startIso = quotaWindowStartIso(provider);
68
- const resetIso = quotaResetsAt(provider);
69
- const startMs = startIso ? Date.parse(startIso) : NaN;
70
- const resetMs = resetIso ? Date.parse(resetIso) : NaN;
71
- if (Number.isFinite(startMs) &&
72
- Number.isFinite(resetMs) &&
73
- resetMs > startMs &&
74
- Number.isFinite(asOfMs)) {
75
- const totalDays = (resetMs - startMs) / 86_400_000;
76
- const elapsedDays = (asOfMs - startMs) / 86_400_000;
77
- projXPct = ((elapsedDays + daysToExhaust) / totalDays) * 100;
78
- }
79
- else if (elapsedPct < 100 && daysLeft > 0) {
80
- projXPct = elapsedPct + (daysToExhaust * (100 - elapsedPct)) / daysLeft;
81
- }
82
- else {
83
- projXPct = 100;
84
- }
85
- projXPct = Math.min(100, Math.max(elapsedPct, projXPct));
106
+ projXPct = 100;
86
107
  }
108
+ projXPct = Math.min(100, Math.max(elapsedPct, projXPct));
87
109
  }
88
110
  else {
89
111
  projXPct = 100;
@@ -99,12 +121,17 @@ export function burnGeometry(provider, asOfMs) {
99
121
  projYPct,
100
122
  exhaustsBeforeReset,
101
123
  tone: burnTone(provider),
124
+ exhaustedElapsedPct,
102
125
  };
103
126
  }
104
127
  export function burnAriaLabel(provider, geo) {
105
128
  const used = Math.round(geo.usedPct);
106
129
  const elapsed = Math.round(geo.elapsedPct);
107
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
+ }
108
135
  if (geo.exhaustsBeforeReset) {
109
136
  return `Burn-down: ${used} percent used at ${elapsed} percent elapsed, forecast exhausts the quota before reset (${window})`;
110
137
  }
@@ -126,10 +153,17 @@ function fallbackBasis(provider, asOfMs) {
126
153
  return PACE_TIPS.recent;
127
154
  const startIso = quotaWindowStartIso(provider);
128
155
  const start = startIso ? Date.parse(startIso) : NaN;
129
- if (Number.isFinite(start) && Number.isFinite(asOfMs) && asOfMs > start) {
130
- const days = (asOfMs - start) / 86_400_000;
131
- 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
+ }
132
165
  return `averaged over ${days.toFixed(1)} days of this window`;
166
+ }
133
167
  }
134
168
  if (provider.evidence.includes("measured") || advisory.burnMeasured)
135
169
  return "Measured";
@@ -167,9 +201,13 @@ export function BurnDown({ provider, asOf, basis, }) {
167
201
  const tone = geo.tone;
168
202
  const nowX = sx(geo.elapsedPct);
169
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;
170
208
  const projX = sx(geo.projXPct);
171
209
  const projY = sy(geo.projYPct);
172
- const showProjDot = Math.hypot(projX - nowX, projY - nowY) >= 3;
210
+ const showProjDot = !isExhausted && Math.hypot(projX - nowX, projY - nowY) >= 3;
173
211
  const label = burnAriaLabel(provider, geo);
174
212
  const windowName = quotaWindowName(provider);
175
213
  const elapsedLabel = `${Math.round(geo.elapsedPct)}% elapsed`;
@@ -179,5 +217,5 @@ export function BurnDown({ provider, asOf, basis, }) {
179
217
  const idealPct = geo.elapsedPct > 52 ? 35 : 66;
180
218
  const idealX = sx(idealPct);
181
219
  const idealY = sy(idealPct) - 5;
182
- 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 }) })] }));
183
221
  }
@@ -140,7 +140,11 @@ export function quotaAvgPace(provider, asOfMs) {
140
140
  const startMs = Date.parse(startIso);
141
141
  if (!Number.isFinite(startMs) || !Number.isFinite(asOfMs))
142
142
  return null;
143
- 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;
144
148
  if (elapsedDays < 0.25)
145
149
  return null;
146
150
  const pace = used / elapsedDays;
@@ -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) };
@@ -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")