quotacap 0.0.34 → 0.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/advisory/engine.d.ts +8 -3
- package/dist/advisory/engine.js +80 -48
- package/dist/advisory/snapshot.js +37 -21
- package/dist/advisory/types.d.ts +28 -2
- package/dist/advisory/types.js +3 -0
- package/dist/catalog/agy.d.ts +4 -0
- package/dist/catalog/agy.js +59 -0
- package/dist/catalog/claude.d.ts +3 -0
- package/dist/catalog/claude.js +70 -0
- package/dist/catalog/codex.d.ts +3 -0
- package/dist/catalog/codex.js +55 -0
- package/dist/catalog/grok.d.ts +3 -0
- package/dist/catalog/grok.js +36 -0
- package/dist/catalog/index.d.ts +17 -0
- package/dist/catalog/index.js +152 -0
- package/dist/catalog/kimi.d.ts +3 -0
- package/dist/catalog/kimi.js +51 -0
- package/dist/catalog/muse.d.ts +4 -0
- package/dist/catalog/muse.js +67 -0
- package/dist/catalog/types.d.ts +21 -0
- package/dist/catalog/types.js +3 -0
- package/dist/cli/advise.js +20 -0
- package/dist/cli/clients.d.ts +3 -0
- package/dist/cli/clients.js +2 -0
- package/dist/cli/models.d.ts +3 -0
- package/dist/cli/models.js +134 -0
- package/dist/cli/snapshot-source.js +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +2 -0
- package/dist/format/markdown.d.ts +1 -0
- package/dist/format/markdown.js +14 -0
- package/dist/format/rows.d.ts +1 -1
- package/dist/format/rows.js +24 -4
- package/dist/format/terminal.js +18 -1
- package/dist/http/server.d.ts +2 -0
- package/dist/http/server.js +100 -0
- package/dist/mcp/server.d.ts +13 -0
- package/dist/mcp/server.js +31 -3
- package/dist/runtime/poll.d.ts +13 -0
- package/dist/runtime/poll.js +34 -2
- package/dist/runtime/service.js +9 -0
- package/dist/store/catalogs.d.ts +10 -0
- package/dist/store/catalogs.js +64 -0
- package/dist/store/db.js +27 -1
- package/dist/store/quotas.d.ts +22 -0
- package/dist/store/quotas.js +93 -8
- package/dist/version.js +1 -1
- package/dist/web/src/components/ClosedWeeks.d.ts +16 -0
- package/dist/web/src/components/ClosedWeeks.js +102 -0
- package/dist/web/src/components/PaceBar.d.ts +17 -5
- package/dist/web/src/components/PaceBar.js +51 -8
- package/dist/web/src/components/PacingLegend.js +1 -0
- package/dist/web/src/components/ProviderCard.js +3 -1
- package/dist/web/src/components/ProviderDrawer.js +7 -1
- package/dist/web/src/components/ProviderRow.js +3 -1
- package/dist/web/src/components/Recommendation.js +12 -3
- package/dist/web/src/components/ResetRail.js +2 -0
- package/dist/web/src/state.d.ts +20 -1
- package/dist/web/src/state.js +1 -0
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +1 -1
- package/web/dist/assets/index-Cm_QtC0G.js +12 -0
- package/web/dist/assets/index-DiOyUuQc.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CjNVp2Yx.css +0 -1
- package/web/dist/assets/index-irnos0UG.js +0 -12
package/dist/store/quotas.js
CHANGED
|
@@ -26,6 +26,21 @@ function mapRow(row) {
|
|
|
26
26
|
delete out.sessionPct;
|
|
27
27
|
return out;
|
|
28
28
|
}
|
|
29
|
+
function cycleBreakReason(prev, cur) {
|
|
30
|
+
const dropped = cur.usedPct < prev.usedPct;
|
|
31
|
+
const prevResets = prev.resetsAt ? new Date(prev.resetsAt).getTime() : NaN;
|
|
32
|
+
const boundaryRolled = !Number.isNaN(prevResets) && prevResets <= cur.t && prev.resetsAt !== cur.resetsAt;
|
|
33
|
+
if (dropped && boundaryRolled)
|
|
34
|
+
return "both";
|
|
35
|
+
if (dropped)
|
|
36
|
+
return "usage-drop";
|
|
37
|
+
if (boundaryRolled)
|
|
38
|
+
return "resets-at-rolled";
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
export function isWeeklyCycleBreak(prev, cur) {
|
|
42
|
+
return cycleBreakReason(prev, cur) !== null;
|
|
43
|
+
}
|
|
29
44
|
export function upsertQuota(db, q) {
|
|
30
45
|
if (Array.isArray(q)) {
|
|
31
46
|
for (const item of q)
|
|
@@ -35,9 +50,52 @@ export function upsertQuota(db, q) {
|
|
|
35
50
|
const credits = q.creditsUsd ?? q.credits_usd ?? null;
|
|
36
51
|
const estimated = q.resetsAtEstimated ? 1 : null;
|
|
37
52
|
const sessionPct = q.sessionPct ?? q.session_pct ?? null;
|
|
38
|
-
db.
|
|
39
|
-
|
|
40
|
-
|
|
53
|
+
db.exec("BEGIN");
|
|
54
|
+
try {
|
|
55
|
+
const prev = getLatestByProvider(db, q.provider);
|
|
56
|
+
if (prev) {
|
|
57
|
+
const reason = cycleBreakReason({ usedPct: prev.usedPct, t: Date.parse(prev.fetchedAt), resetsAt: prev.resetsAt ?? null }, { usedPct: q.usedPct, t: Date.parse(q.fetchedAt), resetsAt: q.resetsAt ?? null });
|
|
58
|
+
if (reason) {
|
|
59
|
+
db.prepare(`INSERT OR IGNORE INTO window_closes(provider, plan, used_pct, leftover_pct, sampled_at, sampled_quota_id, period_start, resets_at, resets_at_estimated, detected_at, reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)`)
|
|
60
|
+
.run(prev.provider, prev.plan ?? null, prev.usedPct, Math.max(0, 100 - prev.usedPct), prev.fetchedAt, prev.id, prev.periodStart ?? null, prev.resetsAt ?? null, prev.resetsAtEstimated ? 1 : null, q.fetchedAt, reason);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
db.prepare(`INSERT INTO quotas(provider, plan, used_pct, resets_at, period_start, source, fetched_at, credits_usd, resets_at_estimated, session_pct) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(q.provider, q.plan, q.usedPct, q.resetsAt, q.periodStart, q.source, q.fetchedAt, credits, estimated, sessionPct);
|
|
64
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
65
|
+
db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES(?,?,?) ON CONFLICT(day, provider) DO UPDATE SET used_pct=excluded.used_pct`).run(day, q.provider, q.usedPct);
|
|
66
|
+
db.exec("COMMIT");
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
try {
|
|
70
|
+
db.exec("ROLLBACK");
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function getWindowCloses(db, provider, limit = 4) {
|
|
77
|
+
let rows = [];
|
|
78
|
+
try {
|
|
79
|
+
rows = db.prepare(`SELECT provider, plan, used_pct, leftover_pct, sampled_at, period_start, resets_at, resets_at_estimated, detected_at, reason FROM window_closes WHERE provider=? ORDER BY detected_at DESC, id DESC LIMIT ?`).all(provider, limit);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
// A pre-observability database read offline is never migrated, so the
|
|
83
|
+
// table can be absent. No table means no recorded closes.
|
|
84
|
+
if (!/no such table/i.test(String(e?.message ?? "")))
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
return rows.map((r) => ({
|
|
88
|
+
provider: r.provider,
|
|
89
|
+
plan: r.plan ?? null,
|
|
90
|
+
usedPct: r.used_pct,
|
|
91
|
+
leftoverPct: r.leftover_pct,
|
|
92
|
+
sampledAt: r.sampled_at,
|
|
93
|
+
periodStart: r.period_start ?? null,
|
|
94
|
+
resetsAt: r.resets_at ?? null,
|
|
95
|
+
resetsAtEstimated: !!r.resets_at_estimated,
|
|
96
|
+
detectedAt: r.detected_at,
|
|
97
|
+
reason: r.reason,
|
|
98
|
+
}));
|
|
41
99
|
}
|
|
42
100
|
export function getLatestByProvider(db, provider) {
|
|
43
101
|
const row = db.prepare(`SELECT * FROM quotas WHERE provider=? ORDER BY fetched_at DESC LIMIT 1`).get(provider);
|
|
@@ -60,11 +118,7 @@ export function getSnapshots(db) { return db.prepare(`SELECT * FROM snapshots OR
|
|
|
60
118
|
function currentCycle(sorted) {
|
|
61
119
|
let start = 0;
|
|
62
120
|
for (let i = 1; i < sorted.length; i++) {
|
|
63
|
-
|
|
64
|
-
const cur = sorted[i];
|
|
65
|
-
const prevResets = prev.resetsAt ? new Date(prev.resetsAt).getTime() : NaN;
|
|
66
|
-
const boundaryRolled = !Number.isNaN(prevResets) && prevResets <= cur.t && prev.resetsAt !== cur.resetsAt;
|
|
67
|
-
if (boundaryRolled || cur.usedPct < prev.usedPct)
|
|
121
|
+
if (isWeeklyCycleBreak(sorted[i - 1], sorted[i]))
|
|
68
122
|
start = i;
|
|
69
123
|
}
|
|
70
124
|
return start === 0 ? sorted : sorted.slice(start);
|
|
@@ -97,3 +151,34 @@ export function getBurnRates(db, now = Date.now()) {
|
|
|
97
151
|
}
|
|
98
152
|
return out;
|
|
99
153
|
}
|
|
154
|
+
export function getHistoryBaseline(db, limit = 4) {
|
|
155
|
+
let rows = [];
|
|
156
|
+
try {
|
|
157
|
+
rows = db.prepare(`SELECT provider, used_pct, resets_at_estimated FROM window_closes ORDER BY detected_at DESC, id DESC`).all();
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
// A pre-observability database read offline is never migrated, so the
|
|
161
|
+
// table can be absent. No table means no recorded closes.
|
|
162
|
+
if (!/no such table/i.test(String(e?.message ?? "")))
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
const byProvider = new Map();
|
|
166
|
+
for (const r of rows) {
|
|
167
|
+
const closes = byProvider.get(r.provider) ?? [];
|
|
168
|
+
closes.push({ usedPct: r.used_pct, verified: !r.resets_at_estimated });
|
|
169
|
+
byProvider.set(r.provider, closes);
|
|
170
|
+
}
|
|
171
|
+
const out = new Map();
|
|
172
|
+
for (const [provider, newestFirst] of byProvider) {
|
|
173
|
+
const pool = newestFirst.slice(0, limit);
|
|
174
|
+
if (!pool.length)
|
|
175
|
+
continue;
|
|
176
|
+
const verified = pool.filter((c) => c.verified);
|
|
177
|
+
const chosen = verified.length ? verified : pool;
|
|
178
|
+
const mean = chosen.reduce((sum, c) => sum + c.usedPct, 0) / chosen.length;
|
|
179
|
+
const rate = mean / 7;
|
|
180
|
+
if (Number.isFinite(rate))
|
|
181
|
+
out.set(provider, rate);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
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.
|
|
2
|
+
export const VERSION = "0.0.35";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { WindowCloseView } from "../state.js";
|
|
3
|
+
export declare function lastClose(provider: {
|
|
4
|
+
lastCloses?: WindowCloseView[];
|
|
5
|
+
}): WindowCloseView | null;
|
|
6
|
+
export declare function closeDateLabel(resetsAt: string | null): string | null;
|
|
7
|
+
export declare function closeIsStale(c: WindowCloseView): boolean;
|
|
8
|
+
export declare function closeSampleStamp(sampledAt: string): string | null;
|
|
9
|
+
export declare function closeResetStamp(resetsAt: string): string | null;
|
|
10
|
+
/** Four-cell strip of closed weekly windows, oldest left. Fill is used at
|
|
11
|
+
* close, the hatch is the stored leftover. One cell per real close, never a
|
|
12
|
+
* placeholder. Stale samples name their reading on the cell and under the
|
|
13
|
+
* strip. */
|
|
14
|
+
export declare function ClosedWeeks({ closes }: {
|
|
15
|
+
closes: WindowCloseView[];
|
|
16
|
+
}): React.JSX.Element | null;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { format } from "date-fns";
|
|
3
|
+
const STALE_EARLY_MS = 15 * 60 * 1000;
|
|
4
|
+
export function lastClose(provider) {
|
|
5
|
+
const closes = provider.lastCloses;
|
|
6
|
+
return Array.isArray(closes) && closes.length > 0 ? closes[0] : null;
|
|
7
|
+
}
|
|
8
|
+
export function closeDateLabel(resetsAt) {
|
|
9
|
+
if (!resetsAt)
|
|
10
|
+
return null;
|
|
11
|
+
const ms = Date.parse(resetsAt);
|
|
12
|
+
if (!Number.isFinite(ms))
|
|
13
|
+
return null;
|
|
14
|
+
return format(new Date(ms), "d MMM");
|
|
15
|
+
}
|
|
16
|
+
export function closeIsStale(c) {
|
|
17
|
+
if (!c.resetsAt)
|
|
18
|
+
return true;
|
|
19
|
+
const resetMs = Date.parse(c.resetsAt);
|
|
20
|
+
const sampledMs = Date.parse(c.sampledAt);
|
|
21
|
+
if (!Number.isFinite(resetMs) || !Number.isFinite(sampledMs))
|
|
22
|
+
return true;
|
|
23
|
+
return resetMs - sampledMs > STALE_EARLY_MS;
|
|
24
|
+
}
|
|
25
|
+
export function closeSampleStamp(sampledAt) {
|
|
26
|
+
const ms = Date.parse(sampledAt);
|
|
27
|
+
if (!Number.isFinite(ms))
|
|
28
|
+
return null;
|
|
29
|
+
return format(new Date(ms), "EEE d MMM H:mm");
|
|
30
|
+
}
|
|
31
|
+
export function closeResetStamp(resetsAt) {
|
|
32
|
+
const ms = Date.parse(resetsAt);
|
|
33
|
+
if (!Number.isFinite(ms))
|
|
34
|
+
return null;
|
|
35
|
+
return format(new Date(ms), "d MMM HH:mm");
|
|
36
|
+
}
|
|
37
|
+
/** One floored duration for both the compact cell line and the strip note. */
|
|
38
|
+
function earlyParts(ms) {
|
|
39
|
+
if (ms < 60_000)
|
|
40
|
+
return { n: Math.max(1, Math.floor(ms / 1000)), unit: "s" };
|
|
41
|
+
const minutes = Math.floor(ms / 60_000);
|
|
42
|
+
if (minutes < 60)
|
|
43
|
+
return { n: minutes, unit: "m" };
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
if (hours < 48)
|
|
46
|
+
return { n: hours, unit: "h" };
|
|
47
|
+
return { n: Math.floor(hours / 24), unit: "d" };
|
|
48
|
+
}
|
|
49
|
+
const EARLY_UNIT_WORDS = {
|
|
50
|
+
s: "second",
|
|
51
|
+
m: "minute",
|
|
52
|
+
h: "hour",
|
|
53
|
+
d: "day",
|
|
54
|
+
};
|
|
55
|
+
function earlyCompact(ms) {
|
|
56
|
+
const { n, unit } = earlyParts(ms);
|
|
57
|
+
return `${n}${unit}`;
|
|
58
|
+
}
|
|
59
|
+
function earlyWords(ms) {
|
|
60
|
+
const { n, unit } = earlyParts(ms);
|
|
61
|
+
return `${n} ${EARLY_UNIT_WORDS[unit]}${n === 1 ? "" : "s"}`;
|
|
62
|
+
}
|
|
63
|
+
/** Four-cell strip of closed weekly windows, oldest left. Fill is used at
|
|
64
|
+
* close, the hatch is the stored leftover. One cell per real close, never a
|
|
65
|
+
* placeholder. Stale samples name their reading on the cell and under the
|
|
66
|
+
* strip. */
|
|
67
|
+
export function ClosedWeeks({ closes }) {
|
|
68
|
+
if (!Array.isArray(closes) || closes.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const cells = [...closes].reverse();
|
|
71
|
+
const labels = cells.map((c) => closeDateLabel(c.resetsAt));
|
|
72
|
+
return (_jsxs("section", { className: "dsec", "aria-label": "Closed weeks", children: [_jsx("h3", { children: "Closed weeks" }), _jsx("div", { className: "weeks", "data-testid": "closed-weeks", children: cells.map((c, i) => {
|
|
73
|
+
const used = Math.min(100, Math.max(0, c.usedPct));
|
|
74
|
+
const leftover = Math.min(100, Math.max(0, c.leftoverPct));
|
|
75
|
+
const stale = closeIsStale(c);
|
|
76
|
+
const sampled = closeSampleStamp(c.sampledAt);
|
|
77
|
+
const reset = c.resetsAt ? closeResetStamp(c.resetsAt) : null;
|
|
78
|
+
const earlyMs = c.resetsAt ? Date.parse(c.resetsAt) - Date.parse(c.sampledAt) : NaN;
|
|
79
|
+
const est = c.resetsAtEstimated ? " (est.)" : "";
|
|
80
|
+
const title = [
|
|
81
|
+
`${c.usedPct}% used`,
|
|
82
|
+
`${c.leftoverPct}% leftover`,
|
|
83
|
+
reset ? `reset ${reset}${est}` : "reset unknown",
|
|
84
|
+
stale && sampled ? `last reading ${sampled}` : null,
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(", ");
|
|
88
|
+
return (_jsxs("div", { className: "week", title: title, children: [_jsxs("div", { className: "track", role: "img", "aria-label": title, children: [_jsx("div", { className: "fill", style: { width: `${used}%` } }), leftover > 0 && (_jsx("div", { className: "band band-waste", style: { left: `${used}%`, width: `${leftover}%` } }))] }), _jsxs("div", { className: "lab", children: [_jsx("span", { children: labels[i] ?? "date unknown" }), _jsxs("b", { children: [c.leftoverPct, "% leftover", est] }), stale && Number.isFinite(earlyMs) && earlyMs > 0 && (_jsxs("span", { className: "stale-note", children: ["read ", earlyCompact(earlyMs), " early"] }))] })] }, `${c.detectedAt}-${c.sampledAt}`));
|
|
89
|
+
}) }), cells.map((c, i) => {
|
|
90
|
+
const resetMs = c.resetsAt ? Date.parse(c.resetsAt) : NaN;
|
|
91
|
+
const sampledMs = Date.parse(c.sampledAt);
|
|
92
|
+
const sampled = closeSampleStamp(c.sampledAt);
|
|
93
|
+
if (!closeIsStale(c) ||
|
|
94
|
+
!sampled ||
|
|
95
|
+
!Number.isFinite(resetMs) ||
|
|
96
|
+
!Number.isFinite(sampledMs) ||
|
|
97
|
+
resetMs <= sampledMs) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return (_jsxs("p", { className: "cell-s stale-note", children: [labels[i] ?? "Date unknown", " leftover is from the ", sampled, " reading, ", earlyWords(resetMs - sampledMs), " ", "before that reset."] }, `note-${c.detectedAt}-${c.sampledAt}`));
|
|
101
|
+
})] }));
|
|
102
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
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" | "Measuring";
|
|
3
|
+
export type PaceBadge = "Not reporting" | "Cap risk" | "Watch" | "Behind pace" | "Ahead of pace" | "On track" | "Measuring";
|
|
4
4
|
/**
|
|
5
5
|
* Pace-state badge from the mapping table. Exclusion always wins; otherwise
|
|
6
|
-
* status "at risk" wins
|
|
6
|
+
* status "at risk" wins, then "watch", then "unknown"; on-track rows ahead of
|
|
7
|
+
* cumulative elapsed time read Ahead of pace; otherwise urgency decides. No
|
|
8
|
+
* client thresholds.
|
|
7
9
|
*/
|
|
8
10
|
export declare function paceBadge(provider: ProviderView): PaceBadge;
|
|
9
11
|
export interface PaceFigures {
|
|
@@ -12,11 +14,14 @@ export interface PaceFigures {
|
|
|
12
14
|
/** Recent burn: last-24h rolling rate. Null unless paceSource is "recent". */
|
|
13
15
|
recent: number | null;
|
|
14
16
|
}
|
|
15
|
-
/** Split the advisory into its two displayed paces.
|
|
16
|
-
*
|
|
17
|
+
/** Split the advisory into its two displayed paces. The 24h figure is the
|
|
18
|
+
* measured `recentRate`, not the blended `burnRate` forecast input; an
|
|
19
|
+
* advisory from a daemon older than the blend falls back to `burnRate`,
|
|
20
|
+
* which was the raw measured rate then. */
|
|
17
21
|
export declare function paceFigures(advisory: {
|
|
18
22
|
avgPace?: number | null;
|
|
19
23
|
burnRate: number | null;
|
|
24
|
+
recentRate?: number | null;
|
|
20
25
|
paceSource: string;
|
|
21
26
|
} | null | undefined): PaceFigures;
|
|
22
27
|
export interface PaceCells {
|
|
@@ -29,6 +34,7 @@ export interface PaceCells {
|
|
|
29
34
|
export declare function paceCells(advisory: {
|
|
30
35
|
avgPace?: number | null;
|
|
31
36
|
burnRate: number | null;
|
|
37
|
+
recentRate?: number | null;
|
|
32
38
|
paceSource: string;
|
|
33
39
|
} | null | undefined): PaceCells;
|
|
34
40
|
/** Hover explanations shared by cards and rows. Plain words, no jargon. */
|
|
@@ -48,6 +54,7 @@ export interface PaceLine {
|
|
|
48
54
|
export declare function paceLines(advisory: {
|
|
49
55
|
avgPace?: number | null;
|
|
50
56
|
burnRate: number | null;
|
|
57
|
+
recentRate?: number | null;
|
|
51
58
|
paceSource: string;
|
|
52
59
|
idealRate: number;
|
|
53
60
|
} | null | undefined): PaceLine[];
|
|
@@ -89,12 +96,17 @@ export declare function barGeometry(provider: ProviderView, asOfMs: number): Bar
|
|
|
89
96
|
export declare function badgeColor(badge: PaceBadge): string;
|
|
90
97
|
export declare function fillToken(badge: PaceBadge): string;
|
|
91
98
|
export declare function edgeToken(badge: PaceBadge): string;
|
|
92
|
-
export declare function outKind(badge: PaceBadge): "behind" | "cap" | "ontrack" | "none";
|
|
99
|
+
export declare function outKind(badge: PaceBadge): "behind" | "watch" | "cap" | "ontrack" | "none";
|
|
93
100
|
/**
|
|
94
101
|
* Hatch fill for unused quota. Ink mix (not an alpha suffix on a custom
|
|
95
102
|
* property) so the browser always paints a gradient.
|
|
96
103
|
*/
|
|
97
104
|
export declare function hatchGradient(_badge?: PaceBadge): string;
|
|
105
|
+
/**
|
|
106
|
+
* Badge hover copy: red and amber explain the verdict; an estimated reset is
|
|
107
|
+
* confessed on the end. Other badges read their own label and need no title.
|
|
108
|
+
*/
|
|
109
|
+
export declare function badgeTitle(badge: PaceBadge, estimated: boolean): string | undefined;
|
|
98
110
|
export declare function Badge({ provider }: {
|
|
99
111
|
provider: ProviderView;
|
|
100
112
|
}): React.JSX.Element;
|
|
@@ -2,7 +2,9 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { ageDuration } from "../state.js";
|
|
3
3
|
/**
|
|
4
4
|
* Pace-state badge from the mapping table. Exclusion always wins; otherwise
|
|
5
|
-
* status "at risk" wins
|
|
5
|
+
* status "at risk" wins, then "watch", then "unknown"; on-track rows ahead of
|
|
6
|
+
* cumulative elapsed time read Ahead of pace; otherwise urgency decides. No
|
|
7
|
+
* client thresholds.
|
|
6
8
|
*/
|
|
7
9
|
export function paceBadge(provider) {
|
|
8
10
|
if (provider.exclusionReason !== null)
|
|
@@ -12,8 +14,12 @@ export function paceBadge(provider) {
|
|
|
12
14
|
return "Not reporting";
|
|
13
15
|
if (advisory.status === "at risk")
|
|
14
16
|
return "Cap risk";
|
|
17
|
+
if (advisory.status === "watch")
|
|
18
|
+
return "Watch";
|
|
15
19
|
if (advisory.status === "unknown")
|
|
16
20
|
return "Measuring";
|
|
21
|
+
if (advisory.aheadOfElapsed === true)
|
|
22
|
+
return "Ahead of pace";
|
|
17
23
|
switch (advisory.urgency) {
|
|
18
24
|
case "burn now":
|
|
19
25
|
case "use soon":
|
|
@@ -25,13 +31,19 @@ export function paceBadge(provider) {
|
|
|
25
31
|
return "On track";
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
|
-
/** Split the advisory into its two displayed paces.
|
|
29
|
-
*
|
|
34
|
+
/** Split the advisory into its two displayed paces. The 24h figure is the
|
|
35
|
+
* measured `recentRate`, not the blended `burnRate` forecast input; an
|
|
36
|
+
* advisory from a daemon older than the blend falls back to `burnRate`,
|
|
37
|
+
* which was the raw measured rate then. */
|
|
30
38
|
export function paceFigures(advisory) {
|
|
31
39
|
if (!advisory)
|
|
32
40
|
return { avg: null, recent: null };
|
|
33
41
|
const avg = advisory.avgPace ?? null;
|
|
34
|
-
const recent = advisory.
|
|
42
|
+
const recent = advisory.recentRate !== undefined
|
|
43
|
+
? advisory.recentRate
|
|
44
|
+
: advisory.paceSource === "recent"
|
|
45
|
+
? advisory.burnRate
|
|
46
|
+
: null;
|
|
35
47
|
return { avg, recent };
|
|
36
48
|
}
|
|
37
49
|
/** Card pace cells: average and 24h rate side by side, each "—" when unknown.
|
|
@@ -187,6 +199,8 @@ export function badgeColor(badge) {
|
|
|
187
199
|
return "var(--warn)";
|
|
188
200
|
case "Ahead of pace":
|
|
189
201
|
return "var(--ahead)";
|
|
202
|
+
case "Watch":
|
|
203
|
+
return "var(--watch)";
|
|
190
204
|
case "Cap risk":
|
|
191
205
|
return "var(--danger)";
|
|
192
206
|
case "Measuring":
|
|
@@ -203,6 +217,8 @@ export function fillToken(badge) {
|
|
|
203
217
|
return "var(--fill-behind)";
|
|
204
218
|
case "Ahead of pace":
|
|
205
219
|
return "var(--fill-ahead)";
|
|
220
|
+
case "Watch":
|
|
221
|
+
return "var(--fill-watch)";
|
|
206
222
|
case "Cap risk":
|
|
207
223
|
return "var(--fill-cap)";
|
|
208
224
|
case "Measuring":
|
|
@@ -219,6 +235,8 @@ export function edgeToken(badge) {
|
|
|
219
235
|
return "var(--edge-behind)";
|
|
220
236
|
case "Ahead of pace":
|
|
221
237
|
return "var(--edge-ahead)";
|
|
238
|
+
case "Watch":
|
|
239
|
+
return "var(--edge-watch)";
|
|
222
240
|
case "Cap risk":
|
|
223
241
|
return "var(--edge-cap)";
|
|
224
242
|
case "Measuring":
|
|
@@ -231,6 +249,8 @@ export function outKind(badge) {
|
|
|
231
249
|
switch (badge) {
|
|
232
250
|
case "Behind pace":
|
|
233
251
|
return "behind";
|
|
252
|
+
case "Watch":
|
|
253
|
+
return "watch";
|
|
234
254
|
case "Cap risk":
|
|
235
255
|
return "cap";
|
|
236
256
|
case "On track":
|
|
@@ -246,6 +266,22 @@ export function outKind(badge) {
|
|
|
246
266
|
export function hatchGradient(_badge) {
|
|
247
267
|
return "repeating-linear-gradient(45deg, color-mix(in oklch, var(--ink) 20%, transparent) 0 1.5px, transparent 1.5px 7px)";
|
|
248
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Badge hover copy: red and amber explain the verdict; an estimated reset is
|
|
271
|
+
* confessed on the end. Other badges read their own label and need no title.
|
|
272
|
+
*/
|
|
273
|
+
export function badgeTitle(badge, estimated) {
|
|
274
|
+
let title;
|
|
275
|
+
if (badge === "Cap risk") {
|
|
276
|
+
title = "Burning faster than the window allows and ahead of elapsed time";
|
|
277
|
+
}
|
|
278
|
+
else if (badge === "Watch") {
|
|
279
|
+
title = "Pace points at the cap, but position or the reset clock is uncertain";
|
|
280
|
+
}
|
|
281
|
+
if (title && estimated)
|
|
282
|
+
title += " (estimated reset)";
|
|
283
|
+
return title;
|
|
284
|
+
}
|
|
249
285
|
export function Badge({ provider }) {
|
|
250
286
|
const badge = paceBadge(provider);
|
|
251
287
|
const detail = exclusionDetail(provider.exclusionReason);
|
|
@@ -255,10 +291,12 @@ export function Badge({ provider }) {
|
|
|
255
291
|
? "pace-ontrack"
|
|
256
292
|
: badge === "Ahead of pace"
|
|
257
293
|
? "pace-ahead"
|
|
258
|
-
: badge === "
|
|
259
|
-
? "pace-
|
|
260
|
-
:
|
|
261
|
-
|
|
294
|
+
: badge === "Watch"
|
|
295
|
+
? "pace-watch"
|
|
296
|
+
: badge === "Cap risk"
|
|
297
|
+
? "pace-cap"
|
|
298
|
+
: "pace-out"; // Not reporting and Measuring share the neutral style
|
|
299
|
+
return (_jsxs("span", { "data-testid": "pace-badge", className: `pace ${badgeClass}`, title: badgeTitle(badge, provider.quota?.resetsAtEstimated === true), children: [badge, detail ? ` · ${detail}` : ""] }));
|
|
262
300
|
}
|
|
263
301
|
/**
|
|
264
302
|
* Two-tier pacing rail: used fill, elapsed tick, projected-unused hatch.
|
|
@@ -316,6 +354,11 @@ export function forecastLine(provider) {
|
|
|
316
354
|
? `Cap risk · exhausts in ${advisory.daysToExhaust.toFixed(1)}d at current pace`
|
|
317
355
|
: "Cap risk at current pace";
|
|
318
356
|
}
|
|
357
|
+
if (advisory.status === "watch") {
|
|
358
|
+
return advisory.daysToExhaust !== null && Number.isFinite(advisory.daysToExhaust)
|
|
359
|
+
? `Watch · exhausts in ${advisory.daysToExhaust.toFixed(1)}d at current pace`
|
|
360
|
+
: "Watch at current pace";
|
|
361
|
+
}
|
|
319
362
|
if (advisory.wastePct !== null) {
|
|
320
363
|
return `${Math.round(advisory.wastePct)}% waste in ${advisory.daysLeft.toFixed(1)}d`;
|
|
321
364
|
}
|
|
@@ -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: "Watch", cls: "pace-watch", meaning: "Pace points at the cap, but position or the reset clock is uncertain" },
|
|
8
9
|
{ label: "Measuring", cls: "pace-out", meaning: "Not enough data yet; pace appears after 6h" },
|
|
9
10
|
{ label: "Not reporting", cls: "pace-out", meaning: "No usable reading; excluded from ranking" },
|
|
10
11
|
];
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { ageDuration, displayPlan } from "../state.js";
|
|
3
3
|
import { Badge, PACE_TIPS, PaceBar, isEstimated, paceCells, resetClock, resetCountdown, timeLeft, } from "./PaceBar.js";
|
|
4
4
|
import { ProviderIcon, providerTint } from "./ProviderIcon.js";
|
|
5
|
+
import { lastClose } from "./ClosedWeeks.js";
|
|
5
6
|
function resetsDate(provider) {
|
|
6
7
|
if (!provider.quota)
|
|
7
8
|
return null;
|
|
@@ -19,10 +20,11 @@ export function ProviderCard({ provider, recommended, asOf, onSelect, }) {
|
|
|
19
20
|
const resetsIn = quota ? timeLeft(quota.resetsAt, asOfMs) : null;
|
|
20
21
|
const name = provider.displayName;
|
|
21
22
|
const plan = displayPlan(quota?.plan);
|
|
23
|
+
const last = lastClose(provider);
|
|
22
24
|
return (_jsxs("article", { "data-testid": `provider-card-${provider.id}`, role: "button", tabIndex: 0, "aria-label": `${name} details`, onClick: () => onSelect(provider.id), onKeyDown: (e) => {
|
|
23
25
|
if (e.key === "Enter" || e.key === " ") {
|
|
24
26
|
e.preventDefault();
|
|
25
27
|
onSelect(provider.id);
|
|
26
28
|
}
|
|
27
|
-
}, className: `pcard ${recommended ? "is-recommended is-top-pick" : ""}`, children: [_jsxs("div", { className: "pcard-head", children: [_jsx("span", { className: "picon", style: providerTint(provider.id), "aria-hidden": "true", children: _jsx(ProviderIcon, { id: provider.id, title: name }) }), _jsxs("div", { className: "who", children: [_jsxs("b", { children: [name, recommended && (_jsx("span", { className: "rec-icon", title: "Recommended for next session", role: "img", "aria-label": "recommended", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.3", 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" }) }) }))] }), plan && _jsx("span", { className: "pl", children: plan }), updated && _jsxs("span", { className: "src", children: ["Updated ", updated, " ago"] })] }), _jsx(Badge, { provider: provider })] }), _jsxs("div", { className: "pcard-body", children: [_jsx(PaceBar, { provider: provider, asOf: asOf }), _jsxs("div", { className: "pstats", children: [_jsxs("div", { title: PACE_TIPS.avg, children: [_jsx("span", { className: "l", children: "Avg pace" }), _jsx("span", { className: "v", children: pace.avg })] }), _jsxs("div", { title: PACE_TIPS.recent, children: [_jsx("span", { className: "l", children: "24h pace" }), _jsx("span", { className: "v", children: pace.recent })] }), _jsxs("div", { title: PACE_TIPS.ideal, children: [_jsx("span", { className: "l", children: "Ideal pace" }), _jsx("span", { className: "v", children: advisory ? `${advisory.idealRate.toFixed(1)}%/day` : "—" })] }), _jsxs("div", { children: [_jsx("span", { className: "l", children: "Resets in" }), _jsx("span", { className: "v", children: resetsIn ? `${resetsIn}${isEstimated(provider) ? " (est.)" : ""}` : resetCountdown(provider, asOfMs) })] })] })] }), _jsxs("div", { className: "pcard-foot", children: [_jsx("span", { children: resetsDate(provider) ?? resetCountdown(provider, asOfMs) }), _jsx("span", { className: "rowbtn", "aria-hidden": "true", children: "Inspect" })] })] }));
|
|
29
|
+
}, className: `pcard ${recommended ? "is-recommended is-top-pick" : ""}`, children: [_jsxs("div", { className: "pcard-head", children: [_jsx("span", { className: "picon", style: providerTint(provider.id), "aria-hidden": "true", children: _jsx(ProviderIcon, { id: provider.id, title: name }) }), _jsxs("div", { className: "who", children: [_jsxs("b", { children: [name, recommended && (_jsx("span", { className: "rec-icon", title: "Recommended for next session", role: "img", "aria-label": "recommended", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.3", 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" }) }) }))] }), plan && _jsx("span", { className: "pl", children: plan }), updated && _jsxs("span", { className: "src", children: ["Updated ", updated, " ago"] })] }), _jsx(Badge, { provider: provider })] }), _jsxs("div", { className: "pcard-body", children: [_jsx(PaceBar, { provider: provider, asOf: asOf }), _jsxs("div", { className: "pstats", children: [_jsxs("div", { title: PACE_TIPS.avg, children: [_jsx("span", { className: "l", children: "Avg pace" }), _jsx("span", { className: "v", children: pace.avg })] }), _jsxs("div", { title: PACE_TIPS.recent, children: [_jsx("span", { className: "l", children: "24h pace" }), _jsx("span", { className: "v", children: pace.recent })] }), _jsxs("div", { title: PACE_TIPS.ideal, children: [_jsx("span", { className: "l", children: "Ideal pace" }), _jsx("span", { className: "v", children: advisory ? `${advisory.idealRate.toFixed(1)}%/day` : "—" })] }), _jsxs("div", { children: [_jsx("span", { className: "l", children: "Resets in" }), _jsx("span", { className: "v", children: resetsIn ? `${resetsIn}${isEstimated(provider) ? " (est.)" : ""}` : resetCountdown(provider, asOfMs) })] })] })] }), _jsxs("div", { className: "pcard-foot", children: [_jsxs("div", { className: "foot-meta", children: [_jsx("span", { children: resetsDate(provider) ?? resetCountdown(provider, asOfMs) }), last && (_jsxs("span", { className: "last-week", children: ["Last week ", last.leftoverPct, "% leftover", last.resetsAtEstimated ? " (est.)" : ""] }))] }), _jsx("span", { className: "rowbtn", "aria-hidden": "true", children: "Inspect" })] })] }));
|
|
28
30
|
}
|
|
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { ageDuration, displayPlan, providerSubtitle, shortWindowLabel } from "../state.js";
|
|
4
4
|
import { Badge, PaceBar, barGeometry, isEstimated, paceBadge, paceFigures, resetClock, timeLeft, } from "./PaceBar.js";
|
|
5
5
|
import { ProviderIcon, providerTint } from "./ProviderIcon.js";
|
|
6
|
+
import { ClosedWeeks } from "./ClosedWeeks.js";
|
|
6
7
|
import { renameProvider } from "../api.js";
|
|
7
8
|
export function exclusionWords(reason) {
|
|
8
9
|
switch (reason) {
|
|
@@ -120,6 +121,8 @@ export function pacingStatusToken(provider) {
|
|
|
120
121
|
return "ontrack";
|
|
121
122
|
case "Ahead of pace":
|
|
122
123
|
return "ahead";
|
|
124
|
+
case "Watch":
|
|
125
|
+
return "watch";
|
|
123
126
|
case "Cap risk":
|
|
124
127
|
return "cap";
|
|
125
128
|
case "Measuring":
|
|
@@ -181,6 +184,9 @@ export function rankingCopy(provider, recommendation) {
|
|
|
181
184
|
else if (advisory?.status === "at risk") {
|
|
182
185
|
decision = "Forecast to hit the cap before reset; flagged to ease off.";
|
|
183
186
|
}
|
|
187
|
+
else if (advisory?.status === "watch") {
|
|
188
|
+
decision = "Near the cap — position or reset clock uncertain.";
|
|
189
|
+
}
|
|
184
190
|
else if (advisory?.urgency === "slow down") {
|
|
185
191
|
decision = "Burning faster than the window; ease off.";
|
|
186
192
|
}
|
|
@@ -362,7 +368,7 @@ export function ProviderDrawer({ provider, asOf, recommendation, onClose, onRena
|
|
|
362
368
|
width: `${Math.min(100, Math.max(0, model.sessionPct))}%`,
|
|
363
369
|
background: "var(--fill-ontrack)",
|
|
364
370
|
["--fill-edge"]: "var(--edge-ontrack)",
|
|
365
|
-
} }) })] })), _jsxs("section", { className: "dsec", "aria-label": "Adapter and provenance", children: [_jsx("h3", { children: "Adapter and provenance" }), _jsxs("dl", { className: "dkv", children: [_jsx("dt", { children: "Read by" }), _jsx("dd", { children: sourceLabel(provider.quota?.source) }), _jsx("dt", { children: "Credential" }), _jsx("dd", { children: credentialLabel(provider) }), _jsx("dt", { children: "Window" }), _jsx("dd", { children: [
|
|
371
|
+
} }) })] })), _jsx(ClosedWeeks, { closes: provider.lastCloses }), _jsxs("section", { className: "dsec", "aria-label": "Adapter and provenance", children: [_jsx("h3", { children: "Adapter and provenance" }), _jsxs("dl", { className: "dkv", children: [_jsx("dt", { children: "Read by" }), _jsx("dd", { children: sourceLabel(provider.quota?.source) }), _jsx("dt", { children: "Credential" }), _jsx("dd", { children: credentialLabel(provider) }), _jsx("dt", { children: "Window" }), _jsx("dd", { children: [
|
|
366
372
|
provider.quota?.plan && provider.quota.plan !== "unknown" ? provider.quota.plan : null,
|
|
367
373
|
resetStamp(provider) ? `resets ${resetStamp(provider)}` : null,
|
|
368
374
|
]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { displayPlan, shortWindowLabel } from "../state.js";
|
|
3
3
|
import { Badge, PaceBar, forecastLine, paceLines, resetCellLines, } from "./PaceBar.js";
|
|
4
|
+
import { lastClose } from "./ClosedWeeks.js";
|
|
4
5
|
export function ProviderRow({ provider, recommended, asOf, onSelect, }) {
|
|
5
6
|
const quota = provider.quota;
|
|
6
7
|
const advisory = provider.advisory;
|
|
@@ -10,7 +11,8 @@ export function ProviderRow({ provider, recommended, asOf, onSelect, }) {
|
|
|
10
11
|
const plan = displayPlan(quota?.plan);
|
|
11
12
|
const shortWindow = shortWindowLabel(provider.id);
|
|
12
13
|
const reset = resetCellLines(provider, asOfMs);
|
|
13
|
-
|
|
14
|
+
const last = lastClose(provider);
|
|
15
|
+
return (_jsxs("div", { "data-testid": `provider-row-${provider.id}`, className: `lrow ${recommended ? "is-recommended is-top-pick" : ""}`, onClick: () => onSelect(provider.id), children: [_jsxs("div", { className: "prov", "data-label": "Provider", children: [_jsx("span", { className: "dot", style: { background: `var(--p-${provider.id.split(":")[0]}, var(--surface-raised))` }, "aria-hidden": "true" }), _jsxs("span", { children: [_jsxs("span", { className: "nm", children: [name, recommended && (_jsx("span", { className: "rec-icon", title: "Recommended for next session", role: "img", "aria-label": "recommended", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.3", 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" }) }) }))] }), plan && _jsx("span", { className: "pl", children: plan }), quota?.sessionPct !== undefined && quota.sessionPct !== null && (_jsxs("span", { className: "src", children: [shortWindow ?? "5h Limit", " \u00B7 ", quota.sessionPct, "% used"] }))] })] }), _jsx("div", { "data-label": "Used vs elapsed", children: quota ? _jsx(PaceBar, { provider: provider, asOf: asOf }) : "—" }), _jsxs("div", { "data-label": "Reset", className: "reset-cell", children: [_jsx("span", { className: "cell-b", children: reset.top }), reset.bottom && _jsx("span", { className: "cell-s", children: reset.bottom }), last && (_jsxs("span", { className: "cell-s", children: ["last week ", last.leftoverPct, "% leftover", last.resetsAtEstimated ? " (est.)" : ""] }))] }), _jsxs("div", { "data-label": "Pace", children: [_jsx(Badge, { provider: provider }), lines.length > 0 && (_jsx("div", { style: { marginTop: 2 }, children: lines.map((l) => (_jsxs("div", { className: "cell-s paceline", title: l.tip, children: [_jsx("span", { children: l.label }), _jsx("span", { className: "cell-b", children: l.value })] }, l.label))) }))] }), _jsx("button", { type: "button", className: "rowbtn", onClick: (e) => {
|
|
14
16
|
e.stopPropagation();
|
|
15
17
|
onSelect(provider.id);
|
|
16
18
|
}, children: "Inspect" }), _jsx("span", { className: "sr-only", children: forecastLine(provider) })] }));
|
|
@@ -56,9 +56,18 @@ export function Recommendation({ recommendation, providers, asOf, onSelectProvid
|
|
|
56
56
|
const { useMore, easeOff } = lanesFor(recommendation.advisories, providers);
|
|
57
57
|
const asOfMs = Date.parse(asOf);
|
|
58
58
|
const measuring = recommendation.use !== "none" && recommendation.wastePct === null;
|
|
59
|
-
|
|
59
|
+
// Lane membership keys off urgency, and both risk tiers can carry urgency
|
|
60
|
+
// "on track" (burn inside 1.4x ideal), so a board with any at-risk or Watch
|
|
61
|
+
// provider is never "clear" even when both lanes are empty.
|
|
62
|
+
const hasRiskOrWatch = recommendation.advisories.some((a) => a.status === "at risk" || a.status === "watch");
|
|
63
|
+
const isClear = recommendation.use === "none" && useMore.length === 0 && easeOff.length === 0 && !hasRiskOrWatch;
|
|
60
64
|
const tracked = providers.filter((p) => p.enabled && p.id !== "manual").length;
|
|
61
|
-
const easeTint = easeOff.some((a) => a.status === "
|
|
65
|
+
const easeTint = easeOff.some((a) => a.status === "watch")
|
|
66
|
+
? "tint-watch"
|
|
67
|
+
: easeOff.some((a) => a.status === "at risk")
|
|
68
|
+
? "tint-cap"
|
|
69
|
+
: "tint-ahead";
|
|
70
|
+
const easeLane = easeTint === "tint-cap" ? "l-cap" : easeTint === "tint-watch" ? "l-watch" : "l-ahead";
|
|
62
71
|
const pick = providers.find((p) => p.id === recommendation.use);
|
|
63
72
|
const pickLeft = pick?.quota ? timeLeft(pick.quota.resetsAt, asOfMs) : null;
|
|
64
73
|
const openAdvice = () => {
|
|
@@ -72,5 +81,5 @@ export function Recommendation({ recommendation, providers, asOf, onSelectProvid
|
|
|
72
81
|
background: "var(--ahead-soft)",
|
|
73
82
|
color: "var(--ahead)",
|
|
74
83
|
borderColor: "var(--ahead)",
|
|
75
|
-
}, children: "measuring pace" }))] }), recommendation.use !== "none" && (_jsxs("button", { type: "button", className: "btn", "data-testid": "view-advice", title: "Open recommended provider", onClick: openAdvice, children: ["View advice", _jsx("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M5 12h14M12 5l7 7-7 7" }) })] }))] }), (useMore.length > 0 || easeOff.length > 0) && (_jsxs("div", { className: "recB", children: [_jsx(LaneSection, { testId: "lane-use-more", label: "Use more", tintClass: "tint-behind", laneClass: "l-behind", icon: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.6", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 20V5M6 11l6-6 6 6" }) }), members: useMore, providers: providers, asOfMs: asOfMs, onSelectProvider: onSelectProvider }), _jsx(LaneSection, { testId: "lane-ease-off", label: "Ease off", tintClass: easeTint, laneClass:
|
|
84
|
+
}, children: "measuring pace" }))] }), recommendation.use !== "none" && (_jsxs("button", { type: "button", className: "btn", "data-testid": "view-advice", title: "Open recommended provider", onClick: openAdvice, children: ["View advice", _jsx("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M5 12h14M12 5l7 7-7 7" }) })] }))] }), (useMore.length > 0 || easeOff.length > 0) && (_jsxs("div", { className: "recB", children: [_jsx(LaneSection, { testId: "lane-use-more", label: "Use more", tintClass: "tint-behind", laneClass: "l-behind", icon: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.6", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 20V5M6 11l6-6 6 6" }) }), members: useMore, providers: providers, asOfMs: asOfMs, onSelectProvider: onSelectProvider }), _jsx(LaneSection, { testId: "lane-ease-off", label: "Ease off", tintClass: easeTint, laneClass: easeLane, icon: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.6", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M12 4v15M6 13l6 6 6-6" }) }), members: easeOff, providers: providers, asOfMs: asOfMs, onSelectProvider: onSelectProvider })] }))] })] }));
|
|
76
85
|
}
|
|
@@ -134,6 +134,8 @@ function pinPace(provider) {
|
|
|
134
134
|
return { cls: "pace-ontrack", color: "var(--good)" };
|
|
135
135
|
case "Ahead of pace":
|
|
136
136
|
return { cls: "pace-ahead", color: "var(--ahead)" };
|
|
137
|
+
case "Watch":
|
|
138
|
+
return { cls: "pace-watch", color: "var(--watch)" };
|
|
137
139
|
case "Cap risk":
|
|
138
140
|
return { cls: "pace-cap", color: "var(--danger)" };
|
|
139
141
|
default:
|