estela 0.2.4 → 0.3.2

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 CHANGED
@@ -273,6 +273,61 @@ anything you use on claude.ai or another machine counts too but isn't on this
273
273
  one. Copilot is left out because it doesn't record reliable tokens. Everything
274
274
  is calculated on your machine.
275
275
 
276
+ ## Is your Claude plan the right one?
277
+
278
+ ```sh
279
+ estela plan set max5 # pro | max5 | max20; add --since YYYY-MM-DD if you had it before today
280
+ estela plan # last 28 days; --days N, --json, --no-color
281
+ ```
282
+
283
+ Answers "is the Claude plan I pay for too big or too small?" with your own data:
284
+ the 5-hour and weekly percentages Claude Code sends to the status line (stored
285
+ since 0.2.4, only in the terminal), and nothing else. Example (made-up data):
286
+
287
+ ```
288
+ estela plan · Max 5x (since 2026-10-01) · 26 days with data
289
+
290
+ 5-hour windows 41 windows · median peak 22% · p90 38% · max 61%
291
+ Caps (≥95%) 0
292
+ Week median peak 34% · max 47% · 0 weeks ≥ 90%
293
+ Blocks (429) 0 5-hour · 0 weekly, from Claude Code's transcripts
294
+
295
+ If you were on…
296
+ Pro ($20) equivalent p90 190% · would have blocked you in 29 of 41 windows
297
+ Max 20x ($200) no caps, same as now
298
+
299
+ Recommendation Your current plan fits your usage.
300
+ ```
301
+
302
+ - **It never invents or estimates a plan's limit.** Other plans are compared only
303
+ on the 5-hour window, using the per-session multiplier Anthropic publishes
304
+ (Max 5x = 5 × Pro, Max 20x = 20 × Pro;
305
+ [source](https://support.claude.com/en/articles/11049741-what-is-the-max-plan)):
306
+ an equivalent peak is your peak × your multiplier ÷ the other plan's. Anthropic
307
+ doesn't publish the weekly limit between plans, so that comparison isn't made.
308
+ It never says how much you have left.
309
+ - A window with a peak of 95% or more is a cap. The time stopped after a cap
310
+ (from the first sample at 99% to the reset) is an upper bound: we don't know if
311
+ you kept working.
312
+ - "Blocks (429)" are the rate-limit responses Claude Code wrote in its
313
+ transcripts. They're official and go back before 0.2.4, but carry no
314
+ percentage, so they're shown apart and don't enter the calculation.
315
+ - With fewer than 7 days of data there's no recommendation; from 7 to 13 it's
316
+ provisional. After a plan change only data since then counts.
317
+ - Prices are Anthropic's website prices on the date in the output; check them.
318
+ Anthropic may change limits: run it again every month.
319
+
320
+ `--json` returns the same figures with a stable structure (`version: 1`): `plan`,
321
+ `period` (`from`, `to`, `days`, `daysWithData`, `samples`), `dataState`
322
+ (`none` | `insufficient` | `provisional` | `ok`), `fiveHour` (`windows`,
323
+ `medianPeak`, `p90Peak`, `maxPeak`, `caps`, `stoppedMinutesUpperBound`), `week`
324
+ (`weeks`, `medianPeak`, `maxPeak`, `weeksAtOrAbove90`), `blocks429`,
325
+ `alternatives[]` (`plan`, `direction`, `monthlyUsd`, `monthlyDiffUsd`,
326
+ `p90Equivalent`, `maxEquivalent`, `blockedWindows`, `capsAvoided`),
327
+ `recommendation` (`kind`: `tight` | `enough` | `almost` | `fits`, `plan`,
328
+ `provisional`, `text`, or `null`), `weeklyBetweenPlans: "not_published"`,
329
+ `sources` and `plans`. New fields may be added; none change meaning.
330
+
276
331
  ## A line under every Claude Code session
277
332
 
278
333
  ```sh
@@ -1,5 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
- import type { TimeEntry } from "@estela/shared";
2
+ import type { CommitRecord, TimeEntry } from "@estela/shared";
3
3
  import { type WorkBlock } from "./sessionize.js";
4
4
  /**
5
5
  * De bloques de trabajo a imputaciones: a qué proyecto va cada uno y con qué
@@ -39,6 +39,16 @@ export interface AssignmentPlan {
39
39
  readonly blocked: readonly RecoveryBlocked[];
40
40
  }
41
41
  export declare function entryId(projectId: string, day: string, branch: string | null): string;
42
+ /**
43
+ * Los bloques deducidos solo de commits: los que no se solapan con ninguna
44
+ * sesión de agente, tampoco con las que ya no están en el disco.
45
+ *
46
+ * Claude Code limpia sus transcripts antiguos, así que lo escaneado no es todo
47
+ * lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
48
+ * trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
49
+ * guarda todos los turnos: `saveTurns` no borra nada.
50
+ */
51
+ export declare function commitOnlyBlocks(db: DatabaseSync, commits: readonly CommitRecord[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
42
52
  export declare function planEntries(db: DatabaseSync, raw: readonly WorkBlock[], options: {
43
53
  readonly recover: boolean;
44
54
  }): AssignmentPlan;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.entryId = entryId;
37
+ exports.commitOnlyBlocks = commitOnlyBlocks;
37
38
  exports.planEntries = planEntries;
38
39
  const shared_1 = require("@estela/shared");
39
40
  const store = __importStar(require("../db/store.js"));
@@ -45,6 +46,19 @@ function entryId(projectId, day, branch) {
45
46
  const slug = (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
46
47
  return `te_${projectId}_${day}_${slug}`;
47
48
  }
49
+ /**
50
+ * Los bloques deducidos solo de commits: los que no se solapan con ninguna
51
+ * sesión de agente, tampoco con las que ya no están en el disco.
52
+ *
53
+ * Claude Code limpia sus transcripts antiguos, así que lo escaneado no es todo
54
+ * lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
55
+ * trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
56
+ * guarda todos los turnos: `saveTurns` no borra nada.
57
+ */
58
+ function commitOnlyBlocks(db, commits, agentBlocks) {
59
+ const saved = (0, sessionize_js_1.sessionize)(store.turnsBetween(db, new Date(0), new Date("9999-12-31")));
60
+ return (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), [...agentBlocks, ...saved]);
61
+ }
48
62
  function planEntries(db, raw, options) {
49
63
  const ownerOf = (0, project_of_js_1.ownerResolver)(db);
50
64
  const direct = (repo) => (repo ? store.projectForRepo(db, repo) : null);
@@ -113,6 +113,11 @@ export declare function sessionizeCommits(commits: readonly CommitRecord[], opti
113
113
  *
114
114
  * Sin esto se contaría dos veces el mismo rato: el agente ya cubrió esas horas
115
115
  * y el commit que las cerró generaría otro bloque encima.
116
+ *
117
+ * Mismo repositorio quiere decir lo mismo que en `attachCommits`: la carpeta
118
+ * del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
119
+ * y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
120
+ * se sumaban 25 minutos estimados encima de una sesión medida.
116
121
  */
117
122
  export declare function withoutOverlap(commitBlocks: readonly WorkBlock[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
118
123
  //# sourceMappingURL=sessionize.d.ts.map
@@ -290,10 +290,20 @@ function sessionizeCommits(commits, options = {}) {
290
290
  *
291
291
  * Sin esto se contaría dos veces el mismo rato: el agente ya cubrió esas horas
292
292
  * y el commit que las cerró generaría otro bloque encima.
293
+ *
294
+ * Mismo repositorio quiere decir lo mismo que en `attachCommits`: la carpeta
295
+ * del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
296
+ * y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
297
+ * se sumaban 25 minutos estimados encima de una sesión medida.
293
298
  */
294
299
  function withoutOverlap(commitBlocks, agentBlocks) {
295
- return commitBlocks.filter((c) => !agentBlocks.some((a) => a.repoPath === c.repoPath &&
300
+ return commitBlocks.filter((c) => !agentBlocks.some((a) => sameRepo(a.repoPath, c.repoPath) &&
296
301
  a.startedAt.getTime() < c.endedAt.getTime() &&
297
302
  c.startedAt.getTime() < a.endedAt.getTime()));
298
303
  }
304
+ function sameRepo(a, b) {
305
+ if (a === b)
306
+ return true;
307
+ return !!a && !!b && ((0, paths_js_1.isSameOrInside)(a, b) || (0, paths_js_1.isSameOrInside)(b, a));
308
+ }
299
309
  //# sourceMappingURL=sessionize.js.map
package/dist/cli.js CHANGED
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.breakdownOfTurn = void 0;
38
+ const node_os_1 = require("node:os");
38
39
  const node_path_1 = require("node:path");
39
40
  const node_readline_1 = require("node:readline");
40
41
  // `node:sqlite` avisa de que es experimental en cada arranque. Es ruido para
@@ -63,6 +64,12 @@ const invoice_pdf_js_1 = require("./export/invoice-pdf.js");
63
64
  const share_js_1 = require("./export/share.js");
64
65
  const server_js_1 = require("./server.js");
65
66
  const updateCheck_js_1 = require("./updateCheck.js");
67
+ const plan_js_1 = require("./metrics/plan.js");
68
+ const plan_blocks_js_1 = require("./plan-blocks.js");
69
+ const plan_prices_js_1 = require("./plan-prices.js");
70
+ const plan_report_js_1 = require("./plan-report.js");
71
+ const rate_history_js_1 = require("./rate-history.js");
72
+ const statusline_cache_js_1 = require("./statusline-cache.js");
66
73
  const publish_js_1 = require("./publish.js");
67
74
  const sync_js_1 = require("./sync.js");
68
75
  const team_js_1 = require("./sync/team.js");
@@ -169,6 +176,15 @@ estela — registro de horas para desarrollo asistido por IA
169
176
  Copilot queda fuera: no guarda tokens fiables. Antes de calcular
170
177
  lee los transcripts desde el lunes (--no-refresh lo evita).
171
178
  A diferencia de ai-cost, que reparte dinero, esto reparte tokens.
179
+
180
+ estela plan [--days <n>] [--json] [--no-color]
181
+ estela plan set pro|max5|max20 [--since AAAA-MM-DD]
182
+ ¿El plan de Claude que pagas te sobra o te falta? Analiza los
183
+ porcentajes de 5 h y semana que Claude Code manda a la status line
184
+ (solo en la terminal) y los compara con los otros planes usando el
185
+ multiplicador por sesión que publica Anthropic. El límite semanal
186
+ entre planes no se compara: no se publica. Primero declara tu plan
187
+ con "plan set". Por defecto, los últimos 28 días.
172
188
  estela statusline install [--force | --append]
173
189
  Una línea bajo cada sesión de Claude Code: proyecto, horas medidas
174
190
  hoy, la parte de tu consumo de la semana que es de este proyecto y,
@@ -262,9 +278,11 @@ estela — registro de horas para desarrollo asistido por IA
262
278
  [--pdf <fichero.pdf>] [--csv <fichero.csv>] [--fx <tipo>]
263
279
  [--number <nº>]
264
280
  [--from-name <tu nombre>] [--from-email <email>]
265
- [--from-tax-id <tu NIF/CIF>]
281
+ [--from-tax-id <tu NIF/CIF>] [--no-amounts]
266
282
  Informe de horas y commits para respaldar tu trabajo. No es una
267
283
  factura: Estela no emite documentos fiscales.
284
+ --no-amounts saca el PDF sin tarifa ni importes. En el panel, lo ya
285
+ emitido se vuelve a descargar con o sin ellos.
268
286
  --number fija el número del informe; sin él se numera solo y
269
287
  correlativo por año (INF-${new Date().getFullYear()}-001, -002...).
270
288
  Los --from-* son tus datos como emisor, en la cabecera del PDF.
@@ -369,6 +387,15 @@ estela — time tracking for AI-assisted development
369
387
  left out: it doesn't record reliable tokens. Before calculating it reads transcripts since Monday
370
388
  (--no-refresh skips that). Unlike ai-cost, which splits money, this
371
389
  splits tokens.
390
+
391
+ estela plan [--days <n>] [--json] [--no-color]
392
+ estela plan set pro|max5|max20 [--since YYYY-MM-DD]
393
+ Is your Claude plan too big or too small? It analyses the 5-hour
394
+ and weekly percentages Claude Code sends to the status line (only
395
+ in the terminal) and compares them with the other plans using the
396
+ per-session multiplier Anthropic publishes. The weekly limit isn't
397
+ compared across plans: it isn't published. Declare your plan first
398
+ with "plan set". Defaults to the last 28 days.
372
399
  estela statusline install [--force | --append]
373
400
  A line under every Claude Code session: project, hours measured
374
401
  today, this project's share of your usage this week and, if Claude
@@ -464,9 +491,11 @@ estela — time tracking for AI-assisted development
464
491
  [--pdf <file.pdf>] [--csv <file.csv>] [--fx <rate>]
465
492
  [--number <no.>]
466
493
  [--from-name <your name>] [--from-email <email>]
467
- [--from-tax-id <your tax ID>]
494
+ [--from-tax-id <your tax ID>] [--no-amounts]
468
495
  Report of hours and commits to back up your work. It isn't an
469
496
  invoice: Estela doesn't issue tax documents.
497
+ --no-amounts leaves the rate and amounts out of the PDF. In the panel,
498
+ issued reports can be downloaded again with or without them.
470
499
  --number sets the report number; without it, reports are numbered
471
500
  automatically and sequentially per year (INF-${new Date().getFullYear()}-001, -002...).
472
501
  The --from-* flags are your details as the issuer, in the PDF header.
@@ -877,7 +906,7 @@ async function cmdImport(args, dbPath) {
877
906
  // compañero se convertirían en tiempo facturable a tu nombre.
878
907
  const commitsAll = (0, identity_js_1.onlyMine)(await allCommits(db), mine);
879
908
  const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commitsAll);
880
- const fromCommits = (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commitsAll), fromAgents);
909
+ const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commitsAll, fromAgents);
881
910
  const raw = [...fromAgents, ...fromCommits];
882
911
  if (fromCommits.length) {
883
912
  console.log((0, index_js_1.tr) ` ${fromCommits.length} bloques deducidos solo de commits (sin agente)`);
@@ -1544,6 +1573,90 @@ async function cmdUnpublish(args, dbPath) {
1544
1573
  db.close();
1545
1574
  }
1546
1575
  }
1576
+ /**
1577
+ * `estela plan`: ¿el plan de Claude que pagas es el que te conviene?
1578
+ *
1579
+ * Solo con lo que manda Claude Code a la status line (rate-limits.jsonl) y el
1580
+ * multiplicador por sesión que publica Anthropic. Ver metrics/plan.ts.
1581
+ */
1582
+ function cmdPlan(args, dbPath) {
1583
+ const db = (0, schema_js_1.openDatabase)(dbPath);
1584
+ try {
1585
+ const declared = store.getClaudePlan(db);
1586
+ const json = args.flags["json"] === true;
1587
+ if (!declared || !(0, plan_prices_js_1.planById)(declared.plan)) {
1588
+ if (json)
1589
+ console.log(JSON.stringify({ version: 1, plan: null }, null, 2));
1590
+ else
1591
+ console.log((0, plan_report_js_1.renderNoPlan)());
1592
+ return;
1593
+ }
1594
+ const days = str(args, "days") === undefined ? 28 : Number(str(args, "days"));
1595
+ if (!Number.isInteger(days) || days < 1 || days > rate_history_js_1.KEEP_DAYS) {
1596
+ throw new UserError((0, index_js_1.tr) `--days tiene que ser un número entero de 1 a ${rate_history_js_1.KEEP_DAYS}.`);
1597
+ }
1598
+ const now = new Date();
1599
+ const file = (0, rate_history_js_1.historyPath)(dbPath);
1600
+ try {
1601
+ (0, rate_history_js_1.pruneSamples)(file, now);
1602
+ }
1603
+ catch { /* sin rotar se analiza igual */ }
1604
+ const all = (0, rate_history_js_1.readSamples)(file);
1605
+ const analysis = (0, plan_js_1.analyzePlan)({ samples: all, plan: declared.plan, since: declared.since, now, days });
1606
+ // Los 429 que Claude Code dejó en sus transcripts, en el mismo período.
1607
+ let blocks = null;
1608
+ try {
1609
+ const found = (0, plan_blocks_js_1.scanBlocks)((0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects"), (0, plan_blocks_js_1.blocksCachePath)(dbPath), now)
1610
+ .filter((b) => b.at >= analysis.from.getTime() && b.at <= now.getTime());
1611
+ blocks = { fiveHour: found.filter((b) => b.type === "five_hour").length,
1612
+ sevenDay: found.filter((b) => b.type === "seven_day").length };
1613
+ }
1614
+ catch { /* sin transcripts legibles, la línea no sale */ }
1615
+ if (json) {
1616
+ console.log(JSON.stringify((0, plan_report_js_1.planToJson)(analysis, { blocks, now }), null, 2));
1617
+ return;
1618
+ }
1619
+ const color = args.flags["no-color"] !== true && !process.env["NO_COLOR"] && process.stdout.isTTY === true;
1620
+ if (analysis.dataState === "none") {
1621
+ // Sin ninguna muestra nunca, y la status line vio entradas sin rate_limits: API key.
1622
+ const apiKey = all.length === 0 && (0, statusline_cache_js_1.loadCache)(dbPath).noLimitsAt !== undefined;
1623
+ console.log((0, plan_report_js_1.renderNoData)(analysis, apiKey, color));
1624
+ return;
1625
+ }
1626
+ console.log((0, plan_report_js_1.renderPlanReport)(analysis, { color, now, blocks }));
1627
+ }
1628
+ finally {
1629
+ db.close();
1630
+ }
1631
+ }
1632
+ /** `estela plan set pro|max5|max20 [--since AAAA-MM-DD]`. */
1633
+ function cmdPlanSet(args, dbPath) {
1634
+ const id = args._[2] ?? "";
1635
+ const plan = (0, plan_prices_js_1.planById)(id);
1636
+ if (!plan)
1637
+ throw new UserError((0, index_js_1.tr) `Plan desconocido: "${id}". Usa pro, max5 o max20.`);
1638
+ const sinceText = str(args, "since");
1639
+ let since = new Date();
1640
+ if (sinceText !== undefined) {
1641
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(sinceText) || Number.isNaN(new Date(`${sinceText}T00:00:00`).getTime())) {
1642
+ throw new UserError((0, index_js_1.tr) `--since tiene que ser una fecha AAAA-MM-DD.`);
1643
+ }
1644
+ since = new Date(`${sinceText}T00:00:00`);
1645
+ }
1646
+ const db = (0, schema_js_1.openDatabase)(dbPath);
1647
+ try {
1648
+ store.setClaudePlan(db, plan.id, since);
1649
+ const d = since;
1650
+ const fecha = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1651
+ console.log((0, index_js_1.tr) `Plan declarado: ${plan.name}, desde ${fecha}. El análisis solo usa lo guardado desde esa fecha.`);
1652
+ if (sinceText === undefined) {
1653
+ console.log((0, index_js_1.tr) `Si ya lo tenías antes, indica desde cuándo: estela plan set ${plan.id} --since AAAA-MM-DD`);
1654
+ }
1655
+ }
1656
+ finally {
1657
+ db.close();
1658
+ }
1659
+ }
1547
1660
  /**
1548
1661
  * `estela sync enable --project <id>` — opt-in explícito, proyecto por
1549
1662
  * proyecto. Sin esta fila, ese proyecto no sale de esta máquina nunca, sea
@@ -2418,6 +2531,7 @@ function cmdInvoice(args, dbPath) {
2418
2531
  },
2419
2532
  } : {}),
2420
2533
  ...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
2534
+ withAmounts: args.flags["no-amounts"] !== true,
2421
2535
  }));
2422
2536
  (0, node_fs_1.writeFileSync)(pdfPath, pdf);
2423
2537
  console.log((0, index_js_1.tr) `\nPDF: ${pdfPath} (${(pdf.length / 1024).toFixed(1)} KB, ${invoice.lines.length} conceptos)`);
@@ -2466,7 +2580,10 @@ async function main() {
2466
2580
  (0, client_js_1.setClientVersion)(VERSION);
2467
2581
  const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
2468
2582
  // `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
2469
- const command = args._[0] === "unpublish" ? "unpublish" : args._.join(" ");
2583
+ const command = args._[0] === "unpublish" ? "unpublish"
2584
+ // `estela plan set max5`: el plan va suelto, como el panel de unpublish.
2585
+ : args._[0] === "plan" && args._[1] === "set" ? "plan set"
2586
+ : args._.join(" ");
2470
2587
  // Se llama aquí, no al final: un comando que falla a medias (por ejemplo
2471
2588
  // "estela import" sin repositorios) debe avisar igual de que hay una
2472
2589
  // versión nueva, no solo los que terminan bien.
@@ -2553,6 +2670,12 @@ async function main() {
2553
2670
  case "unpublish":
2554
2671
  await cmdUnpublish(args, dbPath);
2555
2672
  break;
2673
+ case "plan":
2674
+ cmdPlan(args, dbPath);
2675
+ break;
2676
+ case "plan set":
2677
+ cmdPlanSet(args, dbPath);
2678
+ break;
2556
2679
  case "export":
2557
2680
  await cmdExport(args, dbPath);
2558
2681
  break;
package/dist/db/schema.js CHANGED
@@ -269,6 +269,16 @@ CREATE TABLE IF NOT EXISTS personal_sync_state (
269
269
  entity_type TEXT PRIMARY KEY,
270
270
  synced_up_to TEXT NOT NULL DEFAULT ''
271
271
  );
272
+
273
+ -- El plan de Claude que declara quien usa Estela (estela plan set): el JSON
274
+ -- de la status line no lo dice. Una fila por cambio, para conservar el
275
+ -- historial; el vigente es el de 'since' más reciente.
276
+ CREATE TABLE IF NOT EXISTS claude_plans (
277
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
278
+ plan TEXT NOT NULL,
279
+ since TEXT NOT NULL,
280
+ declared_at TEXT NOT NULL
281
+ );
272
282
  `;
273
283
  /**
274
284
  * Migraciones.
@@ -304,4 +304,13 @@ export declare function entryDetailForTeam(db: DatabaseSync, projectId: string):
304
304
  }[];
305
305
  export declare function getAiConsent(db: DatabaseSync, projectId: string): AiConsentRecord | null;
306
306
  export declare function setAiConsent(db: DatabaseSync, projectId: string, requested: boolean, decision: AiConsentDecision): void;
307
+ export interface DeclaredPlan {
308
+ readonly plan: string;
309
+ readonly since: Date;
310
+ }
311
+ /** El plan vigente: el de `since` más reciente. null si nunca se declaró. */
312
+ export declare function getClaudePlan(db: DatabaseSync): DeclaredPlan | null;
313
+ /** Añade un cambio de plan; los anteriores se conservan. */
314
+ export declare function setClaudePlan(db: DatabaseSync, plan: string, since: Date, at?: Date): void;
315
+ export declare function listClaudePlans(db: DatabaseSync): DeclaredPlan[];
307
316
  //# sourceMappingURL=store.d.ts.map
package/dist/db/store.js CHANGED
@@ -61,6 +61,9 @@ exports.entryAiByDay = entryAiByDay;
61
61
  exports.entryDetailForTeam = entryDetailForTeam;
62
62
  exports.getAiConsent = getAiConsent;
63
63
  exports.setAiConsent = setAiConsent;
64
+ exports.getClaudePlan = getClaudePlan;
65
+ exports.setClaudePlan = setClaudePlan;
66
+ exports.listClaudePlans = listClaudePlans;
64
67
  const node_crypto_1 = require("node:crypto");
65
68
  const shared_1 = require("@estela/shared");
66
69
  const cost_js_1 = require("../pricing/cost.js");
@@ -557,8 +560,14 @@ function saveTimeEntry(db, entry, at = new Date()) {
557
560
  -- Y lo ya facturado es historia. Sin esta condición, un reimport
558
561
  -- recalculaba filas que respaldan una factura emitida y dejaba el panel
559
562
  -- diciendo una cosa y el PDF del cliente otra.
563
+ --
564
+ -- Lo aprobado, igual: aprobar un día es dar por buena esa cifra, y a
565
+ -- menudo ya se ha compartido (un panel publicado, un avance al cliente).
566
+ -- Un cambio en cómo se calculan las horas se aplica a lo que viene, no
567
+ -- reescribe lo que ya se dio por bueno. Para recalcularlo, se desaprueba.
560
568
  WHERE time_entries.source IN ('agent', 'commit')
561
569
  AND time_entries.invoice_id IS NULL
570
+ AND time_entries.approved = 0
562
571
  `).run(id, entry.projectId, iso(entry.startedAt), (0, shared_1.localDate)(entry.startedAt), iso(entry.endedAt), entry.seconds, entry.description, entry.billable ? 1 : 0, entry.invoiceId, entry.aiCost.microUsd, entry.agentSeconds, entry.commitHashes.join(","), entry.agents.join(","), entry.source, entry.kind, entry.branch, (entry.workItems ?? []).join(","), iso(at));
563
572
  return id;
564
573
  }
@@ -982,4 +991,19 @@ function setAiConsent(db, projectId, requested, decision) {
982
991
  requested = excluded.requested, decision = excluded.decision, decided_at = excluded.decided_at
983
992
  `).run(projectId, requested ? 1 : 0, decision, decision === "pending" ? null : iso(new Date()));
984
993
  }
994
+ /** El plan vigente: el de `since` más reciente. null si nunca se declaró. */
995
+ function getClaudePlan(db) {
996
+ const row = db.prepare("SELECT plan, since FROM claude_plans ORDER BY since DESC, id DESC LIMIT 1")
997
+ .get();
998
+ return row ? { plan: row.plan, since: new Date(row.since) } : null;
999
+ }
1000
+ /** Añade un cambio de plan; los anteriores se conservan. */
1001
+ function setClaudePlan(db, plan, since, at = new Date()) {
1002
+ db.prepare("INSERT INTO claude_plans (plan, since, declared_at) VALUES (?, ?, ?)")
1003
+ .run(plan, iso(since), iso(at));
1004
+ }
1005
+ function listClaudePlans(db) {
1006
+ return db.prepare("SELECT plan, since FROM claude_plans ORDER BY since, id").all()
1007
+ .map((r) => ({ plan: r.plan, since: new Date(r.since) }));
1008
+ }
985
1009
  //# sourceMappingURL=store.js.map
@@ -15,6 +15,8 @@ export interface InvoicePdfOptions {
15
15
  };
16
16
  /** Coste real de IA imputado al periodo (cuota amortizada). Solo informativo. */
17
17
  readonly amortizedAiCost?: Money;
18
+ /** Sin tarifa ni importes: solo las horas. Por defecto, con importes. */
19
+ readonly withAmounts?: boolean;
18
20
  readonly footer?: string;
19
21
  }
20
22
  export declare function invoiceToPdf(invoice: Invoice, client: Client, project: Project, options?: InvoicePdfOptions): Buffer;
@@ -15,6 +15,9 @@ function invoiceToPdf(invoice, client, project, options = {}) {
15
15
  // Los importes van con el locale del documento, no con el de la terminal: el
16
16
  // cliente lee "€1.254,79" o "€1,254.79" según SU idioma.
17
17
  const money = (m) => (0, shared_1.formatMoney)(m, (0, index_js_1.moneyLocale)((0, index_js_1.getLang)()));
18
+ const withAmounts = options.withAmounts !== false;
19
+ // Sin tarifa ni importe, el tiempo pasa a ser la última columna.
20
+ const colTime = withAmounts ? COL_TIME : COL_AMOUNT;
18
21
  let y = M;
19
22
  // --- Cabecera -------------------------------------------------------------
20
23
  doc.text(M, y + 4, (0, index_js_1.tr) `INFORME DE HORAS`, "Helvetica-Bold", 20);
@@ -57,20 +60,22 @@ function invoiceToPdf(invoice, client, project, options = {}) {
57
60
  // --- Cabecera de tabla ----------------------------------------------------
58
61
  const header = () => {
59
62
  doc.text(M, y, (0, index_js_1.tr) `CONCEPTO`, "Helvetica-Bold", 8, 0.45);
60
- doc.textRight(COL_TIME, y, (0, index_js_1.tr) `TIEMPO`, "Helvetica-Bold", 8, 0.45);
61
- doc.textRight(COL_RATE, y, (0, index_js_1.tr) `TARIFA`, "Helvetica-Bold", 8, 0.45);
62
- doc.textRight(COL_AMOUNT, y, (0, index_js_1.tr) `IMPORTE`, "Helvetica-Bold", 8, 0.45);
63
+ doc.textRight(colTime, y, (0, index_js_1.tr) `TIEMPO`, "Helvetica-Bold", 8, 0.45);
64
+ if (withAmounts) {
65
+ doc.textRight(COL_RATE, y, (0, index_js_1.tr) `TARIFA`, "Helvetica-Bold", 8, 0.45);
66
+ doc.textRight(COL_AMOUNT, y, (0, index_js_1.tr) `IMPORTE`, "Helvetica-Bold", 8, 0.45);
67
+ }
63
68
  y += 7;
64
69
  doc.line(M, y, COL_AMOUNT, y, 0.8, 0.3);
65
70
  y += 15;
66
71
  };
67
72
  header();
68
73
  // --- Conceptos ------------------------------------------------------------
69
- // La columna TIEMPO se alinea a la derecha en COL_TIME, así que su texto crece
74
+ // La columna TIEMPO se alinea a la derecha en colTime, así que su texto crece
70
75
  // hacia la izquierda. Hay que restar el más ancho de esos textos, no un hueco
71
76
  // fijo, o la descripción se le mete encima.
72
77
  const widestTime = invoice.lines.reduce((max, l) => Math.max(max, pdf_js_1.PdfDocument.measure((0, shared_1.formatDuration)(l.seconds), "Helvetica", 10)), 0);
73
- const maxDescWidth = COL_TIME - M - widestTime - 14;
78
+ const maxDescWidth = colTime - M - widestTime - 14;
74
79
  for (const line of invoice.lines) {
75
80
  if (y > BOTTOM - 60) {
76
81
  doc.newPage();
@@ -79,9 +84,11 @@ function invoiceToPdf(invoice, client, project, options = {}) {
79
84
  }
80
85
  const text = pdf_js_1.PdfDocument.truncate(line.description, "Helvetica", 10, maxDescWidth);
81
86
  doc.text(M, y, text, "Helvetica", 10);
82
- doc.textRight(COL_TIME, y, (0, shared_1.formatDuration)(line.seconds), "Helvetica", 10, 0.2);
83
- doc.textRight(COL_RATE, y, `${money(line.hourlyRate)}/h`, "Helvetica", 10, 0.4);
84
- doc.textRight(COL_AMOUNT, y, money(line.amount), "Helvetica", 10);
87
+ doc.textRight(colTime, y, (0, shared_1.formatDuration)(line.seconds), "Helvetica", 10, 0.2);
88
+ if (withAmounts) {
89
+ doc.textRight(COL_RATE, y, `${money(line.hourlyRate)}/h`, "Helvetica", 10, 0.4);
90
+ doc.textRight(COL_AMOUNT, y, money(line.amount), "Helvetica", 10);
91
+ }
85
92
  y += 9;
86
93
  doc.line(M, y, COL_AMOUNT, y, 0.4, 0.9);
87
94
  y += 12;
@@ -95,19 +102,22 @@ function invoiceToPdf(invoice, client, project, options = {}) {
95
102
  doc.line(M, y, COL_AMOUNT, y, 0.8, 0.3);
96
103
  y += 18;
97
104
  doc.text(M, y, invoice.lines.length === 1 ? (0, index_js_1.tr) `1 concepto` : (0, index_js_1.tr) `${invoice.lines.length} conceptos`, "Helvetica", 9, 0.4);
98
- doc.textRight(COL_TIME, y, (0, shared_1.formatDuration)(invoice.totalSeconds), "Helvetica-Bold", 10);
99
- doc.textRight(COL_AMOUNT, y, money(invoice.subtotal), "Helvetica", 10);
105
+ doc.textRight(colTime, y, (0, shared_1.formatDuration)(invoice.totalSeconds), "Helvetica-Bold", 10);
106
+ if (withAmounts)
107
+ doc.textRight(COL_AMOUNT, y, money(invoice.subtotal), "Helvetica", 10);
100
108
  y += 18;
101
- if (invoice.aiCostBilled) {
109
+ if (withAmounts && invoice.aiCostBilled) {
102
110
  doc.text(M, y, (0, index_js_1.tr) `Coste de IA repercutido (1 USD = ${invoice.usdFxRate} ${invoice.currency})`, "Helvetica", 9, 0.4);
103
111
  doc.textRight(COL_AMOUNT, y, money(invoice.aiCostBilled), "Helvetica", 10);
104
112
  y += 18;
105
113
  }
106
- y += 4;
107
- doc.rect(rightCol - 20, y - 14, COL_AMOUNT - rightCol + 20, 32, 0.94);
108
- doc.text(rightCol - 8, y + 2, (0, index_js_1.tr) `VALOR`, "Helvetica-Bold", 11);
109
- doc.textRight(COL_AMOUNT - 8, y + 3, money(invoice.total), "Helvetica-Bold", 14);
110
- y += 46;
114
+ if (withAmounts) {
115
+ y += 4;
116
+ doc.rect(rightCol - 20, y - 14, COL_AMOUNT - rightCol + 20, 32, 0.94);
117
+ doc.text(rightCol - 8, y + 2, (0, index_js_1.tr) `VALOR`, "Helvetica-Bold", 11);
118
+ doc.textRight(COL_AMOUNT - 8, y + 3, money(invoice.total), "Helvetica-Bold", 14);
119
+ y += 46;
120
+ }
111
121
  // Aquí había una "NOTA INTERNA (no se comparte)" con el consumo de IA del
112
122
  // periodo. Se quitó: esa frase era un deseo, no un mecanismo. Este PDF es
113
123
  // justamente el que se adjunta a la factura del cliente, así que en cuanto
package/dist/i18n/en.js CHANGED
@@ -579,5 +579,50 @@ exports.EN = {
579
579
  "ESTELA_OFFLINE=1: Estela no se conecta a la red. Quita la variable para usar tu cuenta.": "ESTELA_OFFLINE=1: Estela doesn't connect to the network. Unset it to use your account.",
580
580
  // ── statusline install: VS Code ──
581
581
  "Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.": "Only in Claude Code in the terminal: the VS Code extension doesn't show the status line.",
582
+ // ── estela plan ──
583
+ "--days tiene que ser un número entero de 1 a {0}.": "--days has to be a whole number from 1 to {0}.",
584
+ "Plan desconocido: \"{0}\". Usa pro, max5 o max20.": "Unknown plan: \"{0}\". Use pro, max5 or max20.",
585
+ "--since tiene que ser una fecha AAAA-MM-DD.": "--since has to be a YYYY-MM-DD date.",
586
+ "Plan declarado: {0}, desde {1}. El análisis solo usa lo guardado desde esa fecha.": "Plan declared: {0}, since {1}. The analysis only uses what was stored from that date.",
587
+ "Si ya lo tenías antes, indica desde cuándo: estela plan set {0} --since AAAA-MM-DD": "If you had it before, say since when: estela plan set {0} --since YYYY-MM-DD",
588
+ "Ventanas de 5 h": "5-hour windows",
589
+ "ninguna completa todavía": "none yet",
590
+ "{0} ventanas · pico mediano {1} % · p90 {2} % · máximo {3} %": "{0} windows · median peak {1}% · p90 {2}% · max {3}%",
591
+ "Topes (≥95 %)": "Caps (≥95%)",
592
+ "Parado tras topes": "Stopped after caps",
593
+ "hasta {0} (estimado: no sabemos si seguías trabajando)": "up to {0} (estimated: we don't know if you kept working)",
594
+ "Semana": "Week",
595
+ "sin datos de la semana": "no weekly data",
596
+ "pico mediano {0} % · máximo {1} % · {2} semanas ≥ 90 %": "median peak {0}% · max {1}% · {2} weeks ≥ 90%",
597
+ "Bloqueos (429)": "Blocks (429)",
598
+ "{0} de 5 h · {1} semanales, según los transcripts de Claude Code": "{0} 5-hour · {1} weekly, from Claude Code's transcripts",
599
+ "Si estuvieras en…": "If you were on…",
600
+ "Datos de {0} días. La recomendación necesita al menos 14.": "{0} days of data. The recommendation needs at least 14.",
601
+ "Recomendación (provisional, {0} días)": "Recommendation (provisional, {0} days)",
602
+ "Recomendación": "Recommendation",
603
+ "Límite semanal entre planes: Anthropic no publica la relación. No lo estimamos.": "Weekly limit between plans: Anthropic doesn't publish the ratio. We don't estimate it.",
604
+ "Basado en el multiplicador por sesión publicado por Anthropic:": "Based on the per-session multiplier published by Anthropic:",
605
+ "Anthropic puede cambiar los límites; repite el análisis cada mes.": "Anthropic may change the limits; run this again every month.",
606
+ "Precios según la web de Anthropic a {0}; compruébalo: {1}": "Prices from Anthropic's website as of {0}; check them: {1}",
607
+ "Esa fecha tiene más de {0} días: los precios pueden estar desactualizados.": "That date is more than {0} days old: prices may be out of date.",
608
+ "(desde {0})": "(since {0})",
609
+ "{0} días con datos": "{0} days with data",
610
+ "sin topes, igual que ahora": "no caps, same as now",
611
+ "p90 equivalente {0} % · habrías evitado {1} de {2} topes (con el uso que llegó a registrarse)": "equivalent p90 {0}% · you'd have avoided {1} of {2} caps (with the usage that got recorded)",
612
+ "p90 equivalente {0} % · no te habría bloqueado en ninguna de las {1} ventanas": "equivalent p90 {0}% · wouldn't have blocked you in any of the {1} windows",
613
+ "p90 equivalente {0} % · te habría bloqueado en {1} de {2} ventanas": "equivalent p90 {0}% · would have blocked you in {1} of {2} windows",
614
+ "Vas justo con tu plan. Con {0} habrías evitado {1} de {2} topes.": "Your plan is tight for you. With {0} you'd have avoided {1} of {2} caps.",
615
+ "Vas justo con tu plan.": "Your plan is tight for you.",
616
+ "Con los datos de estas {0} semanas, el plan {1} te habría bastado en todas las ventanas de 5 h.": "Based on these {0} weeks of data, the {1} plan would have been enough in every 5-hour window.",
617
+ "El plan {0} te habría bastado casi siempre: te habría bloqueado {1} veces en estas semanas.": "The {0} plan would have been enough almost always: it would have blocked you {1} times in these weeks.",
618
+ "Tu plan actual encaja con tu uso.": "Your current plan fits your usage.",
619
+ "{0} menos al mes, según la web de Anthropic a {1}; compruébalo.": "{0} less per month, per Anthropic's website as of {1}; check it.",
620
+ "{0} más al mes, según la web de Anthropic a {1}; compruébalo.": "{0} more per month, per Anthropic's website as of {1}; check it.",
621
+ "estela plan compara tu uso real de Claude con los otros planes. Primero dime cuál pagas:": "estela plan compares your real Claude usage with the other plans. First tell it which one you pay for:",
622
+ "Si ya lo tenías antes de hoy, añade desde cuándo: --since AAAA-MM-DD": "If you had it before today, add since when: --since YYYY-MM-DD",
623
+ "Este análisis solo funciona con planes Pro o Max.": "This analysis only works with Pro or Max plans.",
624
+ "Aún no hay datos. Instala la status line (estela statusline install) y vuelve en unos días.": "No data yet. Install the status line (estela statusline install) and come back in a few days.",
625
+ "Solo se recogen en Claude Code en la terminal: la extensión de VS Code no ejecuta la status line.": "They're only collected in Claude Code in the terminal: the VS Code extension doesn't run the status line.",
626
+ "{0} $": "${0}",
582
627
  };
583
628
  //# sourceMappingURL=en.js.map