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.
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderPlanReport = renderPlanReport;
4
+ exports.recommendationText = recommendationText;
5
+ exports.pricesStale = pricesStale;
6
+ exports.renderNoPlan = renderNoPlan;
7
+ exports.renderNoData = renderNoData;
8
+ exports.planToJson = planToJson;
9
+ const index_js_1 = require("./i18n/index.js");
10
+ const plan_prices_js_1 = require("./plan-prices.js");
11
+ const statusline_js_1 = require("./statusline.js");
12
+ const ESC = "\x1b[";
13
+ const JADE = `${ESC}38;2;70;192;167m`; // #46C0A7, como en la status line
14
+ const AMBER = `${ESC}38;2;221;168;90m`;
15
+ const DIM = `${ESC}90m`;
16
+ const RESET = `${ESC}0m`;
17
+ const LABEL = 21;
18
+ function renderPlanReport(a, o) {
19
+ const paint = (code, text) => (o.color ? `${code}${text}${RESET}` : text);
20
+ const row = (label, value) => `${label.padEnd(LABEL)}${value}`;
21
+ const out = [];
22
+ out.push(header(a, paint));
23
+ out.push("");
24
+ const f = a.fiveHour;
25
+ out.push(row((0, index_js_1.tr) `Ventanas de 5 h`, f.windows.length === 0
26
+ ? (0, index_js_1.tr) `ninguna completa todavía`
27
+ : (0, index_js_1.tr) `${f.windows.length} ventanas · pico mediano ${pct(f.medianPeak)} % · p90 ${pct(f.p90Peak)} % · máximo ${pct(f.maxPeak)} %`));
28
+ out.push(row((0, index_js_1.tr) `Topes (≥95 %)`, String(f.caps)));
29
+ if (f.stoppedMs > 0) {
30
+ out.push(row((0, index_js_1.tr) `Parado tras topes`, (0, index_js_1.tr) `hasta ${(0, statusline_js_1.compactDuration)(f.stoppedMs / 1000)} (estimado: no sabemos si seguías trabajando)`));
31
+ }
32
+ const w = a.week;
33
+ out.push(row((0, index_js_1.tr) `Semana`, w.weeks.length === 0
34
+ ? (0, index_js_1.tr) `sin datos de la semana`
35
+ : (0, index_js_1.tr) `pico mediano ${pct(w.medianPeak)} % · máximo ${pct(w.maxPeak)} % · ${w.weeksAtOrAbove90} semanas ≥ 90 %`));
36
+ if (o.blocks) {
37
+ out.push(row((0, index_js_1.tr) `Bloqueos (429)`, (0, index_js_1.tr) `${o.blocks.fiveHour} de 5 h · ${o.blocks.sevenDay} semanales, según los transcripts de Claude Code`));
38
+ }
39
+ if (f.windows.length > 0) {
40
+ out.push("");
41
+ out.push((0, index_js_1.tr) `Si estuvieras en…`);
42
+ for (const alt of [...a.alternatives].sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd)) {
43
+ out.push(` ${`${alt.plan.name} (${usd(alt.plan.monthlyUsd)})`.padEnd(LABEL - 2)}${alternativeText(alt, a)}`);
44
+ }
45
+ }
46
+ out.push("");
47
+ if (a.dataState === "insufficient") {
48
+ out.push(paint(AMBER, (0, index_js_1.tr) `Datos de ${a.daysWithData} días. La recomendación necesita al menos 14.`));
49
+ }
50
+ else if (a.recommendation) {
51
+ const label = a.dataState === "provisional"
52
+ ? (0, index_js_1.tr) `Recomendación (provisional, ${a.daysWithData} días)`
53
+ : (0, index_js_1.tr) `Recomendación`;
54
+ const text = recommendationText(a);
55
+ out.push(row(label, paint(a.recommendation.kind === "tight" ? AMBER : JADE, text)));
56
+ const diff = priceDiffText(a);
57
+ if (diff)
58
+ out.push(row("", paint(DIM, diff)));
59
+ }
60
+ out.push("");
61
+ out.push((0, index_js_1.tr) `Límite semanal entre planes: Anthropic no publica la relación. No lo estimamos.`);
62
+ out.push((0, index_js_1.tr) `Basado en el multiplicador por sesión publicado por Anthropic:`);
63
+ out.push(plan_prices_js_1.MULTIPLIER_SOURCE);
64
+ out.push((0, index_js_1.tr) `Anthropic puede cambiar los límites; repite el análisis cada mes.`);
65
+ out.push((0, index_js_1.tr) `Precios según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo: ${plan_prices_js_1.PRICES_SOURCE}`);
66
+ if (pricesStale(o.now))
67
+ out.push(paint(AMBER, (0, index_js_1.tr) `Esa fecha tiene más de ${plan_prices_js_1.PRICES_STALE_DAYS} días: los precios pueden estar desactualizados.`));
68
+ return out.join("\n");
69
+ }
70
+ function header(a, paint) {
71
+ return `${paint(JADE, "estela")} plan · ${a.plan.name} ${(0, index_js_1.tr) `(desde ${day(a.since)})`} · ${(0, index_js_1.tr) `${a.daysWithData} días con datos`}`;
72
+ }
73
+ function alternativeText(alt, a) {
74
+ const n = a.fiveHour.windows.length;
75
+ if (alt.direction === "higher") {
76
+ if (a.fiveHour.caps === 0)
77
+ return (0, index_js_1.tr) `sin topes, igual que ahora`;
78
+ return (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · habrías evitado ${alt.capsAvoided} de ${a.fiveHour.caps} topes (con el uso que llegó a registrarse)`;
79
+ }
80
+ return alt.blockedWindows === 0
81
+ ? (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · no te habría bloqueado en ninguna de las ${n} ventanas`
82
+ : (0, index_js_1.tr) `p90 equivalente ${pct(alt.p90Equivalent)} % · te habría bloqueado en ${alt.blockedWindows} de ${n} ventanas`;
83
+ }
84
+ function recommendationText(a) {
85
+ const r = a.recommendation;
86
+ if (!r)
87
+ return "";
88
+ switch (r.kind) {
89
+ case "tight":
90
+ return r.higher && r.capsAvoided !== null
91
+ ? (0, index_js_1.tr) `Vas justo con tu plan. Con ${r.higher.name} habrías evitado ${r.capsAvoided} de ${a.fiveHour.caps} topes.`
92
+ : (0, index_js_1.tr) `Vas justo con tu plan.`;
93
+ case "enough":
94
+ return (0, index_js_1.tr) `Con los datos de estas ${r.weeks} semanas, el plan ${r.plan.name} te habría bastado en todas las ventanas de 5 h.`;
95
+ case "almost":
96
+ return (0, index_js_1.tr) `El plan ${r.plan.name} te habría bastado casi siempre: te habría bloqueado ${r.blocked} veces en estas semanas.`;
97
+ case "fits":
98
+ return (0, index_js_1.tr) `Tu plan actual encaja con tu uso.`;
99
+ }
100
+ }
101
+ /** La diferencia de precio de lo que se recomienda, si es cambiar de plan. */
102
+ function priceDiffText(a) {
103
+ const r = a.recommendation;
104
+ const other = r?.kind === "enough" || r?.kind === "almost" ? r.plan
105
+ : r?.kind === "tight" ? r.higher : null;
106
+ if (!other)
107
+ return null;
108
+ const diff = other.monthlyUsd - a.plan.monthlyUsd;
109
+ return diff < 0
110
+ ? (0, index_js_1.tr) `${usd(-diff)} menos al mes, según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo.`
111
+ : (0, index_js_1.tr) `${usd(diff)} más al mes, según la web de Anthropic a ${plan_prices_js_1.PRICES_CHECKED_AT}; compruébalo.`;
112
+ }
113
+ function pricesStale(now) {
114
+ return now.getTime() - Date.parse(`${plan_prices_js_1.PRICES_CHECKED_AT}T00:00:00Z`) > plan_prices_js_1.PRICES_STALE_DAYS * 86_400_000;
115
+ }
116
+ /** Sin plan declarado: cómo declararlo. */
117
+ function renderNoPlan() {
118
+ return [
119
+ (0, index_js_1.tr) `estela plan compara tu uso real de Claude con los otros planes. Primero dime cuál pagas:`,
120
+ "",
121
+ " estela plan set pro",
122
+ " estela plan set max5",
123
+ " estela plan set max20",
124
+ "",
125
+ (0, index_js_1.tr) `Si ya lo tenías antes de hoy, añade desde cuándo: --since AAAA-MM-DD`,
126
+ ].join("\n");
127
+ }
128
+ function renderNoData(a, apiKey, color) {
129
+ const paint = (code, text) => (color ? `${code}${text}${RESET}` : text);
130
+ return [
131
+ header(a, paint), "",
132
+ apiKey
133
+ ? (0, index_js_1.tr) `Este análisis solo funciona con planes Pro o Max.`
134
+ : (0, index_js_1.tr) `Aún no hay datos. Instala la status line (estela statusline install) y vuelve en unos días.`,
135
+ ...(apiKey ? [] : [(0, index_js_1.tr) `Solo se recogen en Claude Code en la terminal: la extensión de VS Code no ejecuta la status line.`]),
136
+ ].join("\n");
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ /**
140
+ * `estela plan --json`. Estructura estable (documentada en el README):
141
+ * campos nuevos se pueden añadir, ninguno cambia de nombre ni de significado.
142
+ */
143
+ function planToJson(a, o) {
144
+ const r1 = (n) => (n === null ? null : Math.round(n * 10) / 10);
145
+ const rec = a.recommendation;
146
+ return {
147
+ version: 1,
148
+ plan: { id: a.plan.id, name: a.plan.name, since: a.since.toISOString(), sessionMultiplier: a.plan.sessionMultiplier },
149
+ period: { from: a.from.toISOString(), to: a.to.toISOString(), days: a.days, daysWithData: a.daysWithData, samples: a.samples },
150
+ dataState: a.dataState,
151
+ fiveHour: {
152
+ windows: a.fiveHour.windows.length,
153
+ medianPeak: r1(a.fiveHour.medianPeak), p90Peak: r1(a.fiveHour.p90Peak), maxPeak: r1(a.fiveHour.maxPeak),
154
+ caps: a.fiveHour.caps,
155
+ stoppedMinutesUpperBound: Math.round(a.fiveHour.stoppedMs / 60_000),
156
+ },
157
+ week: {
158
+ weeks: a.week.weeks.length,
159
+ medianPeak: r1(a.week.medianPeak), maxPeak: r1(a.week.maxPeak),
160
+ weeksAtOrAbove90: a.week.weeksAtOrAbove90,
161
+ },
162
+ blocks429: o.blocks,
163
+ alternatives: [...a.alternatives].sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd).map((x) => ({
164
+ plan: x.plan.id, name: x.plan.name, direction: x.direction,
165
+ monthlyUsd: x.plan.monthlyUsd, monthlyDiffUsd: x.plan.monthlyUsd - a.plan.monthlyUsd,
166
+ p90Equivalent: r1(x.p90Equivalent), maxEquivalent: r1(x.maxEquivalent),
167
+ blockedWindows: x.blockedWindows, capsAvoided: x.capsAvoided,
168
+ })),
169
+ recommendation: rec === null ? null : {
170
+ kind: rec.kind,
171
+ provisional: a.dataState === "provisional",
172
+ plan: rec.kind === "enough" || rec.kind === "almost" ? rec.plan.id : rec.kind === "tight" ? rec.higher?.id ?? null : null,
173
+ blockedWindows: rec.kind === "almost" ? rec.blocked : null,
174
+ capsAvoided: rec.kind === "tight" ? rec.capsAvoided : null,
175
+ text: recommendationText(a),
176
+ },
177
+ weeklyBetweenPlans: "not_published",
178
+ sources: {
179
+ sessionMultiplier: plan_prices_js_1.MULTIPLIER_SOURCE,
180
+ prices: plan_prices_js_1.PRICES_SOURCE, pricesCheckedAt: plan_prices_js_1.PRICES_CHECKED_AT, pricesStale: pricesStale(o.now),
181
+ },
182
+ plans: plan_prices_js_1.CLAUDE_PLANS.map((p) => ({ id: p.id, name: p.name, sessionMultiplier: p.sessionMultiplier, monthlyUsd: p.monthlyUsd })),
183
+ };
184
+ }
185
+ function pct(n) { return Math.round(n); }
186
+ function usd(n) { return (0, index_js_1.tr) `${n} $`; }
187
+ function day(d) {
188
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
189
+ }
190
+ //# sourceMappingURL=plan-report.js.map
package/dist/server.js CHANGED
@@ -142,7 +142,7 @@ async function importOnce(dbPath) {
142
142
  // Solo tus commits producen horas tuyas.
143
143
  const commits = (0, identity_js_1.onlyMine)(allRepoCommits, mine);
144
144
  const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commits);
145
- const fromCommits = (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), fromAgents);
145
+ const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commits, fromAgents);
146
146
  // Las mismas reglas que `estela import` (billing/assign.ts). Las horas de
147
147
  // subcarpetas y worktrees solo se recuperan si ya se confirmó una vez
148
148
  // desde la terminal: el panel no cambia horas importadas en segundo plano
@@ -581,11 +581,11 @@ async function api(req, res, url, dbPath) {
581
581
  // recalcular nada: el mismo papel que salió el día que se emitió.
582
582
  const reimpresion = /^\/api\/invoices\/([A-Za-z0-9-]+)\/pdf$/.exec(path);
583
583
  if (reimpresion && req.method === "GET") {
584
- return sendStoredInvoicePdf(db, res, reimpresion[1], url.searchParams.get("author") ?? "");
584
+ return sendStoredInvoicePdf(db, res, reimpresion[1], url.searchParams.get("author") ?? "", url.searchParams.get("amounts") !== "0");
585
585
  }
586
586
  if (path === "/api/invoice" && req.method === "POST") {
587
587
  const body = await readJson(req);
588
- return sendInvoice(db, res, String(body["projectId"] ?? ""), String(body["cutoff"] ?? ""), typeof body["author"] === "string" ? body["author"] : "");
588
+ return sendInvoice(db, res, String(body["projectId"] ?? ""), String(body["cutoff"] ?? ""), typeof body["author"] === "string" ? body["author"] : "", body["withAmounts"] !== false);
589
589
  }
590
590
  if (path === "/api/entry" && req.method === "POST") {
591
591
  const body = await readJson(req);
@@ -1368,7 +1368,7 @@ function sendReport(db, res, projectId, from, to, withAmounts, author) {
1368
1368
  * congeló al emitirla. Es lo que hace que el papel de agosto siga siendo el
1369
1369
  * papel de agosto cuando se pide en diciembre.
1370
1370
  */
1371
- function sendStoredInvoicePdf(db, res, number, author) {
1371
+ function sendStoredInvoicePdf(db, res, number, author, withAmounts) {
1372
1372
  const invoice = store.getInvoice(db, number);
1373
1373
  if (!invoice)
1374
1374
  return json(res, 404, { error: "Esa factura no existe." });
@@ -1382,15 +1382,20 @@ function sendStoredInvoicePdf(db, res, number, author) {
1382
1382
  const pdf = (0, index_js_2.withLang)((0, index_js_2.documentLang)({ clientLanguage: client.language }), () => (0, invoice_pdf_js_1.invoiceToPdf)(invoice, client, project, {
1383
1383
  ...(author ? { issuer: { name: author } } : {}),
1384
1384
  ...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
1385
+ withAmounts,
1385
1386
  }));
1386
1387
  res.writeHead(200, {
1387
1388
  "Content-Type": "application/pdf",
1388
- "Content-Disposition": `attachment; filename="${invoice.number}.pdf"`,
1389
+ "Content-Disposition": `attachment; filename="${invoicePdfName(invoice.number, withAmounts)}"`,
1389
1390
  "Content-Length": pdf.length,
1390
1391
  });
1391
1392
  res.end(pdf);
1392
1393
  }
1393
- function sendInvoice(db, res, projectId, cutoffRaw, author) {
1394
+ /** Con otro nombre, para no confundir al guardarlos juntos cuál lleva importes. */
1395
+ function invoicePdfName(number, withAmounts) {
1396
+ return withAmounts ? `${number}.pdf` : `${number}-sin-importes.pdf`;
1397
+ }
1398
+ function sendInvoice(db, res, projectId, cutoffRaw, author, withAmounts) {
1394
1399
  const project = store.getProject(db, projectId);
1395
1400
  if (!project)
1396
1401
  return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
@@ -1429,8 +1434,9 @@ function sendInvoice(db, res, projectId, cutoffRaw, author) {
1429
1434
  const pdf = (0, index_js_2.withLang)((0, index_js_2.documentLang)({ clientLanguage: client.language }), () => (0, invoice_pdf_js_1.invoiceToPdf)(invoice, client, project, {
1430
1435
  ...(author ? { issuer: { name: author } } : {}),
1431
1436
  ...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
1437
+ withAmounts,
1432
1438
  }));
1433
- const filename = `${invoice.number}.pdf`;
1439
+ const filename = invoicePdfName(invoice.number, withAmounts);
1434
1440
  res.writeHead(200, {
1435
1441
  "Content-Type": "application/pdf",
1436
1442
  "Content-Disposition": `attachment; filename="${filename}"`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "estela",
3
- "version": "0.2.4",
3
+ "version": "0.3.2",
4
4
  "description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
5
5
  "keywords": [
6
6
  "time-tracking",
package/web/app.css CHANGED
@@ -900,6 +900,7 @@ main.wide { max-width: 1180px; }
900
900
  subraye y que se centre como el resto. */
901
901
  .invoice-dl{flex:0 0 auto;text-decoration:none;display:inline-flex;
902
902
  align-items:center;padding:9px 18px;font-size:.9rem}
903
+ .invoice-dls{flex:0 0 auto;display:flex;gap:8px;flex-wrap:wrap}
903
904
  @media(max-width:620px){
904
905
  .invoice-row{flex-wrap:wrap}
905
906
  .invoice-main{flex:1 1 100%}
package/web/app.js CHANGED
@@ -1469,8 +1469,12 @@ function renderReports() {
1469
1469
  <div class="report-hours">${fmtDuration(f.totalSeconds)}</div>
1470
1470
  <div class="report-amount">${importe}</div>
1471
1471
  </div>
1472
- <a class="btn-main invoice-dl" download
1473
- href="/api/invoices/${encodeURIComponent(f.number)}/pdf">${tr("invoices.download")}</a>
1472
+ <div class="invoice-dls">
1473
+ <a class="btn-main invoice-dl" download
1474
+ href="/api/invoices/${encodeURIComponent(f.number)}/pdf">${tr("invoices.withAmounts")}</a>
1475
+ <a class="chip invoice-dl" download
1476
+ href="/api/invoices/${encodeURIComponent(f.number)}/pdf?amounts=0">${tr("invoices.withoutAmounts")}</a>
1477
+ </div>
1474
1478
  </div>`;
1475
1479
  }).join("") + "</div>");
1476
1480
  }
@@ -1535,6 +1539,7 @@ function renderReports() {
1535
1539
  const projectId = form.dataset.project;
1536
1540
  const cutoff = form.querySelector(".rep-to").value;
1537
1541
  const author = form.querySelector(".rep-author").value;
1542
+ const withAmounts = form.querySelector(".rep-amounts").checked;
1538
1543
  const row = (state.reportsOverview?.unbilled || []).find((r) => r.projectId === projectId);
1539
1544
  const proyecto = row ? row.projectName : projectId;
1540
1545
 
@@ -1542,7 +1547,7 @@ function renderReports() {
1542
1547
 
1543
1548
  btn.disabled = true;
1544
1549
  try {
1545
- await cutoffAndInvoice(projectId, cutoff, author);
1550
+ await cutoffAndInvoice(projectId, cutoff, author, withAmounts);
1546
1551
  toast(tr("toast.invoiced"));
1547
1552
  await loadReports();
1548
1553
  } catch (error) {
@@ -1561,11 +1566,11 @@ function renderReports() {
1561
1566
  * con un enlace sintético — el mismo patrón que usa cualquier app sin
1562
1567
  * framework para esto.
1563
1568
  */
1564
- async function cutoffAndInvoice(projectId, cutoff, author) {
1569
+ async function cutoffAndInvoice(projectId, cutoff, author, withAmounts) {
1565
1570
  const res = await fetch("/api/invoice", {
1566
1571
  method: "POST",
1567
1572
  headers: { "Content-Type": "application/json" },
1568
- body: JSON.stringify({ projectId, cutoff, author }),
1573
+ body: JSON.stringify({ projectId, cutoff, author, withAmounts }),
1569
1574
  });
1570
1575
  if (!res.ok) {
1571
1576
  let message = `Error ${res.status}`;
package/web/i18n.js CHANGED
@@ -308,9 +308,10 @@
308
308
  "reports.allbilled.title": "Todo facturado",
309
309
  "reports.allbilled.body": "No queda nada pendiente en este periodo. Lo ya emitido está abajo, y se puede volver a descargar.",
310
310
  "invoices.title": "Ya facturado",
311
- "invoices.note": "Se reimprime tal y como se emitió: los importes y el reparto de IA quedaron congelados ese día.",
311
+ "invoices.note": "Se reimprime tal y como se emitió, con o sin importes: las cifras quedaron congeladas ese día.",
312
312
  "invoices.upto": "Hasta el {date}",
313
- "invoices.download": "Descargar",
313
+ "invoices.withAmounts": "Con importes",
314
+ "invoices.withoutAmounts": "Sin importes",
314
315
  "reports.noMatch": "Ningún proyecto coincide con «{q}».",
315
316
  "reports.client": "{client} · {blocks} · todo lo pendiente desde {date}",
316
317
  "reports.from": "Desde",
@@ -613,9 +614,10 @@
613
614
  "reports.allbilled.title": "All invoiced",
614
615
  "reports.allbilled.body": "Nothing pending in this period. What you already issued is below, and can be downloaded again.",
615
616
  "invoices.title": "Already invoiced",
616
- "invoices.note": "Reprinted exactly as issued: amounts and the AI split were frozen that day.",
617
+ "invoices.note": "Reprinted exactly as issued, with or without amounts: the figures were frozen that day.",
617
618
  "invoices.upto": "Up to {date}",
618
- "invoices.download": "Download",
619
+ "invoices.withAmounts": "With amounts",
620
+ "invoices.withoutAmounts": "Without amounts",
619
621
  "reports.noMatch": "No project matches “{q}”.",
620
622
  "reports.client": "{client} · {blocks} · everything pending since {date}",
621
623
  "reports.from": "From",