quotacap 0.0.14 → 0.0.16

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.
@@ -8,12 +8,14 @@ export function parseClaudeUsage(result, now = new Date()) {
8
8
  const usedPct = weeklyMatch ? parseInt(weeklyMatch[1], 10) : 0;
9
9
  const sessionPct = sessionMatch ? parseInt(sessionMatch[1], 10) : undefined;
10
10
  let resetsAt = parseResetText(result, now);
11
+ const parsedReset = !!resetsAt;
11
12
  if (!resetsAt)
12
13
  resetsAt = new Date(now.getTime() + 7 * 86400000).toISOString();
14
+ const resets = new Date(resetsAt).getTime();
15
+ const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
13
16
  return {
14
17
  provider: "claude", plan: "max", usedPct, sessionPct,
15
- resetsAt, periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
16
- raw: result, source: "cli", fetchedAt: now.toISOString()
18
+ resetsAt, periodStart, raw: result, source: "cli", fetchedAt: now.toISOString()
17
19
  };
18
20
  }
19
21
  export const claudeAdapter = {
@@ -2,13 +2,15 @@ import { parseResetText } from "./parse.js";
2
2
  export function parseManualUsage(provider, text, now = new Date()) {
3
3
  const m = text.match(/(\d+)% used/);
4
4
  const usedPct = m ? parseInt(m[1], 10) : 0;
5
- const resetsAt = parseResetText(text, now) ?? new Date(now.getTime() + 3 * 86400000).toISOString();
5
+ const parsedReset = parseResetText(text, now);
6
+ const resetsAt = parsedReset ?? new Date(now.getTime() + 3 * 86400000).toISOString();
7
+ const periodStart = (parsedReset ? new Date(new Date(resetsAt).getTime() - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
6
8
  return {
7
9
  provider,
8
10
  plan: "unknown",
9
11
  usedPct,
10
12
  resetsAt,
11
- periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
13
+ periodStart,
12
14
  raw: text,
13
15
  source: "manual",
14
16
  fetchedAt: now.toISOString(),
@@ -17,16 +17,25 @@ export function renderQuotasTable(quotas, advisories = []) {
17
17
  const resets = q.resetsAt ? formatResetDate(q.resetsAt) : "—";
18
18
  const daysLeft = a?.daysLeft != null ? a.daysLeft.toFixed(1) : "—";
19
19
  const ideal = a?.idealRate != null ? `${Math.round(a.idealRate)}%/day` : "—";
20
- const burn = a?.burnMeasured ? `${a.burnRate.toFixed(1)}%/day` : "—";
20
+ const burn = (() => {
21
+ if (a?.burnMeasured)
22
+ return `${a.burnRate.toFixed(1)}%/day`;
23
+ if (q.periodStart) {
24
+ const elapsed = (Date.now() - new Date(q.periodStart).getTime()) / 86400000;
25
+ if (elapsed > 0.1)
26
+ return `${((q.usedPct ?? 0) / elapsed).toFixed(1)}%/day avg`;
27
+ }
28
+ return "—";
29
+ })();
21
30
  // Renderers that count emoji as one cell but paint two (Kimi, Claude
22
- // Code) shift every pipe after a wide glyph by +1; the header carries
23
- // the same wide glyph (🚦) so header and body shift together.
31
+ // Code) shift the pipes after a wide glyph by +1; the status glyphs
32
+ // live in the last column so the shift touches only the right border.
24
33
  const icon = a?.status === "at risk" ? "⚠️" : a != null ? "✅" : "—";
25
34
  const waste = a?.wastePct != null ? `${Math.round(a.wastePct)}%` : "—";
26
35
  return `| ${q.provider} | ${used}% | ${100 - used}% | ${resets} | ${daysLeft} | ${ideal} | ${burn} | ${waste} | ${icon} |`;
27
36
  });
28
37
  return [
29
- "| Provider | Used | Remaining | Resets | Days left | Ideal daily burn | Burn rate | Waste if unused | 🚦 |",
38
+ "| Provider | Used | Remaining | Resets | Days left | Ideal daily burn | Burn rate | Waste if unused | Status |",
30
39
  "|---|---|---|---|---|---|---|---|---|",
31
40
  ...rows,
32
41
  ].join("\n");
@@ -8,12 +8,14 @@ export function parseClaudeUsage(result, now = new Date()) {
8
8
  const usedPct = weeklyMatch ? parseInt(weeklyMatch[1], 10) : 0;
9
9
  const sessionPct = sessionMatch ? parseInt(sessionMatch[1], 10) : undefined;
10
10
  let resetsAt = parseResetText(result, now);
11
+ const parsedReset = !!resetsAt;
11
12
  if (!resetsAt)
12
13
  resetsAt = new Date(now.getTime() + 7 * 86400000).toISOString();
14
+ const resets = new Date(resetsAt).getTime();
15
+ const periodStart = (parsedReset ? new Date(resets - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
13
16
  return {
14
17
  provider: "claude", plan: "max", usedPct, sessionPct,
15
- resetsAt, periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
16
- raw: result, source: "cli", fetchedAt: now.toISOString()
18
+ resetsAt, periodStart, raw: result, source: "cli", fetchedAt: now.toISOString()
17
19
  };
18
20
  }
19
21
  export const claudeAdapter = {
@@ -2,13 +2,15 @@ import { parseResetText } from "./parse.js";
2
2
  export function parseManualUsage(provider, text, now = new Date()) {
3
3
  const m = text.match(/(\d+)% used/);
4
4
  const usedPct = m ? parseInt(m[1], 10) : 0;
5
- const resetsAt = parseResetText(text, now) ?? new Date(now.getTime() + 3 * 86400000).toISOString();
5
+ const parsedReset = parseResetText(text, now);
6
+ const resetsAt = parsedReset ?? new Date(now.getTime() + 3 * 86400000).toISOString();
7
+ const periodStart = (parsedReset ? new Date(new Date(resetsAt).getTime() - 7 * 86400000) : new Date(now.getTime() - 7 * 86400000)).toISOString();
6
8
  return {
7
9
  provider,
8
10
  plan: "unknown",
9
11
  usedPct,
10
12
  resetsAt,
11
- periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
13
+ periodStart,
12
14
  raw: text,
13
15
  source: "manual",
14
16
  fetchedAt: now.toISOString(),
@@ -17,16 +17,25 @@ export function renderQuotasTable(quotas, advisories = []) {
17
17
  const resets = q.resetsAt ? formatResetDate(q.resetsAt) : "—";
18
18
  const daysLeft = a?.daysLeft != null ? a.daysLeft.toFixed(1) : "—";
19
19
  const ideal = a?.idealRate != null ? `${Math.round(a.idealRate)}%/day` : "—";
20
- const burn = a?.burnMeasured ? `${a.burnRate.toFixed(1)}%/day` : "—";
20
+ const burn = (() => {
21
+ if (a?.burnMeasured)
22
+ return `${a.burnRate.toFixed(1)}%/day`;
23
+ if (q.periodStart) {
24
+ const elapsed = (Date.now() - new Date(q.periodStart).getTime()) / 86400000;
25
+ if (elapsed > 0.1)
26
+ return `${((q.usedPct ?? 0) / elapsed).toFixed(1)}%/day avg`;
27
+ }
28
+ return "—";
29
+ })();
21
30
  // Renderers that count emoji as one cell but paint two (Kimi, Claude
22
- // Code) shift every pipe after a wide glyph by +1; the header carries
23
- // the same wide glyph (🚦) so header and body shift together.
31
+ // Code) shift the pipes after a wide glyph by +1; the status glyphs
32
+ // live in the last column so the shift touches only the right border.
24
33
  const icon = a?.status === "at risk" ? "⚠️" : a != null ? "✅" : "—";
25
34
  const waste = a?.wastePct != null ? `${Math.round(a.wastePct)}%` : "—";
26
35
  return `| ${q.provider} | ${used}% | ${100 - used}% | ${resets} | ${daysLeft} | ${ideal} | ${burn} | ${waste} | ${icon} |`;
27
36
  });
28
37
  return [
29
- "| Provider | Used | Remaining | Resets | Days left | Ideal daily burn | Burn rate | Waste if unused | 🚦 |",
38
+ "| Provider | Used | Remaining | Resets | Days left | Ideal daily burn | Burn rate | Waste if unused | Status |",
30
39
  "|---|---|---|---|---|---|---|---|---|",
31
40
  ...rows,
32
41
  ].join("\n");
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.14";
2
+ export const VERSION = "0.0.16";
@@ -12,6 +12,7 @@ describe("parseClaudeUsage", () => {
12
12
  expect(q.sessionPct).toBe(46);
13
13
  expect(q.provider).toBe("claude");
14
14
  expect(q.resetsAt).toMatch(/2026-09-03/);
15
+ expect(q.periodStart).toMatch(/^2026-08-27/);
15
16
  });
16
17
  it("fallback resetsAt/periodStart/fetchedAt are deterministic from now", () => {
17
18
  const now = new Date("2026-08-20T00:00:00+10:00");
@@ -11,7 +11,7 @@ describe("format table", () => {
11
11
  { provider: "claude", wastePct: 52.9, daysLeft: 5.5, idealRate: 11.6, burnRate: 2, burnMeasured: false, status: "on track" },
12
12
  ];
13
13
  const table = renderQuotasTable(quotas, advisories);
14
- expect(table).toMatch(/\| Provider \| Used \| Remaining \| Resets \| Days left \| Ideal daily burn \| Burn rate \| Waste if unused \| 🚦 \|/);
14
+ expect(table).toMatch(/\| Provider \| Used \| Remaining \| Resets \| Days left \| Ideal daily burn \| Burn rate \| Waste if unused \| Status \|/);
15
15
  expect(table).toMatch(/\| kimi \| 16% \| 84% \|.*\| 3.0 \| 28%\/day \| 40.0%\/day \| 78% \| ⚠️ \|/);
16
16
  expect(table).toMatch(/\| claude \| 37% \| 63% \|.*\| 5.5 \| 12%\/day \| — \| 53% \| ✅ \|/);
17
17
  });
@@ -27,7 +27,30 @@ describe("format table", () => {
27
27
  expect(table).toMatch(/Sept?[^|]*2026/);
28
28
  expect(table).not.toMatch(/9\/1\/2026/);
29
29
  });
30
- it("keeps header and data pipes aligned under both observed width models", () => {
30
+ it("shows the labeled period average when burn is unmeasured", () => {
31
+ const now = Date.now();
32
+ const quotas = [
33
+ { provider: "kimi", usedPct: 16, resetsAt: "2026-09-01T09:02:00+10:00", periodStart: new Date(now - 4 * 86400000).toISOString() },
34
+ ];
35
+ const advisories = [
36
+ { provider: "kimi", wastePct: 78.0, daysLeft: 2.9, idealRate: 29.0, burnRate: 2, burnMeasured: false, status: "on track" },
37
+ ];
38
+ const table = renderQuotasTable(quotas, advisories);
39
+ expect(table).toMatch(/4.0%\/day avg/);
40
+ });
41
+ it("prefers the measured burn over the period average", () => {
42
+ const now = Date.now();
43
+ const quotas = [
44
+ { provider: "claude", usedPct: 45, resetsAt: "2026-09-03T21:00:00+10:00", periodStart: new Date(now - 2 * 86400000).toISOString() },
45
+ ];
46
+ const advisories = [
47
+ { provider: "claude", wastePct: 0, daysLeft: 5.4, idealRate: 10.0, burnRate: 25.3, burnMeasured: true, status: "at risk" },
48
+ ];
49
+ const table = renderQuotasTable(quotas, advisories);
50
+ expect(table).toMatch(/\| 25.3%\/day \|/);
51
+ expect(table).not.toMatch(/avg/);
52
+ });
53
+ it("keeps every internal pipe aligned — no wide glyphs outside the Status column", () => {
31
54
  const quotas = [
32
55
  { provider: "kimi", usedPct: 16, resetsAt: "2026-09-01T09:02:00+10:00" },
33
56
  { provider: "claude", usedPct: 37, resetsAt: "2026-09-03T21:00:00+10:00" },
@@ -42,21 +65,14 @@ describe("format table", () => {
42
65
  const c = ch.codePointAt(0);
43
66
  return (c >= 0x2600 && c <= 0x27BF) || c >= 0x1F000;
44
67
  };
45
- const paint = (ch) => (emoji(ch) ? 2 : 1);
46
- // Model A: Kimi/Claude Code emoji count 1, paint 2.
47
- // Model B: OpenCode emoji count 2, paint 2.
48
- const countA = (ch) => (emoji(ch) ? 1 : 1);
49
- const countB = (ch) => (emoji(ch) ? 2 : 1);
50
- const gap = (cell, count) => [...cell].reduce((w, ch) => w + paint(ch), 0) - [...cell].reduce((w, ch) => w + count(ch), 0);
51
- // The divider row (---) cannot carry a wide glyph, so it is excluded —
52
- // renderers draw it from the separator and its gap is inherently 0.
53
- const rows = lines.filter((l, i) => i !== 1);
54
- const columns = rows.map((l) => l.split("|").slice(1, -1));
55
- for (let col = 0; col < columns[0].length; col++) {
56
- const gapsA = columns.map((r) => gap(r[col], countA));
57
- const gapsB = columns.map((r) => gap(r[col], countB));
58
- expect(new Set(gapsA).size).toBe(1);
59
- expect(new Set(gapsB).size).toBe(1);
68
+ // Renderers that count emoji as one cell but paint two shift every pipe
69
+ // after the glyph. The status glyphs are confined to the last column, so
70
+ // the shift can only ever touch the right border, never an internal pipe.
71
+ for (const line of lines) {
72
+ const cells = line.split("|").slice(1, -1);
73
+ for (const cell of cells.slice(0, -1)) {
74
+ expect([...cell].some(emoji)).toBe(false);
75
+ }
60
76
  }
61
77
  });
62
78
  });
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.14";
2
+ export const VERSION = "0.0.16";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quotacap",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",