quotacap 0.0.7 → 0.0.8

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.
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const webHtml = "<!doctype html><html><head><meta charset=\"utf-8\"><title>QuotaCap</title> <script type=\"module\" crossorigin src=\"/assets/index-G2N84rBQ.js\"></script>\n</head><body><div id=\"app\"></div></body></html>\n";
2
+ export const webHtml = "<!doctype html><html><head><meta charset=\"utf-8\"><title>QuotaCap</title> <script type=\"module\" crossorigin src=\"/assets/index-M-4j9c6v.js\"></script>\n</head><body><div id=\"app\"></div></body></html>\n";
@@ -3,3 +3,4 @@ export declare function getLatestByProvider(db: any, provider: string): any;
3
3
  export declare function getAllLatest(db: any): any;
4
4
  export declare const getQuotas: typeof getAllLatest;
5
5
  export declare function getSnapshots(db: any): any;
6
+ export declare function getBurnRates(db: any): Map<string, number>;
@@ -26,3 +26,27 @@ export function getAllLatest(db) {
26
26
  // alias for plan's getQuotas naming
27
27
  export const getQuotas = getAllLatest;
28
28
  export function getSnapshots(db) { return db.prepare(`SELECT * FROM snapshots ORDER BY day DESC`).all(); }
29
+ export function getBurnRates(db) {
30
+ const rows = db.prepare(`SELECT day, provider, used_pct FROM snapshots`).all();
31
+ const byProvider = new Map();
32
+ for (const r of rows) {
33
+ const pts = byProvider.get(r.provider) ?? [];
34
+ pts.push({ day: r.day, usedPct: r.used_pct });
35
+ byProvider.set(r.provider, pts);
36
+ }
37
+ const out = new Map();
38
+ for (const [provider, pts] of byProvider) {
39
+ if (pts.length < 2)
40
+ continue;
41
+ const sorted = [...pts].sort((a, b) => a.day.localeCompare(b.day));
42
+ const first = sorted[0];
43
+ const last = sorted[sorted.length - 1];
44
+ const days = (new Date(last.day).getTime() - new Date(first.day).getTime()) / 86400000;
45
+ if (days < 1)
46
+ continue;
47
+ const burn = (last.usedPct - first.usedPct) / days;
48
+ if (burn >= 0)
49
+ out.set(provider, burn);
50
+ }
51
+ return out;
52
+ }
@@ -8,4 +8,27 @@ describe("advisory", () => {
8
8
  expect(adv.wastePct).toBeGreaterThan(0);
9
9
  expect(adv.urgency).toBe("burn now");
10
10
  });
11
+ it("flags at risk when measured burn exhausts the quota before reset", () => {
12
+ const now = new Date("2026-08-28T06:00:00+10:00");
13
+ const q = { provider: "kimi", usedPct: 16, resetsAt: "2026-08-31T11:00:00+10:00" };
14
+ const adv = computeAdvisory(q, 40, now, true);
15
+ expect(adv.burnMeasured).toBe(true);
16
+ expect(adv.burnRate).toBe(40);
17
+ expect(adv.daysToExhaust).toBeCloseTo(2.1, 1);
18
+ expect(adv.status).toBe("at risk");
19
+ });
20
+ it("marks on track when burn stays under the ideal rate", () => {
21
+ const now = new Date("2026-08-28T06:00:00+10:00");
22
+ const q = { provider: "kimi", usedPct: 16, resetsAt: "2026-08-31T11:00:00+10:00" };
23
+ const adv = computeAdvisory(q, 20, now, true);
24
+ expect(adv.daysToExhaust).toBeCloseTo(4.2, 1);
25
+ expect(adv.status).toBe("on track");
26
+ });
27
+ it("reports unmeasured burn as a default", () => {
28
+ const now = new Date("2026-08-28T06:00:00+10:00");
29
+ const q = { provider: "kimi", usedPct: 16, resetsAt: "2026-08-31T11:00:00+10:00" };
30
+ const adv = computeAdvisory(q, 2, now, false);
31
+ expect(adv.burnMeasured).toBe(false);
32
+ expect(adv.burnRate).toBe(2);
33
+ });
11
34
  });
@@ -13,13 +13,13 @@ describe("mcp", () => {
13
13
  { provider: "claude", usedPct: 37, resetsAt: "2026-09-03T21:00:00+10:00" },
14
14
  ],
15
15
  advisories: [
16
- { provider: "kimi", wastePct: 78.0, daysLeft: 3.0, idealRate: 28.0 },
17
- { provider: "claude", wastePct: 52.9, daysLeft: 5.5, idealRate: 11.6 },
16
+ { provider: "kimi", wastePct: 78.0, daysLeft: 3.0, idealRate: 28.0, burnRate: 40, burnMeasured: true, status: "at risk" },
17
+ { provider: "claude", wastePct: 52.9, daysLeft: 5.5, idealRate: 11.6, burnRate: 2, burnMeasured: false, status: "on track" },
18
18
  ],
19
19
  };
20
20
  const table = recommendationTable(rec);
21
- expect(table).toMatch(/\| Provider \| Used \| Remaining \| Resets \| Days left \| Ideal daily burn \| Waste if unused \|/);
22
- expect(table).toMatch(/\| kimi \| 16% \| 84% \|.*\| 3.0 \| 28%\/day \| 78% \|/);
23
- expect(table).toMatch(/\| claude \| 37% \| 63% \|.*\| 5.5 \| 12%\/day \| 53% \|/);
21
+ expect(table).toMatch(/\| Provider \| Used \| Remaining \| Resets \| Days left \| Ideal daily burn \| Burn rate \| Status \| Waste if unused \|/);
22
+ expect(table).toMatch(/\| kimi \| 16% \| 84% \|.*\| 3.0 \| 28%\/day \| 40.0%\/day \| ⚠️ \| 78% \|/);
23
+ expect(table).toMatch(/\| claude \| 37% \| 63% \|.*\| 5.5 \| 12%\/day \| collecting… \| ✅ \| 53% \|/);
24
24
  });
25
25
  });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { openDb, migrate } from "../../src/store/db.js";
3
- import { upsertQuota, getLatestByProvider } from "../../src/store/quotas.js";
3
+ import { upsertQuota, getLatestByProvider, getBurnRates } from "../../src/store/quotas.js";
4
4
  describe("store", () => {
5
5
  it("migrates and upserts", async () => {
6
6
  const db = openDb(":memory:");
@@ -10,4 +10,21 @@ describe("store", () => {
10
10
  const got = getLatestByProvider(db, "claude");
11
11
  expect(got?.usedPct).toBe(25);
12
12
  });
13
+ it("measures burn rate from snapshot history", () => {
14
+ const db = openDb(":memory:");
15
+ migrate(db);
16
+ db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES('2026-08-26','claude',35)`).run();
17
+ db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES('2026-08-28','claude',40)`).run();
18
+ db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES('2026-08-28','kimi',16)`).run();
19
+ const rates = getBurnRates(db);
20
+ expect(rates.get("claude")).toBeCloseTo(2.5, 5);
21
+ expect(rates.has("kimi")).toBe(false);
22
+ });
23
+ it("skips a burn measurement across a reset (usage went down)", () => {
24
+ const db = openDb(":memory:");
25
+ migrate(db);
26
+ db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES('2026-08-26','claude',40)`).run();
27
+ db.prepare(`INSERT INTO snapshots(day, provider, used_pct) VALUES('2026-08-28','claude',16)`).run();
28
+ expect(getBurnRates(db).has("claude")).toBe(false);
29
+ });
13
30
  });
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.7";
2
+ export const VERSION = "0.0.8";
@@ -42,19 +42,21 @@ function App() {
42
42
  advisoriesByProvider.set(a.provider, a);
43
43
  const hasStale = sorted.some((q) => q.stale ?? (q.fetchedAt ? Date.now() - new Date(q.fetchedAt).getTime() > 60 * 60 * 1000 : false));
44
44
  const isDegraded = !!fetchError || (quotas.length === 0 && !rec);
45
- return (_jsxs("div", { style: { fontFamily: "system-ui", padding: 16, maxWidth: 900, margin: "0 auto" }, children: [_jsx("h1", { style: { fontSize: 20, margin: "0 0 12px" }, children: "QuotaCap" }), _jsx(Banner, { rec: rec }), isDegraded && (_jsxs("div", { "data-testid": "error-banner", style: { background: "#fecaca", padding: 10, borderRadius: 8, marginBottom: 12, fontSize: 13 }, children: ["no fresh data", lastPollAt ? `, last ${new Date(lastPollAt).toLocaleString()}` : "", " \u2014 check network/daemon", fetchError ? `: ${fetchError}` : ""] })), hasStale && (_jsxs("div", { "data-testid": "stale-banner", style: { background: "#ffedd5", padding: 8, borderRadius: 8, marginBottom: 12, fontSize: 12 }, children: ["some quotas stale \u2014 ", _jsx("button", { onClick: () => fetch("/api/refresh", { method: "POST" }).then(() => location.reload()), style: { border: "1px solid #d1d5db", background: "#fff", borderRadius: 4, padding: "2px 6px", cursor: "pointer" }, children: "Refresh now" })] })), _jsxs("div", { "data-testid": "strip", style: { display: "flex", gap: 8, margin: "12px 0", overflowX: "auto", alignItems: "center" }, children: [_jsx("span", { style: { color: "#2563eb", fontWeight: 700 }, children: "NOW" }), sorted.map((q) => (_jsxs("span", { style: { border: "1px solid #d1d5db", borderRadius: 999, padding: "4px 10px", whiteSpace: "nowrap", background: "#fff" }, children: [q.provider, " ", formatReset(q.resetsAt)] }, q.provider))), sorted.length === 0 && _jsx("span", { style: { color: "#9ca3af" }, children: "no quotas yet" })] }), _jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: 14 }, children: [_jsx("thead", { children: _jsxs("tr", { style: { textAlign: "left", borderBottom: "2px solid #e5e7eb" }, children: [_jsx("th", { style: { padding: "8px 6px" }, children: "Provider" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Quota" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Rate" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Ideal" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Advice" }), _jsx("th", { style: { padding: "8px 6px" } })] }) }), _jsxs("tbody", { children: [sorted.map((q) => {
45
+ return (_jsxs("div", { style: { fontFamily: "system-ui", padding: 16, maxWidth: 900, margin: "0 auto" }, children: [_jsx("h1", { style: { fontSize: 20, margin: "0 0 12px" }, children: "QuotaCap" }), _jsx(Banner, { rec: rec }), isDegraded && (_jsxs("div", { "data-testid": "error-banner", style: { background: "#fecaca", padding: 10, borderRadius: 8, marginBottom: 12, fontSize: 13 }, children: ["no fresh data", lastPollAt ? `, last ${new Date(lastPollAt).toLocaleString()}` : "", " \u2014 check network/daemon", fetchError ? `: ${fetchError}` : ""] })), hasStale && (_jsxs("div", { "data-testid": "stale-banner", style: { background: "#ffedd5", padding: 8, borderRadius: 8, marginBottom: 12, fontSize: 12 }, children: ["some quotas stale \u2014 ", _jsx("button", { onClick: () => fetch("/api/refresh", { method: "POST" }).then(() => location.reload()), style: { border: "1px solid #d1d5db", background: "#fff", borderRadius: 4, padding: "2px 6px", cursor: "pointer" }, children: "Refresh now" })] })), _jsxs("div", { "data-testid": "strip", style: { display: "flex", gap: 8, margin: "12px 0", overflowX: "auto", alignItems: "center" }, children: [_jsx("span", { style: { color: "#2563eb", fontWeight: 700 }, children: "NOW" }), sorted.map((q) => (_jsxs("span", { style: { border: "1px solid #d1d5db", borderRadius: 999, padding: "4px 10px", whiteSpace: "nowrap", background: "#fff" }, children: [q.provider, " ", formatReset(q.resetsAt)] }, q.provider))), sorted.length === 0 && _jsx("span", { style: { color: "#9ca3af" }, children: "no quotas yet" })] }), _jsxs("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: 14 }, children: [_jsx("thead", { children: _jsxs("tr", { style: { textAlign: "left", borderBottom: "2px solid #e5e7eb" }, children: [_jsx("th", { style: { padding: "8px 6px" }, children: "Provider" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Quota" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Rate" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Ideal" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Advice" }), _jsx("th", { style: { padding: "8px 6px" }, children: "Status" }), _jsx("th", { style: { padding: "8px 6px" } })] }) }), _jsxs("tbody", { children: [sorted.map((q) => {
46
46
  const adv = advisoriesByProvider.get(q.provider);
47
47
  const waste = adv?.wastePct ?? 0;
48
48
  const ideal = adv?.idealRate;
49
49
  const burn = adv?.burnRate;
50
+ const burnMeasured = adv?.burnMeasured;
50
51
  const urgency = adv?.urgency ?? "";
52
+ const status = adv?.status ?? "on track";
51
53
  const isExpanded = expanded === q.provider;
52
54
  const isStale = q.stale ?? (q.fetchedAt ? Date.now() - new Date(q.fetchedAt).getTime() > 60 * 60 * 1000 : false);
53
55
  return (_jsxs(React.Fragment, { children: [_jsxs("tr", { style: { background: waste > 30 ? "#fff7ed" : isStale ? "#fffbeb" : undefined, borderBottom: "1px solid #f3f4f6" }, children: [_jsxs("td", { style: { padding: "8px 6px", fontWeight: 600 }, children: [q.provider, " ", isStale && _jsx("span", { style: { background: "#f59e0b", color: "#fff", fontSize: 10, padding: "2px 4px", borderRadius: 4, marginLeft: 6 }, children: "stale" })] }), _jsx("td", { style: { padding: "8px 6px" }, children: _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("div", { style: { width: 80, height: 8, background: "#e5e7eb", borderRadius: 4, overflow: "hidden" }, children: _jsx("div", { style: {
54
56
  width: `${Math.min(100, q.usedPct)}%`,
55
57
  height: "100%",
56
58
  background: waste > 30 ? "#f59e0b" : "#3b82f6",
57
- } }) }), _jsxs("span", { children: [q.usedPct, "%"] })] }) }), _jsx("td", { style: { padding: "8px 6px" }, children: burn != null ? `${burn.toFixed(1)}%/d` : "— collecting…" }), _jsx("td", { style: { padding: "8px 6px" }, children: ideal != null ? `${ideal.toFixed(1)}%/d` : "—" }), _jsx("td", { style: { padding: "8px 6px" }, children: urgency || "—" }), _jsx("td", { style: { padding: "8px 6px" }, children: _jsx("button", { "aria-label": `expand ${q.provider}`, onClick: () => setExpanded(isExpanded ? null : q.provider), style: { cursor: "pointer", border: "1px solid #d1d5db", background: "#fff", borderRadius: 4, padding: "2px 6px" }, children: isExpanded ? "▼" : "▶" }) })] }), isExpanded && (_jsx("tr", { children: _jsx("td", { colSpan: 6, style: { padding: 0 }, children: _jsxs("div", { style: { padding: 12, background: "#f9fafb", borderTop: "1px solid #e5e7eb" }, children: [_jsxs("div", { style: { position: "relative", height: 24, background: "#e5e7eb", borderRadius: 4, overflow: "hidden", marginBottom: 8 }, children: [_jsx("div", { style: {
59
+ } }) }), _jsxs("span", { children: [q.usedPct, "%"] })] }) }), _jsx("td", { style: { padding: "8px 6px" }, children: burn != null ? (burnMeasured ? `${burn.toFixed(1)}%/d` : "collecting…") : "—" }), _jsx("td", { style: { padding: "8px 6px" }, children: ideal != null ? `${ideal.toFixed(1)}%/d` : "—" }), _jsx("td", { style: { padding: "8px 6px" }, children: urgency || "—" }), _jsx("td", { style: { padding: "8px 6px" }, children: status === "at risk" ? "⚠️ at risk" : "✅ on track" }), _jsx("td", { style: { padding: "8px 6px" }, children: _jsx("button", { "aria-label": `expand ${q.provider}`, onClick: () => setExpanded(isExpanded ? null : q.provider), style: { cursor: "pointer", border: "1px solid #d1d5db", background: "#fff", borderRadius: 4, padding: "2px 6px" }, children: isExpanded ? "▼" : "▶" }) })] }), isExpanded && (_jsx("tr", { children: _jsx("td", { colSpan: 6, style: { padding: 0 }, children: _jsxs("div", { style: { padding: 12, background: "#f9fafb", borderTop: "1px solid #e5e7eb" }, children: [_jsxs("div", { style: { position: "relative", height: 24, background: "#e5e7eb", borderRadius: 4, overflow: "hidden", marginBottom: 8 }, children: [_jsx("div", { style: {
58
60
  position: "absolute",
59
61
  left: "10%",
60
62
  top: 0,