quotacap 0.0.28 → 0.0.29

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.
@@ -51,8 +51,8 @@ export function parseCodexTui(text, now = new Date()) {
51
51
  let fiveLeft = null;
52
52
  let weeklyRaw = null;
53
53
  let fiveRaw = null;
54
- const weeklyRe = /Weekly limit:\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/i;
55
- const fiveRe = /5h limit:\s*(\d+)%\s*left\s*\(resets\s+([^)]+)\)/i;
54
+ const weeklyRe = /Weekly limit:\s*(?:\[[^\]]*\]\s*)?(\d+)%\s*left\s*\(resets\s+([^)]+)\)/i;
55
+ const fiveRe = /5h limit:\s*(?:\[[^\]]*\]\s*)?(\d+)%\s*left\s*\(resets\s+([^)]+)\)/i;
56
56
  const w = cleaned.match(weeklyRe);
57
57
  const f = cleaned.match(fiveRe);
58
58
  if (w) {
@@ -127,8 +127,15 @@ export const codexAdapter = {
127
127
  cols: 140,
128
128
  rows: 50,
129
129
  settleDelayMs: 2000,
130
- input: "/status\r",
131
- completionRegex: /\d+%\s*left/i,
130
+ // Two-phase submit: the slash-command autocomplete swallows a
131
+ // same-burst Enter, so the command never runs.
132
+ input: "/status",
133
+ submitInput: "\r",
134
+ submitAfterMs: 1500,
135
+ // Complete on the /status panel only. The startup statusline footer
136
+ // ("· 5h N% left · weekly N% left") carries no reset timestamps and
137
+ // would otherwise win the race, forcing a drifting now+7d estimate.
138
+ completionRegex: /Weekly limit:/i,
132
139
  abortOn: /Do you trust|Trust.*folder|trust the files in this folder/i,
133
140
  timeoutMs: 12000,
134
141
  maxBytes: 256 * 1024,
@@ -5,7 +5,9 @@ import type { Advisory, PaceSource, RecommendationBasis } from "./types.js";
5
5
  *
6
6
  * `usedPct` is a running total since the window opened, and `periodStart` is
7
7
  * a required field every adapter sets, so a pace is always computable from
8
- * one poll. Returns null only when `periodStart` is missing or unparseable.
8
+ * one poll. Returns null when `periodStart` is missing or unparseable, or
9
+ * when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
10
+ * denominator fabricates verdicts).
9
11
  */
10
12
  export declare function averagePace(q: Quota, now?: Date): number | null;
11
13
  export declare function computeAdvisory(q: Quota, burnRate: number | null, now?: Date, paceSourceOrMeasured?: PaceSource | boolean): Advisory;
@@ -1,10 +1,13 @@
1
+ import { MIN_PACE_SPAN_DAYS } from "./types.js";
1
2
  const DAY_MS = 86400000;
2
3
  /**
3
4
  * Pace from a single reading.
4
5
  *
5
6
  * `usedPct` is a running total since the window opened, and `periodStart` is
6
7
  * a required field every adapter sets, so a pace is always computable from
7
- * one poll. Returns null only when `periodStart` is missing or unparseable.
8
+ * one poll. Returns null when `periodStart` is missing or unparseable, or
9
+ * when less than MIN_PACE_SPAN_DAYS has elapsed (annualizing a smaller
10
+ * denominator fabricates verdicts).
8
11
  */
9
12
  export function averagePace(q, now = new Date()) {
10
13
  if (!q.periodStart)
@@ -13,8 +16,8 @@ export function averagePace(q, now = new Date()) {
13
16
  if (Number.isNaN(start))
14
17
  return null;
15
18
  const elapsedDays = (now.getTime() - start) / DAY_MS;
16
- if (elapsedDays < 1 / 24)
17
- return null; // under an hour in: not yet meaningful
19
+ if (elapsedDays < MIN_PACE_SPAN_DAYS)
20
+ return null;
18
21
  const pace = q.usedPct / elapsedDays;
19
22
  if (!Number.isFinite(pace))
20
23
  return null;
@@ -25,6 +28,10 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
25
28
  const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / DAY_MS);
26
29
  const remaining = 100 - q.usedPct;
27
30
  const idealRate = remaining / daysLeft;
31
+ const avgPace = averagePace(q, now);
32
+ // Nothing left to burn: force the capped verdict even when the recent
33
+ // window is flat (burn 0 at 100% used must not read "on track").
34
+ const exhausted = remaining <= 0;
28
35
  let paceSource;
29
36
  let burnMeasured;
30
37
  if (burnRate === null) {
@@ -46,12 +53,29 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
46
53
  remaining,
47
54
  idealRate,
48
55
  burnRate: null,
56
+ avgPace,
49
57
  burnMeasured: false,
50
58
  paceSource: "unknown",
51
- daysToExhaust: null,
52
- status: "unknown",
53
- wastePct: null,
54
- urgency: "on track",
59
+ daysToExhaust: exhausted ? 0 : null,
60
+ status: exhausted ? "at risk" : "unknown",
61
+ wastePct: exhausted ? 0 : null,
62
+ urgency: exhausted ? "slow down" : "on track",
63
+ };
64
+ }
65
+ if (exhausted) {
66
+ return {
67
+ provider: q.provider,
68
+ daysLeft,
69
+ remaining,
70
+ idealRate,
71
+ burnRate,
72
+ avgPace,
73
+ burnMeasured,
74
+ paceSource,
75
+ daysToExhaust: 0,
76
+ status: "at risk",
77
+ wastePct: 0,
78
+ urgency: "slow down",
55
79
  };
56
80
  }
57
81
  const wastePct = Math.max(0, remaining - burnRate * daysLeft);
@@ -67,7 +91,7 @@ export function computeAdvisory(q, burnRate, now = new Date(), paceSourceOrMeasu
67
91
  urgency = "slow down";
68
92
  else if (wastePct > 10)
69
93
  urgency = "save";
70
- return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency };
94
+ return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, avgPace, burnMeasured, paceSource, daysToExhaust, status, wastePct, urgency };
71
95
  }
72
96
  export function recommend(quotas, _task, burnByProvider = new Map(), now = new Date()) {
73
97
  if (!quotas.length) {
@@ -12,6 +12,7 @@ function normalizeAdvisory(adv) {
12
12
  remaining: Number.isFinite(adv.remaining) ? adv.remaining : 0,
13
13
  idealRate: Number.isFinite(adv.idealRate) ? adv.idealRate : 0,
14
14
  burnRate: adv.burnRate !== null && Number.isFinite(adv.burnRate) ? adv.burnRate : null,
15
+ avgPace: adv.avgPace !== null && Number.isFinite(adv.avgPace) ? adv.avgPace : null,
15
16
  daysToExhaust: adv.daysToExhaust !== null && Number.isFinite(adv.daysToExhaust) ? adv.daysToExhaust : null,
16
17
  wastePct: adv.wastePct !== null && Number.isFinite(adv.wastePct) ? adv.wastePct : null,
17
18
  };
@@ -1,5 +1,12 @@
1
1
  import type { Quota } from "../adapters/types.js";
2
2
  import type { AttemptRecord } from "../store/attempts.js";
3
+ /**
4
+ * Minimum data span before a pace annualizes, in days. Under 6h a single
5
+ * 1% quantum annualizes to 4%/day of noise — enough to fabricate Cap-risk
6
+ * and burn-now verdicts on a fresh window. Applies to the window average
7
+ * (elapsed time) and the recent rate (reading span) alike.
8
+ */
9
+ export declare const MIN_PACE_SPAN_DAYS: number;
3
10
  export type Urgency = "burn now" | "use soon" | "slow down" | "save" | "on track";
4
11
  export type BurnStatus = "at risk" | "on track" | "unknown";
5
12
  export type PaceSource = "recent" | "window-average" | "unknown";
@@ -12,6 +19,13 @@ export interface Advisory {
12
19
  remaining: number;
13
20
  idealRate: number;
14
21
  burnRate: number | null;
22
+ /**
23
+ * Window-average pace (used % ÷ days elapsed), always computed when the
24
+ * window start is known — independent of `burnRate`, which is the forecast
25
+ * input (recent 24h rolling burn when available, else this same average).
26
+ * Surfaces show both so a flat 24h (0.0) never hides real window usage.
27
+ */
28
+ avgPace: number | null;
15
29
  burnMeasured: boolean;
16
30
  paceSource: PaceSource;
17
31
  daysToExhaust: number | null;
@@ -1 +1,7 @@
1
- export {};
1
+ /**
2
+ * Minimum data span before a pace annualizes, in days. Under 6h a single
3
+ * 1% quantum annualizes to 4%/day of noise — enough to fabricate Cap-risk
4
+ * and burn-now verdicts on a fresh window. Applies to the window average
5
+ * (elapsed time) and the recent rate (reading span) alike.
6
+ */
7
+ export const MIN_PACE_SPAN_DAYS = 6 / 24;
@@ -28,6 +28,7 @@ export interface RuntimeCommandDeps {
28
28
  export interface WebLaunchOptions {
29
29
  port?: string;
30
30
  foreground?: boolean;
31
+ open?: boolean;
31
32
  }
32
33
  export declare function launchWeb(o: WebLaunchOptions, deps?: RuntimeCommandDeps): Promise<void>;
33
34
  export declare function registerRuntimeCommands(program: Command, deps?: RuntimeCommandDeps): void;
@@ -15,7 +15,13 @@ import { checkSkew, execSkewWarning, formatWedged, olderCliWarning, takeoverMana
15
15
  // overrides skip service management entirely.
16
16
  export async function launchWeb(o, deps = {}) {
17
17
  const start = deps.startService ?? startService;
18
- const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
18
+ // Tab-free mode for tests and automation: print the URL instead of
19
+ // opening the user's browser. The env form also covers bare `quotacap`,
20
+ // which takes no flags.
21
+ const noOpen = o.open === false || process.env.QUOTACAP_NO_OPEN === "1";
22
+ const openBrowser = noOpen
23
+ ? async (url) => { console.log(url); }
24
+ : deps.openBrowser ?? defaultOpenBrowser;
19
25
  const exit = deps.exit ?? process.exit;
20
26
  const execService = deps.execService ?? ((args, opts) => runServiceCommand(args, opts ?? {}, {}));
21
27
  const createClient = deps.createClient ?? ((c) => createServiceClient(c));
@@ -203,6 +209,7 @@ export function registerRuntimeCommands(program, deps) {
203
209
  .command("web")
204
210
  .option("--port <n>")
205
211
  .option("--foreground", "hold the terminal with a foreground service instead of using the login service")
212
+ .option("--no-open", "print the dashboard URL instead of opening a browser")
206
213
  .description("open the dashboard, starting the service if needed")
207
214
  .action(async (o) => launchWeb(o, {
208
215
  startService: start,
@@ -10,11 +10,11 @@ export function renderMarkdownTable(snapshot, now) {
10
10
  const name = p.id === use && use !== "none" ? `★ ${p.displayName}` : p.displayName;
11
11
  const used = row.usedPct === null ? "—" : `${row.usedPct}%`;
12
12
  const elapsed = row.elapsedPct === null ? "—" : `${row.elapsedPct}%`;
13
- return `| ${esc(name)} | ${used} | ${elapsed} | ${esc(row.countdown)} | ${esc(row.state)} | ${esc(row.forecast)} |`;
13
+ return `| ${esc(name)} | ${used} | ${elapsed} | ${esc(row.pace)} | ${esc(row.countdown)} | ${esc(row.state)} | ${esc(row.forecast)} |`;
14
14
  });
15
15
  return [
16
- "| Provider | Used | Elapsed | Resets | State | Forecast |",
17
- "|---|---|---|---|---|---|",
16
+ "| Provider | Used | Elapsed | Pace (%/day avg · 24h) | Resets | State | Forecast |",
17
+ "|---|---|---|---|---|---|---|",
18
18
  ...lines,
19
19
  ].join("\n");
20
20
  }
@@ -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" | "Not reporting";
2
+ export type StateWord = "On track" | "Behind pace" | "Ahead of pace" | "Cap risk" | "Measuring" | "Not reporting";
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 = "" | "!" | "~" | "?";
@@ -11,6 +11,8 @@ export interface Row {
11
11
  elapsedPct: number | null;
12
12
  usedPct: number | null;
13
13
  railCells: RailCell[];
14
+ /** Compact pace pair without units, e.g. "10.6 avg · 0.0 24h". Surfaces label %/day once. */
15
+ pace: string;
14
16
  }
15
17
  export declare const RAIL_CELLS = 20;
16
18
  export declare const SORT_KEYS: SortKey[];
@@ -24,4 +26,5 @@ export declare function forecastText(ps: ProviderSnapshot, now: Date): string;
24
26
  export declare function compactSuffix(ps: ProviderSnapshot): CompactSuffix;
25
27
  export declare function assertValidSortKey(key: string): asserts key is SortKey;
26
28
  export declare function sortProviders(providers: ProviderSnapshot[], key: string, use: string): ProviderSnapshot[];
29
+ export declare function paceText(ps: ProviderSnapshot): string;
27
30
  export declare function buildRow(ps: ProviderSnapshot, now: Date): Row;
@@ -70,6 +70,8 @@ export function stateWord(ps) {
70
70
  return "Not reporting";
71
71
  if (adv.status === "at risk")
72
72
  return "Cap risk";
73
+ if (adv.status === "unknown")
74
+ return "Measuring";
73
75
  switch (adv.urgency) {
74
76
  case "burn now":
75
77
  case "use soon":
@@ -116,7 +118,10 @@ export function forecastText(ps, now) {
116
118
  if (!adv)
117
119
  return "no readings yet";
118
120
  let text;
119
- if (adv.paceSource === "unknown" || adv.burnRate === null) {
121
+ if (adv.remaining <= 0) {
122
+ text = "Exhausted";
123
+ }
124
+ else if (adv.paceSource === "unknown" || adv.burnRate === null) {
120
125
  text = `Measuring pace; ${Math.round(adv.remaining)}% remains with ${adv.daysLeft.toFixed(1)}d until reset`;
121
126
  }
122
127
  else if (adv.wastePct !== null && adv.wastePct > 0) {
@@ -230,6 +235,24 @@ export function sortProviders(providers, key, use) {
230
235
  }
231
236
  }
232
237
  }
238
+ // Compact pace pair: window average first, 24h rate alongside. A pair that
239
+ // renders identically at display precision collapses to one figure.
240
+ export function paceText(ps) {
241
+ const adv = ps.advisory;
242
+ if (!adv)
243
+ return "—";
244
+ // avgPace is absent on advisories from a daemon older than the split.
245
+ const avg = adv.avgPace ?? null;
246
+ const recent = adv.paceSource === "recent" ? adv.burnRate : null;
247
+ if (avg !== null && recent !== null && avg.toFixed(1) !== recent.toFixed(1)) {
248
+ return `${avg.toFixed(1)} avg · ${recent.toFixed(1)} 24h`;
249
+ }
250
+ if (avg !== null)
251
+ return `${avg.toFixed(1)} avg`;
252
+ if (recent !== null)
253
+ return `${recent.toFixed(1)} 24h`;
254
+ return "—";
255
+ }
233
256
  export function buildRow(ps, now) {
234
257
  return {
235
258
  id: ps.id,
@@ -239,5 +262,6 @@ export function buildRow(ps, now) {
239
262
  elapsedPct: elapsedPct(ps, now),
240
263
  usedPct: usedPctOf(ps),
241
264
  railCells: railCells(ps, now),
265
+ pace: paceText(ps),
242
266
  };
243
267
  }
@@ -29,6 +29,7 @@ const STATE_COLORS = {
29
29
  "Behind pace": "\x1b[33m",
30
30
  "Ahead of pace": "\x1b[93m",
31
31
  "Cap risk": "\x1b[31m",
32
+ "Measuring": "\x1b[90m",
32
33
  "Not reporting": "\x1b[90m",
33
34
  };
34
35
  // provWidth covers the 2-char prefix plus name, measured before ANSI.
@@ -72,22 +73,25 @@ export function renderWide(snapshot, opts) {
72
73
  const use = snapshot.recommendation.use;
73
74
  const sorted = sortProviders(snapshot.providers, opts.sort ?? "recommended", use);
74
75
  const width = provWidthFor(snapshot.providers.map((p) => p.displayName));
76
+ const rows = sorted.map((p) => ({ p, row: buildRow(p, opts.now) }));
77
+ const paceWidth = Math.max("PACE (%/DAY)".length, ...rows.map(({ row }) => row.pace.length));
75
78
  const paint = (s, code) => (color ? `${code}${s}${RESET}` : s);
76
79
  const header = [
77
80
  "PROVIDER".padEnd(width),
78
81
  "USED".padStart(6),
79
82
  "ELAPSED".padStart(8),
83
+ "PACE (%/DAY)".padEnd(paceWidth),
80
84
  "USED VS TIME".padEnd(20),
81
85
  "RESETS".padEnd(9),
82
86
  "STATE".padEnd(15),
83
87
  "FORECAST",
84
88
  ].join(GAP);
85
- const lines = sorted.map((p) => {
86
- const row = buildRow(p, opts.now);
89
+ const lines = rows.map(({ p, row }) => {
87
90
  const recommended = p.id === use && use !== "none";
88
91
  const name = rawProviderField(p.displayName, recommended, width, g);
89
92
  const used = (row.usedPct === null ? g.dash : `${row.usedPct}%`).padStart(6);
90
93
  const elapsed = (row.elapsedPct === null ? g.dash : `${row.elapsedPct}%`).padStart(8);
94
+ const pace = (opts.ascii ? asciiText(row.pace) : row.pace).padEnd(paceWidth);
91
95
  const rail = renderRail(row.railCells, g, row.state, color);
92
96
  const countdown = (opts.ascii ? asciiText(row.countdown) : row.countdown).padEnd(9);
93
97
  const state = row.state.padEnd(15);
@@ -96,6 +100,7 @@ export function renderWide(snapshot, opts) {
96
100
  color ? colorProviderField(name, recommended, row.state) : name,
97
101
  used,
98
102
  elapsed,
103
+ pace,
99
104
  rail,
100
105
  countdown,
101
106
  paint(state, STATE_COLORS[row.state]),
@@ -120,8 +125,10 @@ export function renderNarrow(snapshot, opts) {
120
125
  const rail = renderRail(row.railCells, g, row.state, color);
121
126
  const countdown = opts.ascii ? asciiText(row.countdown) : row.countdown;
122
127
  const forecast = opts.ascii ? asciiText(row.forecast) : row.forecast;
128
+ const pace = opts.ascii ? asciiText(row.pace) : row.pace;
129
+ const paceBits = row.pace === "—" ? pace : `${pace} %/day`;
123
130
  const state = color ? `${STATE_COLORS[row.state]}${row.state}${RESET}` : row.state;
124
- lines.push(`${color ? colorProviderField(name, recommended, row.state) : name} ${used} used ${g.sep} ${elapsed} elapsed`, `${rail} resets ${countdown} ${g.sep} ${state} ${g.sep} ${forecast}`);
131
+ lines.push(`${color ? colorProviderField(name, recommended, row.state) : name} ${used} used ${g.sep} ${elapsed} elapsed ${g.sep} ${paceBits}`, `${rail} resets ${countdown} ${g.sep} ${state} ${g.sep} ${forecast}`);
125
132
  }
126
133
  return lines.join("\n");
127
134
  }
@@ -10,7 +10,7 @@ import { ClientError, OFFLINE_LABEL, resolveSnapshot, } from "../cli/snapshot-so
10
10
  export const tools = [
11
11
  { name: "get_quotas", description: "All quotas with resets and health", inputSchema: { type: "object", properties: {}, required: [] } },
12
12
  { name: "get_recommendation", description: "Which provider to use next (same advice for every task)", inputSchema: { type: "object", properties: { task: { type: "string", enum: ["any", "heavy", "light"] } } } },
13
- { name: "forecast", description: "Burn vs ideal + waste for a provider", inputSchema: { type: "object", properties: { provider: { type: "string" } }, required: ["provider"] } },
13
+ { name: "forecast", description: "Burn (24h + window avg) vs ideal + waste for a provider", inputSchema: { type: "object", properties: { provider: { type: "string" } }, required: ["provider"] } },
14
14
  ];
15
15
  // QUOTACAP_URL is used as configured: scheme, authority, effective port and
16
16
  // path prefix all survive, so HTTPS and reverse-proxied services still work.
@@ -1,3 +1,4 @@
1
+ import { MIN_PACE_SPAN_DAYS } from "../advisory/types.js";
1
2
  function mapRow(row) {
2
3
  if (!row)
3
4
  return row;
@@ -88,7 +89,7 @@ export function getBurnRates(db, now = Date.now()) {
88
89
  const cutoff = latest.t - 86400000;
89
90
  const windowStart = sorted.find((p) => p.t >= cutoff) ?? sorted[0];
90
91
  const days = (latest.t - windowStart.t) / 86400000;
91
- if (sorted.length < 2 || days < 1 / 24)
92
+ if (sorted.length < 2 || days < MIN_PACE_SPAN_DAYS)
92
93
  continue;
93
94
  const burn = (latest.usedPct - windowStart.usedPct) / days;
94
95
  if (burn >= 0 && Number.isFinite(burn))
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.28";
2
+ export const VERSION = "0.0.29";
@@ -56,9 +56,10 @@ export function AdviceDrawer({ open, recommendation, providers, asOf, onClose, }
56
56
  ? Math.round(pick.advisory.wastePct)
57
57
  : null;
58
58
  const left = pick?.quota ? timeLeft(pick.quota.resetsAt, asOfMs) : null;
59
+ const basis = pick?.advisory?.paceSource === "window-average" ? "window-average pace" : "current 24h pace";
59
60
  const othersMore = useMore.filter((a) => a.provider !== pickId);
60
61
  const titleId = "advice-drawer-title";
61
- return (_jsxs(_Fragment, { children: [_jsx("div", { "data-testid": "advice-scrim", className: "scrim is-open", "aria-hidden": "true", onClick: onClose }), _jsxs("aside", { ref: dialogRef, tabIndex: -1, "data-testid": "advice-drawer", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: "drawer is-open", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "drawer-head", children: [_jsx("span", { className: "picon rec-picon", "aria-hidden": "true", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }) }), _jsxs("div", { style: { flex: 1 }, children: [_jsx("h2", { id: titleId, children: "Quota Advice & Pacing" }), _jsx("span", { className: "sub", children: "Actionable recommendations based on quota pacing & reset schedules" })] }), _jsx("button", { className: "drawer-close", type: "button", "aria-label": "Close advice", onClick: onClose, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.4", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M6 6l12 12M18 6L6 18" }) }) })] }), _jsxs("div", { className: "dsec", children: [_jsx("h3", { children: "Recommended for next session" }), pick && pickName ? (_jsxs("div", { className: "advice-card", style: { marginTop: 8 }, children: [_jsxs("div", { className: "advice-card-main", children: [_jsxs("div", { className: "advice-card-lead", children: [_jsx("span", { className: "sq", style: providerTint(pick.id), children: _jsx(ProviderIcon, { id: pick.id, title: pickName, size: 16 }) }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("span", { className: "name", children: pickName }), _jsxs("span", { className: "recommended-badge", children: [_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }), "Recommended"] })] })] }), waste !== null && left && (_jsxs("div", { className: "advice-card-stats", children: [_jsxs("b", { children: [waste, "%"] }), " unused quota expires in ", _jsx("b", { children: left })] }))] }), _jsx("p", { className: "advice-card-body", children: remaining !== null && left && waste !== null ? (_jsxs(_Fragment, { children: [_jsx("b", { children: pickName }), " has ", remaining, "% quota remaining with its reset window closing in ", left, ". Current pacing projects ", waste, "% unused quota at reset. Using this subscription now avoids unused quota expiration."] })) : (recommendation.reason) })] })) : (_jsx("p", { className: "advice-card-body", children: recommendation.reason }))] }), _jsxs("div", { className: "dsec", children: [_jsx("h3", { children: "Fleet Pacing Breakdown" }), _jsxs("dl", { className: "dkv", children: [_jsx("dt", { children: "Use more" }), _jsx("dd", { children: othersMore.length
62
+ return (_jsxs(_Fragment, { children: [_jsx("div", { "data-testid": "advice-scrim", className: "scrim is-open", "aria-hidden": "true", onClick: onClose }), _jsxs("aside", { ref: dialogRef, tabIndex: -1, "data-testid": "advice-drawer", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, className: "drawer is-open", onClick: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "drawer-head", children: [_jsx("span", { className: "picon rec-picon", "aria-hidden": "true", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }) }), _jsxs("div", { style: { flex: 1 }, children: [_jsx("h2", { id: titleId, children: "Quota Advice & Pacing" }), _jsx("span", { className: "sub", children: "Actionable recommendations based on quota pacing & reset schedules" })] }), _jsx("button", { className: "drawer-close", type: "button", "aria-label": "Close advice", onClick: onClose, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.4", strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M6 6l12 12M18 6L6 18" }) }) })] }), _jsxs("div", { className: "dsec", children: [_jsx("h3", { children: "Recommended for next session" }), pick && pickName ? (_jsxs("div", { className: "advice-card", style: { marginTop: 8 }, children: [_jsxs("div", { className: "advice-card-main", children: [_jsxs("div", { className: "advice-card-lead", children: [_jsx("span", { className: "sq", style: providerTint(pick.id), children: _jsx(ProviderIcon, { id: pick.id, title: pickName, size: 16 }) }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("span", { className: "name", children: pickName }), _jsxs("span", { className: "recommended-badge", children: [_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z" }) }), "Recommended"] })] })] }), waste !== null && left && (_jsxs("div", { className: "advice-card-stats", children: [_jsxs("b", { children: [waste, "%"] }), " unused quota expires in ", _jsx("b", { children: left })] }))] }), _jsx("p", { className: "advice-card-body", children: remaining !== null && left && waste !== null ? (_jsxs(_Fragment, { children: [_jsx("b", { children: pickName }), " has ", remaining, "% quota remaining with its reset window closing in ", left, ". At the ", basis, ", ", waste, "% unused quota is projected at reset. Using this subscription now avoids unused quota expiration."] })) : (recommendation.reason) })] })) : (_jsx("p", { className: "advice-card-body", children: recommendation.reason }))] }), _jsxs("div", { className: "dsec", children: [_jsx("h3", { children: "Fleet Pacing Breakdown" }), _jsxs("dl", { className: "dkv", children: [_jsx("dt", { children: "Use more" }), _jsx("dd", { children: othersMore.length
62
63
  ? othersMore.map((a, i) => {
63
64
  const p = providers.find((x) => x.id === a.provider);
64
65
  const w = a.wastePct !== null ? `${Math.round(a.wastePct)}% unused` : null;
@@ -1,15 +1,58 @@
1
1
  import React from "react";
2
2
  import type { ExclusionReason, ProviderView } from "../state.js";
3
- export type PaceBadge = "Not reporting" | "Cap risk" | "Behind pace" | "Ahead of pace" | "On track";
3
+ export type PaceBadge = "Not reporting" | "Cap risk" | "Behind pace" | "Ahead of pace" | "On track" | "Measuring";
4
4
  /**
5
5
  * Pace-state badge from the mapping table. Exclusion always wins; otherwise
6
6
  * status "at risk" wins; otherwise urgency decides. No client thresholds.
7
7
  */
8
8
  export declare function paceBadge(provider: ProviderView): PaceBadge;
9
+ export interface PaceFigures {
10
+ /** Window average: used % ÷ days elapsed. Null before the window start is known. */
11
+ avg: number | null;
12
+ /** Recent burn: last-24h rolling rate. Null unless paceSource is "recent". */
13
+ recent: number | null;
14
+ }
15
+ /** Split the advisory into its two displayed paces. `burnRate` doubles as the
16
+ * forecast input, so the recent figure only exists when it was measured. */
17
+ export declare function paceFigures(advisory: {
18
+ avgPace?: number | null;
19
+ burnRate: number | null;
20
+ paceSource: string;
21
+ } | null | undefined): PaceFigures;
22
+ export interface PaceCells {
23
+ avg: string;
24
+ recent: string;
25
+ }
26
+ /** Card pace cells: average and 24h rate side by side, each "—" when unknown.
27
+ * Unlike the collapsed summaries, both cells always render so the pair
28
+ * reads as two peer figures. */
29
+ export declare function paceCells(advisory: {
30
+ avgPace?: number | null;
31
+ burnRate: number | null;
32
+ paceSource: string;
33
+ } | null | undefined): PaceCells;
34
+ /** Hover explanations shared by cards and rows. Plain words, no jargon. */
35
+ export declare const PACE_TIPS: {
36
+ readonly avg: "Window average — quota used ÷ days elapsed. Your overall pace this window.";
37
+ readonly recent: "Burn over the last 24 hours. Forecasts use this rate when recent readings exist.";
38
+ readonly ideal: "The even pace that lands exactly on 100% at reset: remaining ÷ days left.";
39
+ };
40
+ export interface PaceLine {
41
+ label: string;
42
+ value: string;
43
+ tip: string;
44
+ }
45
+ /** Table-row pace stack: one labeled line per known pace plus the needed
46
+ * rate. Both paces always show when known (no collapsing) so rows read
47
+ * uniformly. Empty when no advisory is present. */
48
+ export declare function paceLines(advisory: {
49
+ avgPace?: number | null;
50
+ burnRate: number | null;
51
+ paceSource: string;
52
+ idealRate: number;
53
+ } | null | undefined): PaceLine[];
9
54
  /** Plain-words reason shown beside "Not reporting". Null when the badge says it all. */
10
55
  export declare function exclusionDetail(reason: ExclusionReason): string | null;
11
- /** Coordinator-locked evidence labels. Unknown tokens pass through verbatim. */
12
- export declare function evidenceLabels(evidence: string[]): string[];
13
56
  export declare function isEstimated(provider: ProviderView): boolean;
14
57
  /** Weekday plus 24h clock, e.g. "Thu 21:00". Null when unparseable. */
15
58
  export declare function resetClock(resetsAt: string, locale?: string): string | null;
@@ -20,6 +63,13 @@ export declare function timeLeft(resetsAt: string, asOfMs: number): string | nul
20
63
  * rows await a fresh window; invalid rows carry a row-level note.
21
64
  */
22
65
  export declare function resetCountdown(provider: ProviderView, asOfMs: number): string;
66
+ /**
67
+ * Reset cell for the table view: clock time plus countdown, e.g.
68
+ * "Tue 10:25 · in 20h 30m". Estimated resets mark the timestamp, per the
69
+ * 0.0.25 convention. Falls back to the countdown-only wording for
70
+ * reset-passed, missing, and unparseable rows.
71
+ */
72
+ export declare function resetCell(provider: ProviderView, asOfMs: number): string;
23
73
  export interface BarGeometry {
24
74
  usedPct: number;
25
75
  elapsedPct: number | null;
@@ -12,6 +12,8 @@ export function paceBadge(provider) {
12
12
  return "Not reporting";
13
13
  if (advisory.status === "at risk")
14
14
  return "Cap risk";
15
+ if (advisory.status === "unknown")
16
+ return "Measuring";
15
17
  switch (advisory.urgency) {
16
18
  case "burn now":
17
19
  case "use soon":
@@ -23,6 +25,46 @@ export function paceBadge(provider) {
23
25
  return "On track";
24
26
  }
25
27
  }
28
+ /** Split the advisory into its two displayed paces. `burnRate` doubles as the
29
+ * forecast input, so the recent figure only exists when it was measured. */
30
+ export function paceFigures(advisory) {
31
+ if (!advisory)
32
+ return { avg: null, recent: null };
33
+ const avg = advisory.avgPace ?? null;
34
+ const recent = advisory.paceSource === "recent" ? advisory.burnRate : null;
35
+ return { avg, recent };
36
+ }
37
+ /** Card pace cells: average and 24h rate side by side, each "—" when unknown.
38
+ * Unlike the collapsed summaries, both cells always render so the pair
39
+ * reads as two peer figures. */
40
+ export function paceCells(advisory) {
41
+ const { avg, recent } = paceFigures(advisory);
42
+ return {
43
+ avg: avg !== null ? `${avg.toFixed(1)}%/day` : "—",
44
+ recent: recent !== null ? `${recent.toFixed(1)}%/day` : "—",
45
+ };
46
+ }
47
+ /** Hover explanations shared by cards and rows. Plain words, no jargon. */
48
+ export const PACE_TIPS = {
49
+ avg: "Window average — quota used ÷ days elapsed. Your overall pace this window.",
50
+ recent: "Burn over the last 24 hours. Forecasts use this rate when recent readings exist.",
51
+ ideal: "The even pace that lands exactly on 100% at reset: remaining ÷ days left.",
52
+ };
53
+ /** Table-row pace stack: one labeled line per known pace plus the needed
54
+ * rate. Both paces always show when known (no collapsing) so rows read
55
+ * uniformly. Empty when no advisory is present. */
56
+ export function paceLines(advisory) {
57
+ if (!advisory)
58
+ return [];
59
+ const { avg, recent } = paceFigures(advisory);
60
+ const lines = [];
61
+ if (avg !== null)
62
+ lines.push({ label: "Avg", value: `${avg.toFixed(1)}%/day`, tip: PACE_TIPS.avg });
63
+ if (recent !== null)
64
+ lines.push({ label: "24h", value: `${recent.toFixed(1)}%/day`, tip: PACE_TIPS.recent });
65
+ lines.push({ label: "Ideal", value: `${advisory.idealRate.toFixed(1)}%/day`, tip: PACE_TIPS.ideal });
66
+ return lines;
67
+ }
26
68
  /** Plain-words reason shown beside "Not reporting". Null when the badge says it all. */
27
69
  export function exclusionDetail(reason) {
28
70
  switch (reason) {
@@ -39,16 +81,6 @@ export function exclusionDetail(reason) {
39
81
  return null;
40
82
  }
41
83
  }
42
- const EVIDENCE_LABELS = {
43
- measured: "Measured",
44
- "window-average": "Window avg",
45
- };
46
- /** Coordinator-locked evidence labels. Unknown tokens pass through verbatim. */
47
- export function evidenceLabels(evidence) {
48
- return evidence
49
- .filter((e) => e !== "estimated-reset")
50
- .map((e) => EVIDENCE_LABELS[e] ?? e);
51
- }
52
84
  export function isEstimated(provider) {
53
85
  return provider.evidence.includes("estimated-reset");
54
86
  }
@@ -103,6 +135,30 @@ export function resetCountdown(provider, asOfMs) {
103
135
  const estimated = isEstimated(provider) ? " (est.)" : "";
104
136
  return `in ${left}${estimated}`;
105
137
  }
138
+ /**
139
+ * Reset cell for the table view: clock time plus countdown, e.g.
140
+ * "Tue 10:25 · in 20h 30m". Estimated resets mark the timestamp, per the
141
+ * 0.0.25 convention. Falls back to the countdown-only wording for
142
+ * reset-passed, missing, and unparseable rows.
143
+ */
144
+ export function resetCell(provider, asOfMs) {
145
+ if (provider.exclusionReason === "reset-passed")
146
+ return "awaiting fresh window";
147
+ if (!provider.quota)
148
+ return "no readings";
149
+ const clock = resetClock(provider.quota.resetsAt);
150
+ const left = timeLeft(provider.quota.resetsAt, asOfMs);
151
+ const estimated = isEstimated(provider) ? " (est.)" : "";
152
+ if (clock && left)
153
+ return `${clock}${estimated} · in ${left}`;
154
+ if (left === null) {
155
+ const resetMs = Date.parse(provider.quota.resetsAt);
156
+ if (Number.isFinite(resetMs) && resetMs <= asOfMs)
157
+ return "awaiting fresh window";
158
+ return "reset unknown";
159
+ }
160
+ return "reset unknown";
161
+ }
106
162
  /** Bar geometry from server timestamps and percents. Presentation only. */
107
163
  export function barGeometry(provider, asOfMs) {
108
164
  const usedPct = provider.quota ? Math.min(100, Math.max(0, provider.quota.usedPct)) : 0;
@@ -126,6 +182,8 @@ export function badgeColor(badge) {
126
182
  return "var(--ahead)";
127
183
  case "Cap risk":
128
184
  return "var(--danger)";
185
+ case "Measuring":
186
+ return "var(--ink-soft)";
129
187
  case "Not reporting":
130
188
  return "var(--line-strong)";
131
189
  }
@@ -140,6 +198,8 @@ export function fillToken(badge) {
140
198
  return "var(--fill-ahead)";
141
199
  case "Cap risk":
142
200
  return "var(--fill-cap)";
201
+ case "Measuring":
202
+ return "var(--fill-out)";
143
203
  case "Not reporting":
144
204
  return "var(--fill-out)";
145
205
  }
@@ -154,6 +214,8 @@ export function edgeToken(badge) {
154
214
  return "var(--edge-ahead)";
155
215
  case "Cap risk":
156
216
  return "var(--edge-cap)";
217
+ case "Measuring":
218
+ return "var(--edge-out)";
157
219
  case "Not reporting":
158
220
  return "var(--edge-out)";
159
221
  }
@@ -188,7 +250,7 @@ export function Badge({ provider }) {
188
250
  ? "pace-ahead"
189
251
  : badge === "Cap risk"
190
252
  ? "pace-cap"
191
- : "pace-out";
253
+ : "pace-out"; // Not reporting and Measuring share the neutral style
192
254
  return (_jsxs("span", { "data-testid": "pace-badge", className: `pace ${badgeClass}`, children: [badge, detail ? ` · ${detail}` : ""] }));
193
255
  }
194
256
  /**
@@ -236,6 +298,9 @@ export function forecastLine(provider) {
236
298
  const advisory = provider.advisory;
237
299
  if (!advisory)
238
300
  return null;
301
+ if (advisory.remaining <= 0) {
302
+ return "Exhausted · quota fully used";
303
+ }
239
304
  if (advisory.paceSource === "unknown") {
240
305
  return `Measuring pace · ${Math.round(advisory.remaining)}% remaining`;
241
306
  }