estela 0.2.3 → 0.3.0
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 +65 -0
- package/dist/cli.js +123 -1
- package/dist/db/schema.js +10 -0
- package/dist/db/store.d.ts +9 -0
- package/dist/db/store.js +18 -0
- package/dist/i18n/en.js +47 -0
- package/dist/metrics/plan.d.ts +110 -0
- package/dist/metrics/plan.js +175 -0
- package/dist/plan-blocks.d.ts +26 -0
- package/dist/plan-blocks.js +110 -0
- package/dist/plan-prices.d.ts +27 -0
- package/dist/plan-prices.js +29 -0
- package/dist/plan-report.d.ts +96 -0
- package/dist/plan-report.js +190 -0
- package/dist/rate-history.d.ts +63 -0
- package/dist/rate-history.js +193 -0
- package/dist/statusline-cache.d.ts +10 -0
- package/dist/statusline-run.js +8 -0
- package/package.json +1 -1
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
|
|
@@ -306,6 +361,16 @@ won't overwrite it: `--force` replaces it, `--append` shows yours after
|
|
|
306
361
|
Estela's. `estela statusline uninstall` removes it (and puts yours back if
|
|
307
362
|
you used `--append`).
|
|
308
363
|
|
|
364
|
+
The status line only runs in Claude Code in the terminal: the VS Code
|
|
365
|
+
extension doesn't show it.
|
|
366
|
+
|
|
367
|
+
Since 0.2.4, the status line stores your 5-hour and weekly percentages locally
|
|
368
|
+
(`~/.estela/rate-limits.jsonl`) so `estela plan` can analyse them. Nothing leaves
|
|
369
|
+
your machine. Only what Claude Code sends is kept — each percentage, its reset
|
|
370
|
+
time and the Claude Code version — at most every 5 minutes unless a figure moves,
|
|
371
|
+
and for 120 days. Transcripts don't carry these percentages, so the history
|
|
372
|
+
starts the day you install 0.2.4.
|
|
373
|
+
|
|
309
374
|
## Hours no import will infer
|
|
310
375
|
|
|
311
376
|
Meetings, travel, research, and development without an agent that didn't leave
|
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,
|
|
@@ -369,6 +385,15 @@ estela — time tracking for AI-assisted development
|
|
|
369
385
|
left out: it doesn't record reliable tokens. Before calculating it reads transcripts since Monday
|
|
370
386
|
(--no-refresh skips that). Unlike ai-cost, which splits money, this
|
|
371
387
|
splits tokens.
|
|
388
|
+
|
|
389
|
+
estela plan [--days <n>] [--json] [--no-color]
|
|
390
|
+
estela plan set pro|max5|max20 [--since YYYY-MM-DD]
|
|
391
|
+
Is your Claude plan too big or too small? It analyses the 5-hour
|
|
392
|
+
and weekly percentages Claude Code sends to the status line (only
|
|
393
|
+
in the terminal) and compares them with the other plans using the
|
|
394
|
+
per-session multiplier Anthropic publishes. The weekly limit isn't
|
|
395
|
+
compared across plans: it isn't published. Declare your plan first
|
|
396
|
+
with "plan set". Defaults to the last 28 days.
|
|
372
397
|
estela statusline install [--force | --append]
|
|
373
398
|
A line under every Claude Code session: project, hours measured
|
|
374
399
|
today, this project's share of your usage this week and, if Claude
|
|
@@ -1544,6 +1569,90 @@ async function cmdUnpublish(args, dbPath) {
|
|
|
1544
1569
|
db.close();
|
|
1545
1570
|
}
|
|
1546
1571
|
}
|
|
1572
|
+
/**
|
|
1573
|
+
* `estela plan`: ¿el plan de Claude que pagas es el que te conviene?
|
|
1574
|
+
*
|
|
1575
|
+
* Solo con lo que manda Claude Code a la status line (rate-limits.jsonl) y el
|
|
1576
|
+
* multiplicador por sesión que publica Anthropic. Ver metrics/plan.ts.
|
|
1577
|
+
*/
|
|
1578
|
+
function cmdPlan(args, dbPath) {
|
|
1579
|
+
const db = (0, schema_js_1.openDatabase)(dbPath);
|
|
1580
|
+
try {
|
|
1581
|
+
const declared = store.getClaudePlan(db);
|
|
1582
|
+
const json = args.flags["json"] === true;
|
|
1583
|
+
if (!declared || !(0, plan_prices_js_1.planById)(declared.plan)) {
|
|
1584
|
+
if (json)
|
|
1585
|
+
console.log(JSON.stringify({ version: 1, plan: null }, null, 2));
|
|
1586
|
+
else
|
|
1587
|
+
console.log((0, plan_report_js_1.renderNoPlan)());
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
const days = str(args, "days") === undefined ? 28 : Number(str(args, "days"));
|
|
1591
|
+
if (!Number.isInteger(days) || days < 1 || days > rate_history_js_1.KEEP_DAYS) {
|
|
1592
|
+
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}.`);
|
|
1593
|
+
}
|
|
1594
|
+
const now = new Date();
|
|
1595
|
+
const file = (0, rate_history_js_1.historyPath)(dbPath);
|
|
1596
|
+
try {
|
|
1597
|
+
(0, rate_history_js_1.pruneSamples)(file, now);
|
|
1598
|
+
}
|
|
1599
|
+
catch { /* sin rotar se analiza igual */ }
|
|
1600
|
+
const all = (0, rate_history_js_1.readSamples)(file);
|
|
1601
|
+
const analysis = (0, plan_js_1.analyzePlan)({ samples: all, plan: declared.plan, since: declared.since, now, days });
|
|
1602
|
+
// Los 429 que Claude Code dejó en sus transcripts, en el mismo período.
|
|
1603
|
+
let blocks = null;
|
|
1604
|
+
try {
|
|
1605
|
+
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)
|
|
1606
|
+
.filter((b) => b.at >= analysis.from.getTime() && b.at <= now.getTime());
|
|
1607
|
+
blocks = { fiveHour: found.filter((b) => b.type === "five_hour").length,
|
|
1608
|
+
sevenDay: found.filter((b) => b.type === "seven_day").length };
|
|
1609
|
+
}
|
|
1610
|
+
catch { /* sin transcripts legibles, la línea no sale */ }
|
|
1611
|
+
if (json) {
|
|
1612
|
+
console.log(JSON.stringify((0, plan_report_js_1.planToJson)(analysis, { blocks, now }), null, 2));
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const color = args.flags["no-color"] !== true && !process.env["NO_COLOR"] && process.stdout.isTTY === true;
|
|
1616
|
+
if (analysis.dataState === "none") {
|
|
1617
|
+
// Sin ninguna muestra nunca, y la status line vio entradas sin rate_limits: API key.
|
|
1618
|
+
const apiKey = all.length === 0 && (0, statusline_cache_js_1.loadCache)(dbPath).noLimitsAt !== undefined;
|
|
1619
|
+
console.log((0, plan_report_js_1.renderNoData)(analysis, apiKey, color));
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
console.log((0, plan_report_js_1.renderPlanReport)(analysis, { color, now, blocks }));
|
|
1623
|
+
}
|
|
1624
|
+
finally {
|
|
1625
|
+
db.close();
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/** `estela plan set pro|max5|max20 [--since AAAA-MM-DD]`. */
|
|
1629
|
+
function cmdPlanSet(args, dbPath) {
|
|
1630
|
+
const id = args._[2] ?? "";
|
|
1631
|
+
const plan = (0, plan_prices_js_1.planById)(id);
|
|
1632
|
+
if (!plan)
|
|
1633
|
+
throw new UserError((0, index_js_1.tr) `Plan desconocido: "${id}". Usa pro, max5 o max20.`);
|
|
1634
|
+
const sinceText = str(args, "since");
|
|
1635
|
+
let since = new Date();
|
|
1636
|
+
if (sinceText !== undefined) {
|
|
1637
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(sinceText) || Number.isNaN(new Date(`${sinceText}T00:00:00`).getTime())) {
|
|
1638
|
+
throw new UserError((0, index_js_1.tr) `--since tiene que ser una fecha AAAA-MM-DD.`);
|
|
1639
|
+
}
|
|
1640
|
+
since = new Date(`${sinceText}T00:00:00`);
|
|
1641
|
+
}
|
|
1642
|
+
const db = (0, schema_js_1.openDatabase)(dbPath);
|
|
1643
|
+
try {
|
|
1644
|
+
store.setClaudePlan(db, plan.id, since);
|
|
1645
|
+
const d = since;
|
|
1646
|
+
const fecha = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
1647
|
+
console.log((0, index_js_1.tr) `Plan declarado: ${plan.name}, desde ${fecha}. El análisis solo usa lo guardado desde esa fecha.`);
|
|
1648
|
+
if (sinceText === undefined) {
|
|
1649
|
+
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`);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
finally {
|
|
1653
|
+
db.close();
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1547
1656
|
/**
|
|
1548
1657
|
* `estela sync enable --project <id>` — opt-in explícito, proyecto por
|
|
1549
1658
|
* proyecto. Sin esta fila, ese proyecto no sale de esta máquina nunca, sea
|
|
@@ -2134,6 +2243,7 @@ function cmdStatuslineInstall(args) {
|
|
|
2134
2243
|
}
|
|
2135
2244
|
if (plan.status === "already") {
|
|
2136
2245
|
console.log((0, index_js_1.tr) `La status line de Estela ya está instalada en ${path}.`);
|
|
2246
|
+
console.log((0, index_js_1.tr) `Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.`);
|
|
2137
2247
|
return;
|
|
2138
2248
|
}
|
|
2139
2249
|
if (plan.status === "conflict") {
|
|
@@ -2158,6 +2268,9 @@ function cmdStatuslineInstall(args) {
|
|
|
2158
2268
|
? (0, index_js_1.tr) `Status line de Estela instalada en lugar de: ${plan.previous ?? ""}`
|
|
2159
2269
|
: (0, index_js_1.tr) `Status line de Estela instalada en ${path}.`);
|
|
2160
2270
|
console.log((0, index_js_1.tr) `Se verá en la próxima sesión de Claude Code (o al reiniciar la actual).`);
|
|
2271
|
+
// Comprobado: la extensión de VS Code no ejecuta la status line. Sin este
|
|
2272
|
+
// aviso, quien trabaja ahí cree que está instalada y no la ve nunca.
|
|
2273
|
+
console.log((0, index_js_1.tr) `Solo en Claude Code en la terminal: la extensión de VS Code no enseña la status line.`);
|
|
2161
2274
|
if (!(0, statusline_install_js_1.estelaOnPath)()) {
|
|
2162
2275
|
console.log((0, index_js_1.tr) `\nAviso: "estela" no está en tu PATH, así que Claude Code no podrá ejecutarla.`);
|
|
2163
2276
|
console.log((0, index_js_1.tr) `Instálala de forma global: npm install -g estela`);
|
|
@@ -2462,7 +2575,10 @@ async function main() {
|
|
|
2462
2575
|
(0, client_js_1.setClientVersion)(VERSION);
|
|
2463
2576
|
const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
|
|
2464
2577
|
// `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
|
|
2465
|
-
const command = args._[0] === "unpublish" ? "unpublish"
|
|
2578
|
+
const command = args._[0] === "unpublish" ? "unpublish"
|
|
2579
|
+
// `estela plan set max5`: el plan va suelto, como el panel de unpublish.
|
|
2580
|
+
: args._[0] === "plan" && args._[1] === "set" ? "plan set"
|
|
2581
|
+
: args._.join(" ");
|
|
2466
2582
|
// Se llama aquí, no al final: un comando que falla a medias (por ejemplo
|
|
2467
2583
|
// "estela import" sin repositorios) debe avisar igual de que hay una
|
|
2468
2584
|
// versión nueva, no solo los que terminan bien.
|
|
@@ -2549,6 +2665,12 @@ async function main() {
|
|
|
2549
2665
|
case "unpublish":
|
|
2550
2666
|
await cmdUnpublish(args, dbPath);
|
|
2551
2667
|
break;
|
|
2668
|
+
case "plan":
|
|
2669
|
+
cmdPlan(args, dbPath);
|
|
2670
|
+
break;
|
|
2671
|
+
case "plan set":
|
|
2672
|
+
cmdPlanSet(args, dbPath);
|
|
2673
|
+
break;
|
|
2552
2674
|
case "export":
|
|
2553
2675
|
await cmdExport(args, dbPath);
|
|
2554
2676
|
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.
|
package/dist/db/store.d.ts
CHANGED
|
@@ -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");
|
|
@@ -982,4 +985,19 @@ function setAiConsent(db, projectId, requested, decision) {
|
|
|
982
985
|
requested = excluded.requested, decision = excluded.decision, decided_at = excluded.decided_at
|
|
983
986
|
`).run(projectId, requested ? 1 : 0, decision, decision === "pending" ? null : iso(new Date()));
|
|
984
987
|
}
|
|
988
|
+
/** El plan vigente: el de `since` más reciente. null si nunca se declaró. */
|
|
989
|
+
function getClaudePlan(db) {
|
|
990
|
+
const row = db.prepare("SELECT plan, since FROM claude_plans ORDER BY since DESC, id DESC LIMIT 1")
|
|
991
|
+
.get();
|
|
992
|
+
return row ? { plan: row.plan, since: new Date(row.since) } : null;
|
|
993
|
+
}
|
|
994
|
+
/** Añade un cambio de plan; los anteriores se conservan. */
|
|
995
|
+
function setClaudePlan(db, plan, since, at = new Date()) {
|
|
996
|
+
db.prepare("INSERT INTO claude_plans (plan, since, declared_at) VALUES (?, ?, ?)")
|
|
997
|
+
.run(plan, iso(since), iso(at));
|
|
998
|
+
}
|
|
999
|
+
function listClaudePlans(db) {
|
|
1000
|
+
return db.prepare("SELECT plan, since FROM claude_plans ORDER BY since, id").all()
|
|
1001
|
+
.map((r) => ({ plan: r.plan, since: new Date(r.since) }));
|
|
1002
|
+
}
|
|
985
1003
|
//# sourceMappingURL=store.js.map
|
package/dist/i18n/en.js
CHANGED
|
@@ -577,5 +577,52 @@ exports.EN = {
|
|
|
577
577
|
"No hay ningún panel publicado de \"{0}\". Míralos con: estela unpublish": "There's no published dashboard for \"{0}\". See them with: estela unpublish",
|
|
578
578
|
// ── ESTELA_OFFLINE ──
|
|
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
|
+
// ── statusline install: VS Code ──
|
|
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}",
|
|
580
627
|
};
|
|
581
628
|
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type ClaudePlan, type ClaudePlanId } from "../plan-prices.js";
|
|
2
|
+
import type { RateSample } from "../rate-history.js";
|
|
3
|
+
/**
|
|
4
|
+
* "¿El plan de Claude que pago es el que me conviene?"
|
|
5
|
+
*
|
|
6
|
+
* Funciones puras sobre el historial de la status line (rate-limits.jsonl):
|
|
7
|
+
* entran muestras, salen cifras, y cada cifra tiene su test.
|
|
8
|
+
*
|
|
9
|
+
* La regla de siempre, más estricta aquí que en ningún sitio: Estela no
|
|
10
|
+
* inventa ni estima el límite de ningún plan. Usa solo
|
|
11
|
+
*
|
|
12
|
+
* - los porcentajes que manda Claude Code, tal cual, y
|
|
13
|
+
* - el multiplicador por sesión de 5 h que publica Anthropic (plan-prices.ts).
|
|
14
|
+
*
|
|
15
|
+
* Por eso la comparación entre planes es solo de la ventana de 5 h: del
|
|
16
|
+
* límite semanal Anthropic no publica la relación entre planes, y no se
|
|
17
|
+
* calcula. Y nada de "te quedan X" ni de cuándo te vas a quedar sin cuota.
|
|
18
|
+
*/
|
|
19
|
+
/** Pico desde el que una ventana cuenta como tope. */
|
|
20
|
+
export declare const CAP_AT = 95;
|
|
21
|
+
export declare const MIN_DAYS = 7;
|
|
22
|
+
export declare const FULL_DAYS = 14;
|
|
23
|
+
export interface FiveHourWindow {
|
|
24
|
+
/** `resets_at` de la ventana (epoch en s). */
|
|
25
|
+
readonly reset: number;
|
|
26
|
+
readonly peak: number;
|
|
27
|
+
readonly samples: number;
|
|
28
|
+
readonly capped: boolean;
|
|
29
|
+
/** Desde la primera muestra ≥ 99 % hasta el reinicio (o hasta ahora). Cota superior, estimada. */
|
|
30
|
+
readonly stoppedMs: number;
|
|
31
|
+
}
|
|
32
|
+
export interface WeekWindow {
|
|
33
|
+
readonly reset: number;
|
|
34
|
+
readonly peak: number;
|
|
35
|
+
}
|
|
36
|
+
export interface Alternative {
|
|
37
|
+
readonly plan: ClaudePlan;
|
|
38
|
+
readonly direction: "lower" | "higher";
|
|
39
|
+
readonly p90Equivalent: number;
|
|
40
|
+
readonly maxEquivalent: number;
|
|
41
|
+
/** Ventanas cuyo pico equivalente pasa de 100: te habrían bloqueado. */
|
|
42
|
+
readonly blockedWindows: number;
|
|
43
|
+
/** Subiendo de plan: topes de ahora cuyo pico equivalente se queda por debajo del tope. */
|
|
44
|
+
readonly capsAvoided: number;
|
|
45
|
+
}
|
|
46
|
+
export type Recommendation = {
|
|
47
|
+
readonly kind: "tight";
|
|
48
|
+
readonly higher: ClaudePlan | null;
|
|
49
|
+
readonly capsAvoided: number | null;
|
|
50
|
+
} | {
|
|
51
|
+
readonly kind: "enough";
|
|
52
|
+
readonly plan: ClaudePlan;
|
|
53
|
+
readonly weeks: number;
|
|
54
|
+
} | {
|
|
55
|
+
readonly kind: "almost";
|
|
56
|
+
readonly plan: ClaudePlan;
|
|
57
|
+
readonly blocked: number;
|
|
58
|
+
} | {
|
|
59
|
+
readonly kind: "fits";
|
|
60
|
+
};
|
|
61
|
+
export type DataState = "none" | "insufficient" | "provisional" | "ok";
|
|
62
|
+
export interface PlanAnalysis {
|
|
63
|
+
readonly plan: ClaudePlan;
|
|
64
|
+
readonly since: Date;
|
|
65
|
+
readonly from: Date;
|
|
66
|
+
readonly to: Date;
|
|
67
|
+
readonly days: number;
|
|
68
|
+
readonly daysWithData: number;
|
|
69
|
+
readonly samples: number;
|
|
70
|
+
readonly dataState: DataState;
|
|
71
|
+
readonly fiveHour: {
|
|
72
|
+
readonly windows: readonly FiveHourWindow[];
|
|
73
|
+
readonly medianPeak: number | null;
|
|
74
|
+
readonly p90Peak: number | null;
|
|
75
|
+
readonly maxPeak: number | null;
|
|
76
|
+
readonly caps: number;
|
|
77
|
+
readonly stoppedMs: number;
|
|
78
|
+
};
|
|
79
|
+
readonly week: {
|
|
80
|
+
readonly weeks: readonly WeekWindow[];
|
|
81
|
+
readonly medianPeak: number | null;
|
|
82
|
+
readonly maxPeak: number | null;
|
|
83
|
+
readonly weeksAtOrAbove90: number;
|
|
84
|
+
};
|
|
85
|
+
readonly alternatives: readonly Alternative[];
|
|
86
|
+
/** null con menos de 7 días de datos. */
|
|
87
|
+
readonly recommendation: Recommendation | null;
|
|
88
|
+
}
|
|
89
|
+
export interface PlanAnalysisInput {
|
|
90
|
+
readonly samples: readonly RateSample[];
|
|
91
|
+
readonly plan: ClaudePlanId;
|
|
92
|
+
readonly since: Date;
|
|
93
|
+
readonly now: Date;
|
|
94
|
+
/** Período, en días hacia atrás desde `now`. */
|
|
95
|
+
readonly days: number;
|
|
96
|
+
/** Para contar los días con datos en tu zona horaria. Por defecto, la de esta máquina. */
|
|
97
|
+
readonly localDay?: (ms: number) => string;
|
|
98
|
+
}
|
|
99
|
+
export declare function analyzePlan(input: PlanAnalysisInput): PlanAnalysis;
|
|
100
|
+
/**
|
|
101
|
+
* Una ventana por `resets_at`, redondeado al minuto: dos sesiones pueden
|
|
102
|
+
* mandar el mismo reinicio con segundos distintos. Una muestra sin reinicio
|
|
103
|
+
* no se puede asignar a ninguna ventana y no cuenta.
|
|
104
|
+
*/
|
|
105
|
+
export declare function fiveHourWindows(samples: readonly RateSample[], now: Date): FiveHourWindow[];
|
|
106
|
+
export declare function weekWindows(samples: readonly RateSample[]): WeekWindow[];
|
|
107
|
+
export declare function median(values: readonly number[]): number | null;
|
|
108
|
+
/** Percentil 90 por rango más cercano: siempre es un valor observado. */
|
|
109
|
+
export declare function p90(values: readonly number[]): number | null;
|
|
110
|
+
//# sourceMappingURL=plan.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FULL_DAYS = exports.MIN_DAYS = exports.CAP_AT = void 0;
|
|
4
|
+
exports.analyzePlan = analyzePlan;
|
|
5
|
+
exports.fiveHourWindows = fiveHourWindows;
|
|
6
|
+
exports.weekWindows = weekWindows;
|
|
7
|
+
exports.median = median;
|
|
8
|
+
exports.p90 = p90;
|
|
9
|
+
const plan_prices_js_1 = require("../plan-prices.js");
|
|
10
|
+
/**
|
|
11
|
+
* "¿El plan de Claude que pago es el que me conviene?"
|
|
12
|
+
*
|
|
13
|
+
* Funciones puras sobre el historial de la status line (rate-limits.jsonl):
|
|
14
|
+
* entran muestras, salen cifras, y cada cifra tiene su test.
|
|
15
|
+
*
|
|
16
|
+
* La regla de siempre, más estricta aquí que en ningún sitio: Estela no
|
|
17
|
+
* inventa ni estima el límite de ningún plan. Usa solo
|
|
18
|
+
*
|
|
19
|
+
* - los porcentajes que manda Claude Code, tal cual, y
|
|
20
|
+
* - el multiplicador por sesión de 5 h que publica Anthropic (plan-prices.ts).
|
|
21
|
+
*
|
|
22
|
+
* Por eso la comparación entre planes es solo de la ventana de 5 h: del
|
|
23
|
+
* límite semanal Anthropic no publica la relación entre planes, y no se
|
|
24
|
+
* calcula. Y nada de "te quedan X" ni de cuándo te vas a quedar sin cuota.
|
|
25
|
+
*/
|
|
26
|
+
/** Pico desde el que una ventana cuenta como tope. */
|
|
27
|
+
exports.CAP_AT = 95;
|
|
28
|
+
/** Desde aquí se cuenta el tiempo parado tras un tope. */
|
|
29
|
+
const STOPPED_FROM = 99;
|
|
30
|
+
/** Una semana con este pico o más es "ir justo". */
|
|
31
|
+
const TIGHT_WEEK_AT = 90;
|
|
32
|
+
const TIGHT_CAPS_PER_WEEK = 2;
|
|
33
|
+
/** Un plan inferior "basta" si no habría bloqueado nunca y su p90 equivalente no pasa de aquí. */
|
|
34
|
+
const ENOUGH_P90 = 80;
|
|
35
|
+
const ALMOST_MAX_BLOCKS = 3;
|
|
36
|
+
exports.MIN_DAYS = 7;
|
|
37
|
+
exports.FULL_DAYS = 14;
|
|
38
|
+
function analyzePlan(input) {
|
|
39
|
+
const plan = (0, plan_prices_js_1.planById)(input.plan);
|
|
40
|
+
if (!plan)
|
|
41
|
+
throw new Error(`plan desconocido: ${input.plan}`);
|
|
42
|
+
const to = input.now;
|
|
43
|
+
const from = new Date(Math.max(input.since.getTime(), to.getTime() - input.days * 86_400_000));
|
|
44
|
+
const localDay = input.localDay ?? defaultLocalDay;
|
|
45
|
+
const samples = dedupe(input.samples)
|
|
46
|
+
.filter((s) => s.ts >= from.getTime() && s.ts <= to.getTime())
|
|
47
|
+
.sort((a, b) => a.ts - b.ts);
|
|
48
|
+
const daysWithData = new Set(samples.map((s) => localDay(s.ts))).size;
|
|
49
|
+
const windows = fiveHourWindows(samples, to);
|
|
50
|
+
const peaks = windows.map((w) => w.peak);
|
|
51
|
+
const weeks = weekWindows(samples);
|
|
52
|
+
const weekPeaks = weeks.map((w) => w.peak);
|
|
53
|
+
const caps = windows.filter((w) => w.capped).length;
|
|
54
|
+
const alternatives = plan_prices_js_1.CLAUDE_PLANS.filter((p) => p.id !== plan.id).map((target) => {
|
|
55
|
+
const factor = plan.sessionMultiplier / target.sessionMultiplier;
|
|
56
|
+
const eq = windows.map((w) => w.peak * factor);
|
|
57
|
+
return {
|
|
58
|
+
plan: target,
|
|
59
|
+
direction: target.sessionMultiplier < plan.sessionMultiplier ? "lower" : "higher",
|
|
60
|
+
p90Equivalent: p90(eq) ?? 0,
|
|
61
|
+
maxEquivalent: eq.length ? Math.max(...eq) : 0,
|
|
62
|
+
blockedWindows: eq.filter((e) => e > 100).length,
|
|
63
|
+
capsAvoided: factor < 1 ? windows.filter((w) => w.capped && w.peak * factor < exports.CAP_AT).length : 0,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
const dataState = samples.length === 0 ? "none"
|
|
67
|
+
: daysWithData < exports.MIN_DAYS ? "insufficient"
|
|
68
|
+
: daysWithData < exports.FULL_DAYS ? "provisional" : "ok";
|
|
69
|
+
const analysis = {
|
|
70
|
+
plan, since: input.since, from, to, days: input.days, daysWithData, samples: samples.length, dataState,
|
|
71
|
+
fiveHour: {
|
|
72
|
+
windows, medianPeak: median(peaks), p90Peak: p90(peaks), maxPeak: peaks.length ? Math.max(...peaks) : null,
|
|
73
|
+
caps, stoppedMs: windows.reduce((s, w) => s + w.stoppedMs, 0),
|
|
74
|
+
},
|
|
75
|
+
week: {
|
|
76
|
+
weeks, medianPeak: median(weekPeaks), maxPeak: weekPeaks.length ? Math.max(...weekPeaks) : null,
|
|
77
|
+
weeksAtOrAbove90: weekPeaks.filter((p) => p >= TIGHT_WEEK_AT).length,
|
|
78
|
+
},
|
|
79
|
+
alternatives,
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
...analysis,
|
|
83
|
+
recommendation: dataState === "provisional" || dataState === "ok" ? recommend(analysis) : null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Las cuatro reglas, en orden. Conservadoras: ante la duda, el plan actual. */
|
|
87
|
+
function recommend(a) {
|
|
88
|
+
const weeksOfData = a.daysWithData / 7;
|
|
89
|
+
const capsPerWeek = weeksOfData > 0 ? a.fiveHour.caps / weeksOfData : 0;
|
|
90
|
+
if (capsPerWeek >= TIGHT_CAPS_PER_WEEK || a.week.weeksAtOrAbove90 > 0) {
|
|
91
|
+
const higher = a.alternatives
|
|
92
|
+
.filter((x) => x.direction === "higher")
|
|
93
|
+
.sort((x, y) => x.plan.sessionMultiplier - y.plan.sessionMultiplier)[0];
|
|
94
|
+
return { kind: "tight", higher: higher?.plan ?? null, capsAvoided: higher ? higher.capsAvoided : null };
|
|
95
|
+
}
|
|
96
|
+
const lower = a.alternatives
|
|
97
|
+
.filter((x) => x.direction === "lower")
|
|
98
|
+
.sort((x, y) => x.plan.monthlyUsd - y.plan.monthlyUsd);
|
|
99
|
+
const enough = lower.find((x) => x.blockedWindows === 0 && x.p90Equivalent <= ENOUGH_P90);
|
|
100
|
+
if (enough)
|
|
101
|
+
return { kind: "enough", plan: enough.plan, weeks: Math.max(1, Math.round(weeksOfData)) };
|
|
102
|
+
const almost = lower.find((x) => x.blockedWindows >= 1 && x.blockedWindows <= ALMOST_MAX_BLOCKS);
|
|
103
|
+
if (almost)
|
|
104
|
+
return { kind: "almost", plan: almost.plan, blocked: almost.blockedWindows };
|
|
105
|
+
return { kind: "fits" };
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
/**
|
|
109
|
+
* Una ventana por `resets_at`, redondeado al minuto: dos sesiones pueden
|
|
110
|
+
* mandar el mismo reinicio con segundos distintos. Una muestra sin reinicio
|
|
111
|
+
* no se puede asignar a ninguna ventana y no cuenta.
|
|
112
|
+
*/
|
|
113
|
+
function fiveHourWindows(samples, now) {
|
|
114
|
+
const groups = new Map();
|
|
115
|
+
for (const s of samples) {
|
|
116
|
+
if (s.w5 === undefined || s.w5_reset === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
const key = Math.round(s.w5_reset / 60);
|
|
119
|
+
const g = groups.get(key);
|
|
120
|
+
if (g)
|
|
121
|
+
g.push(s);
|
|
122
|
+
else
|
|
123
|
+
groups.set(key, [s]);
|
|
124
|
+
}
|
|
125
|
+
return [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([key, g]) => {
|
|
126
|
+
const peak = Math.max(...g.map((s) => s.w5));
|
|
127
|
+
const capped = peak >= exports.CAP_AT;
|
|
128
|
+
const first99 = g.filter((s) => s.w5 >= STOPPED_FROM).map((s) => s.ts).sort((a, b) => a - b)[0];
|
|
129
|
+
const end = Math.min(key * 60_000, now.getTime());
|
|
130
|
+
return {
|
|
131
|
+
reset: key * 60, peak, samples: g.length, capped,
|
|
132
|
+
stoppedMs: capped && first99 !== undefined ? Math.max(0, end - first99) : 0,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function weekWindows(samples) {
|
|
137
|
+
const peaks = new Map();
|
|
138
|
+
for (const s of samples) {
|
|
139
|
+
if (s.w7 === undefined || s.w7_reset === undefined)
|
|
140
|
+
continue;
|
|
141
|
+
const key = Math.round(s.w7_reset / 60);
|
|
142
|
+
peaks.set(key, Math.max(peaks.get(key) ?? -Infinity, s.w7));
|
|
143
|
+
}
|
|
144
|
+
return [...peaks.entries()].sort((a, b) => a[0] - b[0]).map(([key, peak]) => ({ reset: key * 60, peak }));
|
|
145
|
+
}
|
|
146
|
+
/** Dos sesiones a la vez pueden escribir la misma muestra. */
|
|
147
|
+
function dedupe(samples) {
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
return samples.filter((s) => {
|
|
150
|
+
const k = `${s.ts}|${s.w5}|${s.w5_reset}|${s.w7}|${s.w7_reset}`;
|
|
151
|
+
if (seen.has(k))
|
|
152
|
+
return false;
|
|
153
|
+
seen.add(k);
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
function median(values) {
|
|
158
|
+
if (values.length === 0)
|
|
159
|
+
return null;
|
|
160
|
+
const v = [...values].sort((a, b) => a - b);
|
|
161
|
+
const mid = Math.floor(v.length / 2);
|
|
162
|
+
return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
|
|
163
|
+
}
|
|
164
|
+
/** Percentil 90 por rango más cercano: siempre es un valor observado. */
|
|
165
|
+
function p90(values) {
|
|
166
|
+
if (values.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const v = [...values].sort((a, b) => a - b);
|
|
169
|
+
return v[Math.ceil(0.9 * v.length) - 1];
|
|
170
|
+
}
|
|
171
|
+
function defaultLocalDay(ms) {
|
|
172
|
+
const d = new Date(ms);
|
|
173
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=plan.js.map
|