estela 0.4.0 → 0.4.1

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
@@ -274,7 +274,7 @@ estela delivery --project shop --milestone "Sprint 4" --json
274
274
  Three blocks, each with its hours and AI cost: what was planned and delivered in
275
275
  the period (an issue closed as completed, or closed by a merged PR), what was
276
276
  planned and not delivered, and what was delivered without being planned. Planned
277
- means it carries a GitHub label or milestone. Each line's cost is that of the
277
+ means it carries a GitHub or GitLab label or milestone. Each line's cost is that of the
278
278
  hours linked to it: the PR's branch (squash merges included) and its number in
279
279
  branches, commits or set by hand.
280
280
 
@@ -284,6 +284,15 @@ and `--offline` avoids it. What it reads is saved, so the report works offline a
284
284
  `--undelivered` knows which branches were squash-merged. Without `gh` the command
285
285
  says so and shows what git says was delivered.
286
286
 
287
+ GitLab works the same way, with `glab`, the official GitLab CLI (self-hosted
288
+ servers too, because `glab` works out the server from the repo's remote):
289
+ `estela project tracker --project shop --system gitlab --plan-label planned`.
290
+ Merge requests are shown as `!45`, separately from issue `#45`. Two differences
291
+ in the figures: GitLab doesn't say whether an issue was closed done or closed
292
+ won't-do, so a closed issue counts as delivered (unless it was moved to another
293
+ project); and which issues an MR closes is read from its description (`Closes #12`,
294
+ `Fixes`, `Resolves`, `Implements`), the same words GitLab uses to close them.
295
+
287
296
  ### What each client asked for
288
297
 
289
298
  ```sh
package/dist/cli.js CHANGED
@@ -87,6 +87,7 @@ const git_js_1 = require("./watchers/git.js");
87
87
  const identity_js_1 = require("./watchers/identity.js");
88
88
  const project_of_js_1 = require("./project-of.js");
89
89
  const github_js_1 = require("./delivery/github.js");
90
+ const gitlab_js_1 = require("./delivery/gitlab.js");
90
91
  const report_js_1 = require("./delivery/report.js");
91
92
  const clients_js_1 = require("./delivery/clients.js");
92
93
  const index_js_1 = require("./i18n/index.js");
@@ -143,9 +144,10 @@ estela — registro de horas para desarrollo asistido por IA
143
144
  Planificado frente a entregado en un periodo (por defecto, este mes),
144
145
  con el coste de IA de cada parte: lo del plan que se entregó, lo que
145
146
  no, y lo entregado que no estaba en el plan. Lee las issues y los PR
146
- de GitHub con "gh", solo en un proyecto con --system github; es la
147
- única llamada fuera de tu máquina, y --offline la evita. Sin gh usa
148
- lo leído la última vez o, si nunca se leyó, lo entregado según git.
147
+ de GitHub con "gh" (--system github) o las issues y las MR de GitLab
148
+ con "glab" (--system gitlab); es la única llamada fuera de tu
149
+ máquina, y --offline la evita. Sin ellos usa lo leído la última vez
150
+ o, si nunca se leyó, lo entregado según git.
149
151
  estela delivery --by client [--project <id>] [--month YYYY-MM | --from …]
150
152
  El gasto de IA del periodo en peticiones de cada cliente frente al
151
153
  resto: una issue o un PR con la etiqueta "cliente:<nombre>" es una
@@ -402,10 +404,10 @@ estela — time tracking for AI-assisted development
402
404
  Planned versus delivered in a period (this month by default), with
403
405
  the AI cost of each part: what was planned and delivered, what
404
406
  wasn't, and what was delivered without being planned. It reads
405
- GitHub issues and PRs with "gh", only for a project with --system
406
- github; it's the only call off your machine, and --offline avoids
407
- it. Without gh it uses what was read last time or, if never read,
408
- what git says was delivered.
407
+ GitHub issues and PRs with "gh" (--system github) or GitLab issues
408
+ and MRs with "glab" (--system gitlab); it's the only call off your
409
+ machine, and --offline avoids it. Without them it uses what was read
410
+ last time or, if never read, what git says was delivered.
409
411
  estela delivery --by client [--project <id>] [--month YYYY-MM | --from …]
410
412
  The period's AI spend on each client's requests versus the rest: an
411
413
  issue or PR labelled "cliente:<name>" is that client's request, and a
@@ -2404,8 +2406,8 @@ function shareDenominators(db) {
2404
2406
  * `estela delivery` — planificado frente a entregado en un periodo, con el
2405
2407
  * coste de IA de cada parte. Ver delivery/report.ts.
2406
2408
  *
2407
- * La única llamada fuera de esta máquina de todo Estela sin cuenta: `gh`, y
2408
- * solo en un proyecto con GitHub configurado como gestor. Sin `gh`, o con
2409
+ * La única llamada fuera de esta máquina de todo Estela sin cuenta: `gh` o
2410
+ * `glab`, y solo en un proyecto con GitHub o GitLab como gestor. Sin ellos, o con
2409
2411
  * --offline, se usa lo leído la última vez; si nunca se leyó, lo entregado
2410
2412
  * según git.
2411
2413
  */
@@ -2453,7 +2455,7 @@ async function cmdDelivery(args, dbPath) {
2453
2455
  }
2454
2456
  }
2455
2457
  /**
2456
- * Las issues y los PRs de un proyecto: de GitHub con gh si se puede; si no, lo
2458
+ * Las issues y los PRs de un proyecto: de GitHub con gh o de GitLab con glab si se puede; si no, lo
2457
2459
  * guardado la última vez; y si nunca se leyó, lo entregado según git.
2458
2460
  */
2459
2461
  async function loadTrackerItems(db, project, args, query) {
@@ -2461,34 +2463,37 @@ async function loadTrackerItems(db, project, args, query) {
2461
2463
  let items = null;
2462
2464
  let origin = "";
2463
2465
  const repo = project.repoPaths.find((p) => (0, node_fs_1.existsSync)(p));
2464
- const github = project.tracker?.system === "github";
2465
- if (!github) {
2466
- notes.push((0, index_js_1.tr) `Este proyecto no tiene GitHub como gestor, así que no se lee el plan. Para leerlo:`);
2467
- notes.push((0, index_js_1.tr) ` estela project tracker --project ${project.id} --system github --plan-label <etiqueta>`);
2466
+ const system = project.tracker?.system;
2467
+ const remote = system === "github" || system === "gitlab" ? system : null;
2468
+ const name = remote === "gitlab" ? "GitLab" : "GitHub";
2469
+ const cli = remote === "gitlab" ? "glab" : "gh";
2470
+ if (!remote) {
2471
+ notes.push((0, index_js_1.tr) `Este proyecto no tiene GitHub ni GitLab como gestor, así que no se lee el plan. Para leerlo:`);
2472
+ notes.push((0, index_js_1.tr) ` estela project tracker --project ${project.id} --system github|gitlab --plan-label <etiqueta>`);
2468
2473
  }
2469
2474
  else if (!repo) {
2470
- notes.push((0, index_js_1.tr) `La carpeta del repo de este proyecto ya no existe: gh no tiene dónde mirar.`);
2475
+ notes.push((0, index_js_1.tr) `La carpeta del repo de este proyecto ya no existe: ${cli} no tiene dónde mirar.`);
2471
2476
  }
2472
2477
  else if ("offline" in args.flags) {
2473
- notes.push((0, index_js_1.tr) `--offline: no se consulta GitHub.`);
2478
+ notes.push((0, index_js_1.tr) `--offline: no se consulta ${name}.`);
2474
2479
  }
2475
2480
  else {
2476
- const source = new github_js_1.GitHubPlanSource(repo);
2481
+ const source = remote === "gitlab" ? new gitlab_js_1.GitLabPlanSource(repo) : new github_js_1.GitHubPlanSource(repo);
2477
2482
  const available = await source.availability();
2478
2483
  if (available.ok) {
2479
2484
  items = await source.fetch(query);
2480
2485
  store.saveTrackerItems(db, project.id, items);
2481
- origin = (0, index_js_1.tr) `GitHub (gh), leído ahora`;
2486
+ origin = (0, index_js_1.tr) `${name} (${cli}), leído ahora`;
2482
2487
  }
2483
2488
  else {
2484
- notes.push(unavailableText(available));
2489
+ notes.push(unavailableText(available, remote));
2485
2490
  }
2486
2491
  }
2487
- if (!items && github) {
2492
+ if (!items && remote) {
2488
2493
  const cached = store.listTrackerItems(db, project.id);
2489
2494
  if (cached.items.length && cached.fetchedAt) {
2490
2495
  items = cached.items;
2491
- origin = (0, index_js_1.tr) `GitHub, lo guardado el ${(0, shared_1.localDate)(cached.fetchedAt)} (sin conexión)`;
2496
+ origin = (0, index_js_1.tr) `${name}, lo guardado el ${(0, shared_1.localDate)(cached.fetchedAt)} (sin conexión)`;
2492
2497
  }
2493
2498
  }
2494
2499
  if (items)
@@ -2500,7 +2505,7 @@ async function loadTrackerItems(db, project, args, query) {
2500
2505
  * `estela summary` — el informe para finanzas: gasto, clientes, entregas y lo
2501
2506
  * que no llegó a entregarse, en Markdown, CSV o JSON. Ver export/finance.ts.
2502
2507
  *
2503
- * No llama a nada fuera de la máquina: de GitHub usa lo que ya leyó
2508
+ * No llama a nada fuera de la máquina: de GitHub o GitLab usa lo que ya leyó
2504
2509
  * `estela delivery`, y si no hay nada, lo que dice git.
2505
2510
  */
2506
2511
  async function cmdSummary(args, dbPath) {
@@ -2595,13 +2600,15 @@ async function cmdSummary(args, dbPath) {
2595
2600
  for (const p of projects)
2596
2601
  if ((entriesOf.get(p.id) ?? []).some((e) => e.agents.includes("copilot")))
2597
2602
  unpriced = true;
2598
- // Entregas y peticiones de clientes: lo guardado de GitHub, o lo que dice git.
2603
+ // Entregas y peticiones de clientes: lo guardado de GitHub o GitLab, o lo que dice git.
2599
2604
  const deliveries = [];
2600
2605
  const merges = store.listMerges(db);
2601
2606
  for (const p of projects) {
2602
2607
  const project = store.getProject(db, p.id);
2603
2608
  const cached = store.listTrackerItems(db, p.id);
2609
+ // Lo guardado es de un gestor u otro según sus claves (github:12, gitlab:12).
2604
2610
  const fromGitHub = cached.items.length > 0;
2611
+ const tracker = cached.items[0]?.key.startsWith("gitlab:") ? "gitlab" : "github";
2605
2612
  const items = fromGitHub ? cached.items : deliveredFromGit(db, project.repoPaths);
2606
2613
  const planLabel = fromGitHub ? project.tracker?.planLabel : undefined;
2607
2614
  const report = (0, report_js_1.deliveryReport)({ items, plan: planLabel ? { label: planLabel } : null, from, to,
@@ -2626,7 +2633,7 @@ async function cmdSummary(args, dbPath) {
2626
2633
  if (!lines.length && !report.planned)
2627
2634
  continue;
2628
2635
  deliveries.push({
2629
- projectName: p.name, clientName: p.clientName, source: fromGitHub ? "github" : "git",
2636
+ projectName: p.name, clientName: p.clientName, source: fromGitHub ? tracker : "git",
2630
2637
  fetchedAt: cached.fetchedAt, delivered: lines.map((l) => ({ label: itemLabel(l.item), cost: l })),
2631
2638
  plan: report.planned && planLabel ? {
2632
2639
  label: planLabel, delivered: report.planned.delivered.lines.length,
@@ -2697,7 +2704,7 @@ async function deliveryByClient(db, args, project, title, from, to) {
2697
2704
  const prefix = project.tracker?.clientLabel ?? DEFAULT_CLIENT_LABEL;
2698
2705
  const { items, origin, notes, fromGit } = await loadTrackerItems(db, project, args, { from, to, plan: null, clientLabelPrefix: prefix });
2699
2706
  if (fromGit)
2700
- notes.push((0, index_js_1.tr) `Sin GitHub no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.`);
2707
+ notes.push((0, index_js_1.tr) `Sin GitHub ni GitLab no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.`);
2701
2708
  const requesterOf = (name) => {
2702
2709
  const clientId = store.clientForLabel(db, name);
2703
2710
  const c = clientId ? store.getClient(db, clientId) : null;
@@ -2769,7 +2776,14 @@ function deliveryPeriod(args) {
2769
2776
  throw new UserError((0, index_js_1.tr) `Con --to hace falta --from.`);
2770
2777
  return { from: `${today.slice(0, 7)}-01`, to: `${today.slice(0, 7)}-31` };
2771
2778
  }
2772
- function unavailableText(a) {
2779
+ function unavailableText(a, system) {
2780
+ if (system === "gitlab") {
2781
+ switch (a.reason) {
2782
+ case "missing": return (0, index_js_1.tr) `glab no está instalado: sin él no se puede leer el plan de GitLab. Instálalo desde https://gitlab.com/gitlab-org/cli y ejecuta: glab auth login`;
2783
+ case "auth": return (0, index_js_1.tr) `glab no tiene sesión iniciada. Ejecuta: glab auth login`;
2784
+ case "repo": return (0, index_js_1.tr) `glab no reconoce un proyecto de GitLab en la carpeta del proyecto (${a.detail}).`;
2785
+ }
2786
+ }
2773
2787
  switch (a.reason) {
2774
2788
  case "missing": return (0, index_js_1.tr) `gh no está instalado: sin él no se puede leer el plan de GitHub. Instálalo desde https://cli.github.com y ejecuta: gh auth login`;
2775
2789
  case "auth": return (0, index_js_1.tr) `gh no tiene sesión iniciada. Ejecuta: gh auth login`;
@@ -2777,20 +2791,20 @@ function unavailableText(a) {
2777
2791
  }
2778
2792
  }
2779
2793
  /**
2780
- * Lo entregado que se sabe sin GitHub: los PR fusionados con merge (su
2781
- * mensaje dice el número y la rama) y los squash con "(#N)" al final. No hay
2782
- * plan: eso solo lo sabe el gestor.
2794
+ * Lo entregado que se sabe sin GitHub ni GitLab: los PR y las MR fusionados
2795
+ * con merge (su mensaje dice el número y la rama) y los squash de GitHub con
2796
+ * "(#N)" al final. No hay plan: eso solo lo sabe el gestor.
2783
2797
  */
2784
2798
  function deliveredFromGit(db, repoPaths) {
2785
2799
  const inProject = (path) => repoPaths.some((r) => (0, paths_js_1.isSameOrInside)(path, r) || (0, paths_js_1.isSameOrInside)(r, path));
2786
2800
  const items = new Map();
2787
2801
  const pr = (key, title, at, branch) => ({
2788
- key, kind: "pr", number: Number(key.split(":")[1]), title, state: "merged", completed: true,
2802
+ key, kind: "pr", number: Number(key.split(":")[1].replace("!", "")), title, state: "merged", completed: true,
2789
2803
  closedAt: at, branch, closes: [], labels: [], milestone: null, url: "",
2790
2804
  });
2791
2805
  for (const m of store.listMerges(db)) {
2792
2806
  // El mensaje del merge solo repite el número: la rama dice más.
2793
- if (m.ref?.startsWith("github:") && inProject(m.repoPath)) {
2807
+ if (m.ref && /^(github|gitlab):/.test(m.ref) && inProject(m.repoPath)) {
2794
2808
  items.set(m.ref, pr(m.ref, m.sourceBranch ?? m.subject, m.at, m.sourceBranch));
2795
2809
  }
2796
2810
  }
@@ -2811,7 +2825,15 @@ function planText(plan) {
2811
2825
  return plan.label ? (0, index_js_1.tr) `etiqueta "${plan.label}"` : (0, index_js_1.tr) `milestone "${plan.milestone}"`;
2812
2826
  }
2813
2827
  function itemLabel(i) {
2814
- return `${i.kind === "pr" ? (0, index_js_1.tr) `PR #${i.number}` : `#${i.number}`} ${i.title}`;
2828
+ return `${i.kind === "pr" ? prLabel(i) : `#${i.number}`} ${i.title}`;
2829
+ }
2830
+ /** Una clave de merge: `PR #10` en GitHub, `MR !10` en GitLab. */
2831
+ function refLabel(key) {
2832
+ return key.startsWith("gitlab:!") ? (0, index_js_1.tr) `MR ${(0, shared_1.formatWorkItem)(key)}` : (0, index_js_1.tr) `PR ${(0, shared_1.formatWorkItem)(key)}`;
2833
+ }
2834
+ /** `PR #10` en GitHub, `MR !10` en GitLab. */
2835
+ function prLabel(i) {
2836
+ return i.key.startsWith("gitlab:") ? (0, index_js_1.tr) `MR !${i.number}` : (0, index_js_1.tr) `PR #${i.number}`;
2815
2837
  }
2816
2838
  function printDelivery(r, v) {
2817
2839
  console.log((0, index_js_1.tr) `Planificado frente a entregado · ${v.title} · ${v.from} → ${v.to}`);
@@ -2839,7 +2861,7 @@ function printDelivery(r, v) {
2839
2861
  detail.push(l.item.state === "open" ? (0, index_js_1.tr) `abierto` : (0, index_js_1.tr) `cerrado sin fusionar`);
2840
2862
  }
2841
2863
  else if (l.deliveredBy.length) {
2842
- detail.push((0, index_js_1.tr) `entregada por ${l.deliveredBy.map((p) => (0, index_js_1.tr) `PR #${p.number}`).join(", ")}`);
2864
+ detail.push((0, index_js_1.tr) `entregada por ${l.deliveredBy.map(prLabel).join(", ")}`);
2843
2865
  }
2844
2866
  else if (l.item.completed && when) {
2845
2867
  detail.push((0, index_js_1.tr) `cerrada ${when}`);
@@ -2995,7 +3017,7 @@ async function collectUndelivered(db, projects, staleDays, from, to) {
2995
3017
  merged.add(m.sourceBranch);
2996
3018
  facts.set(root, { merged, exists: await (0, git_js_1.branchNames)(root) });
2997
3019
  }
2998
- // Lo leído de GitHub con estela delivery: la rama de un PR fusionado está
3020
+ // Lo leído de GitHub o GitLab con estela delivery: la rama de un PR fusionado está
2999
3021
  // entregada, aunque fuera con squash y git no lo sepa.
3000
3022
  for (const p of projects) {
3001
3023
  const prRoots = new Set(turns.filter((t) => t.projectId === p.id).map((t) => t.root));
@@ -3139,7 +3161,7 @@ function unitLabel(u, by) {
3139
3161
  if (by === "branch")
3140
3162
  return u.key;
3141
3163
  const fromMerge = u.basis.length === 1 && u.basis[0] === "merge";
3142
- return u.items.map((i) => (fromMerge ? (0, index_js_1.tr) `PR ${(0, shared_1.formatWorkItem)(i)}` : (0, shared_1.formatWorkItem)(i))).join(", ");
3164
+ return u.items.map((i) => (fromMerge ? refLabel(i) : (0, shared_1.formatWorkItem)(i))).join(", ");
3143
3165
  }
3144
3166
  function printSpend(reports, by, args, hasSubs) {
3145
3167
  const title = by === "branch" ? (0, index_js_1.tr) `Coste de IA por rama` : (0, index_js_1.tr) `Coste de IA por ticket o PR`;
@@ -3164,7 +3186,7 @@ function printSpend(reports, by, args, hasSubs) {
3164
3186
  if (u.items.length)
3165
3187
  detail.push(u.items.map(shared_1.formatWorkItem).join(", "));
3166
3188
  if (u.prs.length)
3167
- detail.push(u.prs.map((p) => (0, index_js_1.tr) `PR ${(0, shared_1.formatWorkItem)(p)}`).join(", "));
3189
+ detail.push(u.prs.map(refLabel).join(", "));
3168
3190
  }
3169
3191
  else if (u.branches.length) {
3170
3192
  detail.push((0, index_js_1.tr) `ramas: ${u.branches.join(", ")}`);
package/dist/db/schema.js CHANGED
@@ -161,7 +161,7 @@ CREATE TABLE IF NOT EXISTS merges (
161
161
  at TEXT NOT NULL,
162
162
  -- Rama integrada, tal como la escribe el mensaje. NULL si no lo dice.
163
163
  source_branch TEXT,
164
- -- PR o merge request (github:12, gitlab:45). NULL en un merge local.
164
+ -- PR o merge request (github:12, gitlab:!45). NULL en un merge local.
165
165
  ref TEXT,
166
166
  subject TEXT NOT NULL,
167
167
  PRIMARY KEY (repo_path, hash)
@@ -337,6 +337,16 @@ CREATE TABLE IF NOT EXISTS claude_plans (
337
337
  * empezar no es una opción de mantenimiento.
338
338
  */
339
339
  const MIGRATIONS = [
340
+ {
341
+ version: 21,
342
+ describe: "las merge requests de GitLab como gitlab:!45, aparte de la issue #45",
343
+ run: (db) => {
344
+ // En GitLab la issue #45 y la MR !45 son cosas distintas con el mismo
345
+ // número. Solo se tocan los merges, que se leen de git: ninguna imputación.
346
+ db.exec(`UPDATE merges SET ref = 'gitlab:!' || substr(ref, 8)
347
+ WHERE ref LIKE 'gitlab:%' AND ref NOT LIKE 'gitlab:!%'`);
348
+ },
349
+ },
340
350
  {
341
351
  version: 20,
342
352
  describe: "alias de cliente para las etiquetas cliente:<nombre>",
@@ -0,0 +1,38 @@
1
+ import type { Availability, FetchQuery, PlanSource, TrackerItem } from "./source.js";
2
+ /**
3
+ * GitLab, a través de la CLI `glab` que ya tienes instalada y con sesión.
4
+ *
5
+ * Como con GitHub: sin token propio, y en la carpeta del repo, así que `glab`
6
+ * deduce el proyecto y el servidor (también uno autoalojado) por el remoto.
7
+ * Se usa `glab api` y no `glab mr list`: la API devuelve siempre el mismo JSON
8
+ * y la paginación queda en manos de Estela.
9
+ *
10
+ * Dos diferencias con GitHub que se notan en las cifras:
11
+ * - Las issues (`#12`) y las merge requests (`!12`) se numeran aparte, así
12
+ * que una MR es `gitlab:!12` y una issue `gitlab:12`.
13
+ * - GitLab no dice si una issue se cerró hecha o sin hacer. Cerrada cuenta
14
+ * como entregada, salvo que se moviera a otro proyecto.
15
+ * - Qué issues cierra una MR se lee de su descripción ("Closes #12"), con las
16
+ * mismas palabras con las que GitLab las cierra. No se pregunta MR a MR.
17
+ */
18
+ /** Ejecuta `glab` con esos argumentos en esa carpeta y devuelve su salida. Inyectable en los tests. */
19
+ export type GlabRunner = (args: readonly string[], cwd: string) => Promise<string>;
20
+ export declare const runGlab: GlabRunner;
21
+ export declare class GitLabPlanSource implements PlanSource {
22
+ private readonly cwd;
23
+ private readonly glab;
24
+ readonly system: "gitlab";
25
+ constructor(cwd: string, glab?: GlabRunner);
26
+ availability(): Promise<Availability>;
27
+ fetch(query: FetchQuery): Promise<TrackerItem[]>;
28
+ private list;
29
+ /** Todas las páginas de `projects/:fullpath/<path>`. */
30
+ private pages;
31
+ }
32
+ /**
33
+ * Las issues de este proyecto que cierra una descripción, con el patrón por
34
+ * defecto de GitLab: "Closes #12", "Fixes #12, #13", "Resolves issue #12 and #14",
35
+ * "Implements #12". Las de otro proyecto (`grupo/otro#12`) no.
36
+ */
37
+ export declare function closingIssues(description: string): number[];
38
+ //# sourceMappingURL=gitlab.d.ts.map
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GitLabPlanSource = exports.runGlab = void 0;
4
+ exports.closingIssues = closingIssues;
5
+ const node_child_process_1 = require("node:child_process");
6
+ const node_util_1 = require("node:util");
7
+ const shared_1 = require("@estela/shared");
8
+ const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
9
+ const runGlab = async (args, cwd) => {
10
+ const { stdout } = await run("glab", [...args], { cwd, timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
11
+ return stdout;
12
+ };
13
+ exports.runGlab = runGlab;
14
+ const PER_PAGE = 100;
15
+ /** Tope de páginas por lista: 10.000 elementos. Más es otro tipo de proyecto. */
16
+ const MAX_PAGES = 100;
17
+ class GitLabPlanSource {
18
+ cwd;
19
+ glab;
20
+ system = "gitlab";
21
+ constructor(cwd, glab = exports.runGlab) {
22
+ this.cwd = cwd;
23
+ this.glab = glab;
24
+ }
25
+ async availability() {
26
+ try {
27
+ await this.glab(["--version"], this.cwd);
28
+ }
29
+ catch (error) {
30
+ return { ok: false, reason: "missing", detail: errorText(error) };
31
+ }
32
+ try {
33
+ await this.glab(["auth", "status"], this.cwd);
34
+ }
35
+ catch (error) {
36
+ return { ok: false, reason: "auth", detail: errorText(error) };
37
+ }
38
+ try {
39
+ await this.glab(["api", "projects/:fullpath"], this.cwd);
40
+ }
41
+ catch (error) {
42
+ return { ok: false, reason: "repo", detail: errorText(error) };
43
+ }
44
+ return { ok: true };
45
+ }
46
+ async fetch(query) {
47
+ // GitLab no filtra por fecha de merge o de cierre: se piden las tocadas
48
+ // desde el día antes (cerrar o fusionar también es tocar) y se filtra aquí.
49
+ const since = new Date(new Date(`${query.from}T00:00:00Z`).getTime() - 86_400_000).toISOString();
50
+ const inPeriod = (i) => !!i.closedAt && (0, shared_1.localDate)(i.closedAt) >= query.from && (0, shared_1.localDate)(i.closedAt) <= query.to;
51
+ const lists = [
52
+ this.list("merge_requests", { state: "merged", updated_after: since }).then((l) => l.filter(inPeriod)),
53
+ this.list("issues", { state: "closed", updated_after: since }).then((l) => l.filter(inPeriod)),
54
+ ];
55
+ const plan = query.plan;
56
+ if (plan?.label) {
57
+ lists.push(this.list("issues", { labels: plan.label }));
58
+ lists.push(this.list("merge_requests", { labels: plan.label }));
59
+ }
60
+ else if (plan?.milestone) {
61
+ lists.push(this.list("issues", { milestone: plan.milestone }));
62
+ lists.push(this.list("merge_requests", { milestone: plan.milestone }));
63
+ }
64
+ const prefix = query.clientLabelPrefix?.toLowerCase();
65
+ if (prefix) {
66
+ // Las MR en curso: una sin etiqueta que cierra la issue de un cliente
67
+ // también es trabajo para ese cliente.
68
+ lists.push(this.list("merge_requests", { state: "opened" }));
69
+ // GitLab no busca etiquetas por prefijo: primero cuáles existen.
70
+ const labels = (await this.pages("labels", {}))
71
+ .map((l) => l.name).filter((n) => n.toLowerCase().startsWith(prefix));
72
+ for (const label of labels) {
73
+ lists.push(this.list("issues", { labels: label }));
74
+ lists.push(this.list("merge_requests", { labels: label }));
75
+ }
76
+ }
77
+ // Una issue o una MR puede salir en dos listas: se queda una vez.
78
+ const items = new Map();
79
+ for (const list of await Promise.all(lists))
80
+ for (const item of list)
81
+ items.set(`${item.kind}:${item.key}`, item);
82
+ return [...items.values()];
83
+ }
84
+ async list(kind, params) {
85
+ const rows = await this.pages(kind, params);
86
+ return rows.map((r) => (kind === "issues" ? normalizeIssue(r) : normalizeMr(r)));
87
+ }
88
+ /** Todas las páginas de `projects/:fullpath/<path>`. */
89
+ async pages(path, params) {
90
+ const out = [];
91
+ for (let page = 1; page <= MAX_PAGES; page++) {
92
+ const qs = new URLSearchParams({ ...params, per_page: String(PER_PAGE), page: String(page) });
93
+ const rows = JSON.parse((await this.glab(["api", `projects/:fullpath/${path}?${qs}`], this.cwd)) || "[]");
94
+ out.push(...rows);
95
+ if (rows.length < PER_PAGE)
96
+ break;
97
+ }
98
+ return out;
99
+ }
100
+ }
101
+ exports.GitLabPlanSource = GitLabPlanSource;
102
+ const date = (s) => (s ? new Date(s) : null);
103
+ const labelsOf = (raw) => (raw.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
104
+ function normalizeMr(raw) {
105
+ const merged = raw.state === "merged" || !!raw.merged_at;
106
+ return {
107
+ key: `gitlab:!${raw.iid}`, kind: "pr", number: raw.iid, title: raw.title,
108
+ state: merged ? "merged" : raw.state === "opened" ? "open" : "closed",
109
+ completed: merged, closedAt: date(raw.merged_at) ?? date(raw.closed_at),
110
+ branch: raw.source_branch || null,
111
+ closes: closingIssues(raw.description ?? "").map((n) => `gitlab:${n}`),
112
+ labels: labelsOf(raw), milestone: raw.milestone?.title ?? null, url: raw.web_url,
113
+ };
114
+ }
115
+ function normalizeIssue(raw) {
116
+ const closed = raw.state === "closed";
117
+ return {
118
+ key: `gitlab:${raw.iid}`, kind: "issue", number: raw.iid, title: raw.title,
119
+ state: closed ? "closed" : "open",
120
+ // GitLab no distingue hecha de "no se hará". Movida a otro proyecto, no es de aquí.
121
+ completed: closed && !raw.moved_to_id,
122
+ closedAt: date(raw.closed_at), branch: null, closes: [],
123
+ labels: labelsOf(raw), milestone: raw.milestone?.title ?? null, url: raw.web_url,
124
+ };
125
+ }
126
+ /**
127
+ * Las issues de este proyecto que cierra una descripción, con el patrón por
128
+ * defecto de GitLab: "Closes #12", "Fixes #12, #13", "Resolves issue #12 and #14",
129
+ * "Implements #12". Las de otro proyecto (`grupo/otro#12`) no.
130
+ */
131
+ function closingIssues(description) {
132
+ const out = new Set();
133
+ const verbs = /\b(?:clos(?:e[sd]?|ing)|fix(?:e[sd]|ing)?|resolv(?:e[sd]?|ing)|implement(?:s|ed|ing)?):?((?:\s*,?\s*(?:and\s+)?(?:issues?\s+)?#\d+\b)+)/gi;
134
+ for (const m of description.matchAll(verbs)) {
135
+ for (const n of m[1].matchAll(/(?<![\w/])#(\d+)/g))
136
+ out.add(Number(n[1]));
137
+ }
138
+ return [...out];
139
+ }
140
+ function errorText(error) {
141
+ const e = error;
142
+ if (e.code === "ENOENT")
143
+ return "glab no está instalado";
144
+ return (e.stderr || e.message || String(error)).trim().split("\n")[0] ?? "";
145
+ }
146
+ //# sourceMappingURL=gitlab.js.map
@@ -27,8 +27,8 @@ export interface FinanceDelivery {
27
27
  export interface FinanceDeliveries {
28
28
  readonly projectName: string;
29
29
  readonly clientName: string;
30
- /** "github": lo leído de GitHub con `estela delivery`; "git": solo merges y squash. */
31
- readonly source: "github" | "git";
30
+ /** "github"/"gitlab": lo leído del gestor con `estela delivery`; "git": solo merges y squash. */
31
+ readonly source: "github" | "gitlab" | "git";
32
32
  readonly fetchedAt: Date | null;
33
33
  readonly delivered: readonly FinanceDelivery[];
34
34
  readonly plan: {
@@ -83,9 +83,9 @@ function financeToMarkdown(s) {
83
83
  out.push((0, index_js_1.tr) `| … y ${d.delivered.length - 15} más | | |`);
84
84
  out.push("");
85
85
  }
86
- out.push(d.source === "github"
87
- ? (0, index_js_1.tr) `_Fuente: GitHub, leído el ${d.fetchedAt ? d.fetchedAt.toISOString().slice(0, 10) : "—"} con estela delivery._`
88
- : (0, index_js_1.tr) `_Fuente: el historial de git (PR fusionados). Sin GitHub no se sabe qué estaba planificado._`, "");
86
+ out.push(d.source !== "git"
87
+ ? (0, index_js_1.tr) `_Fuente: ${d.source === "gitlab" ? "GitLab" : "GitHub"}, leído el ${d.fetchedAt ? d.fetchedAt.toISOString().slice(0, 10) : "—"} con estela delivery._`
88
+ : (0, index_js_1.tr) `_Fuente: el historial de git (PR fusionados). Sin GitHub ni GitLab no se sabe qué estaba planificado._`, "");
89
89
  }
90
90
  }
91
91
  const conPeticiones = s.deliveries.filter((d) => d.requests?.length);
@@ -118,7 +118,7 @@ function financeToMarkdown(s) {
118
118
  : (0, index_js_1.tr) `- **Gasto:** tokens a precio de lista de cada modelo. Si se paga una cuota fija, el gasto real es la cuota: regístrala con estela subscription add.`);
119
119
  out.push((0, index_js_1.tr) `- **Valor de uso a precio de lista:** ${(0, shared_1.formatAiCost)(s.paid.consumption)} en todo el periodo.`);
120
120
  out.push((0, index_js_1.tr) `- **Horas:** el tiempo entre mensajes de cada sesión de trabajo, sin contar pausas de más de 30 minutos. Dos tareas a la vez se reparten el mismo rato.`);
121
- out.push((0, index_js_1.tr) `- **Entregas:** las horas se unen a una issue o un PR por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.`);
121
+ out.push((0, index_js_1.tr) `- **Entregas:** las horas se unen a una issue o un PR (una MR en GitLab) por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.`);
122
122
  out.push((0, index_js_1.tr) `- **Sin entregar:** sesiones sin ningún commit y ramas sin fusionar tras ${s.staleDays} días. Lo que no se puede saber (una rama borrada que pudo fusionarse con squash) no cuenta.`);
123
123
  if (s.unpricedAgents)
124
124
  out.push((0, index_js_1.tr) `- Copilot no registra tokens: sus horas cuentan, pero su coste no se conoce y no está sumado.`);
package/dist/i18n/en.js CHANGED
@@ -640,6 +640,7 @@ exports.EN = {
640
640
  "Sin atribuir (sin rama)": "Unattributed (no branch)",
641
641
  "Sin atribuir (sin ticket ni PR)": "Unattributed (no ticket or PR)",
642
642
  "PR {0}": "PR {0}",
643
+ "MR {0}": "MR {0}",
643
644
  "Coste de IA por rama": "AI cost by branch",
644
645
  "Coste de IA por ticket o PR": "AI cost by ticket or PR",
645
646
  "\nNo hay horas en ese periodo.": "\nNo hours in that period.",
@@ -688,12 +689,16 @@ exports.EN = {
688
689
  "\"{0}\" ya no tiene etiqueta de plan.": "\"{0}\" no longer has a plan label.",
689
690
  "Falta la etiqueta: --plan-label <etiqueta> (o none para quitarla).": "Missing the label: --plan-label <label> (or none to remove it).",
690
691
  "--label y --milestone van por separado: elige una forma de marcar el plan.": "--label and --milestone go separately: pick one way to mark the plan.",
691
- "Este proyecto no tiene GitHub como gestor, así que no se lee el plan. Para leerlo:": "This project doesn't use GitHub as its tracker, so the plan isn't read. To read it:",
692
- " estela project tracker --project {0} --system github --plan-label <etiqueta>": " estela project tracker --project {0} --system github --plan-label <label>",
693
- "La carpeta del repo de este proyecto ya no existe: gh no tiene dónde mirar.": "This project's repo folder no longer exists: gh has nowhere to look.",
694
- "--offline: no se consulta GitHub.": "--offline: GitHub isn't queried.",
695
- "GitHub (gh), leído ahora": "GitHub (gh), read just now",
696
- "GitHub, lo guardado el {0} (sin conexión)": "GitHub, as saved on {0} (offline)",
692
+ "Este proyecto no tiene GitHub ni GitLab como gestor, así que no se lee el plan. Para leerlo:": "This project uses neither GitHub nor GitLab as its tracker, so the plan isn't read. To read it:",
693
+ " estela project tracker --project {0} --system github|gitlab --plan-label <etiqueta>": " estela project tracker --project {0} --system github|gitlab --plan-label <label>",
694
+ "La carpeta del repo de este proyecto ya no existe: {0} no tiene dónde mirar.": "This project's repo folder no longer exists: {0} has nowhere to look.",
695
+ "--offline: no se consulta {0}.": "--offline: {0} isn't queried.",
696
+ "{0} ({1}), leído ahora": "{0} ({1}), read just now",
697
+ "{0}, lo guardado el {1} (sin conexión)": "{0}, as saved on {1} (offline)",
698
+ "glab no está instalado: sin él no se puede leer el plan de GitLab. Instálalo desde https://gitlab.com/gitlab-org/cli y ejecuta: glab auth login": "glab isn't installed: without it the GitLab plan can't be read. Install it from https://gitlab.com/gitlab-org/cli and run: glab auth login",
699
+ "glab no tiene sesión iniciada. Ejecuta: glab auth login": "glab isn't logged in. Run: glab auth login",
700
+ "glab no reconoce un proyecto de GitLab en la carpeta del proyecto ({0}).": "glab doesn't recognise a GitLab project in the project's folder ({0}).",
701
+ "MR !{0}": "MR !{0}",
697
702
  "git: los PR de los commits de merge y de los squash con (#N)": "git: the PRs from merge commits and from squashes ending in (#N)",
698
703
  "Sin etiqueta ni milestone de plan solo se enseña lo entregado. Indícalo con --label o --milestone,": "With no plan label or milestone only what was delivered is shown. Set one with --label or --milestone,",
699
704
  "o déjalo fijo: estela project tracker --project {0} --plan-label <etiqueta>": "or keep it: estela project tracker --project {0} --plan-label <label>",
@@ -737,7 +742,7 @@ exports.EN = {
737
742
  "La etiqueta necesita al menos una letra o un número.": "The label needs at least one letter or digit.",
738
743
  "La etiqueta \"{0}\" es {1}.": "The \"{0}\" label is {1}.",
739
744
  "--by en delivery solo admite client.": "--by in delivery only takes client.",
740
- "Sin GitHub no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.": "Without GitHub there are no labels: there's no telling what each client asked for, so it all goes to the rest.",
745
+ "Sin GitHub ni GitLab no hay etiquetas: no se sabe qué pidió cada cliente, y todo va al resto.": "Without GitHub or GitLab there are no labels: there's no telling what each client asked for, so it all goes to the rest.",
741
746
  "Peticiones de clientes · {0} · {1} → {2}": "Client requests · {0} · {1} → {2}",
742
747
  "Etiqueta: \"{0}<nombre>\" · Fuente: {1}": "Label: \"{0}<name>\" · Source: {1}",
743
748
  "Estela no conoce ingresos ni contratos: esto solo une el gasto de IA con quién pidió el trabajo.": "Estela knows nothing about revenue or contracts: this only links AI spend to who asked for the work.",
@@ -771,8 +776,8 @@ exports.EN = {
771
776
  "Del plan ({0}) se entregaron {1} de {2}. Lo pendiente lleva {3} de gasto; lo entregado fuera del plan, {4}.": "Of the plan ({0}), {1} of {2} were delivered. What's pending has {3} of spend; what was delivered outside the plan, {4}.",
772
777
  "| Entrega | Horas | Gasto |": "| Delivery | Hours | Spend |",
773
778
  "| … y {0} más | | |": "| … and {0} more | | |",
774
- "_Fuente: GitHub, leído el {0} con estela delivery._": "_Source: GitHub, read on {0} with estela delivery._",
775
- "_Fuente: el historial de git (PR fusionados). Sin GitHub no se sabe qué estaba planificado._": "_Source: the git history (merged PRs). Without GitHub there's no telling what was planned._",
779
+ "_Fuente: {0}, leído el {1} con estela delivery._": "_Source: {0}, read on {1} with estela delivery._",
780
+ "_Fuente: el historial de git (PR fusionados). Sin GitHub ni GitLab no se sabe qué estaba planificado._": "_Source: the git history (merged PRs). Without GitHub or GitLab there's no telling what was planned._",
776
781
  "## Trabajo pedido por clientes": "## Work requested by clients",
777
782
  "Qué parte del gasto fue a peticiones concretas de un cliente. No incluye ingresos ni contratos: Estela no los conoce.": "How much of the spend went to a client's specific requests. It includes no revenue or contracts: Estela doesn't know them.",
778
783
  "| Proyecto | Quién lo pidió | Horas | Gasto |": "| Project | Requested by | Hours | Spend |",
@@ -787,7 +792,7 @@ exports.EN = {
787
792
  "- **Gasto:** tokens a precio de lista de cada modelo. Si se paga una cuota fija, el gasto real es la cuota: regístrala con estela subscription add.": "- **Spend:** tokens at each model's list price. If a flat fee is paid, the real spend is the fee: record it with estela subscription add.",
788
793
  "- **Valor de uso a precio de lista:** {0} en todo el periodo.": "- **Usage value at list price:** {0} for the whole period.",
789
794
  "- **Horas:** el tiempo entre mensajes de cada sesión de trabajo, sin contar pausas de más de 30 minutos. Dos tareas a la vez se reparten el mismo rato.": "- **Hours:** the time between messages in each work session, leaving out breaks over 30 minutes. Two tasks at once share the same stretch of time.",
790
- "- **Entregas:** las horas se unen a una issue o un PR por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.": "- **Deliveries:** hours are linked to an issue or PR by the PR's branch and by its number in branches or commits. Whatever links to nothing isn't spread around.",
795
+ "- **Entregas:** las horas se unen a una issue o un PR (una MR en GitLab) por la rama del PR y por su número en ramas o commits. Lo que no se une a nada no se reparte.": "- **Deliveries:** hours are linked to an issue or PR (an MR on GitLab) by the PR's branch and by its number in branches or commits. Whatever links to nothing isn't spread around.",
791
796
  "- **Sin entregar:** sesiones sin ningún commit y ramas sin fusionar tras {0} días. Lo que no se puede saber (una rama borrada que pudo fusionarse con squash) no cuenta.": "- **Not delivered:** sessions with no commit and branches unmerged after {0} days. What can't be known (a deleted branch that may have been squash-merged) isn't counted.",
792
797
  "- Copilot no registra tokens: sus horas cuentan, pero su coste no se conoce y no está sumado.": "- Copilot doesn't record tokens: its hours count, but its cost is unknown and not added.",
793
798
  "- † Algún mes no tenía cuota registrada: el importe solo cuenta los meses con cuota.": "- † Some month had no subscription recorded: the amount only counts the months with a fee.",
package/dist/server.js CHANGED
@@ -758,10 +758,20 @@ function buildSummary(db, from, to) {
758
758
  SUM(approved) AS approved,
759
759
  MIN(local_date) AS first_day,
760
760
  MAX(local_date) AS last_day,
761
- COUNT(DISTINCT local_date) AS days
761
+ COUNT(DISTINCT local_date) AS days,
762
+ SUM(CASE WHEN invoice_id IS NOT NULL THEN seconds ELSE 0 END) AS invoiced,
763
+ SUM(CASE WHEN invoice_id IS NULL AND billable = 1 THEN seconds ELSE 0 END) AS unbilled,
764
+ SUM(CASE WHEN invoice_id IS NULL AND billable = 0 THEN seconds ELSE 0 END) AS not_billable
762
765
  FROM time_entries
763
766
  WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
764
767
  GROUP BY project_id
768
+ `).all(from, to);
769
+ // En qué facturas está lo ya facturado del periodo, para nombrarlas.
770
+ const invoiceNumbers = db.prepare(`
771
+ SELECT DISTINCT t.project_id, i.number
772
+ FROM time_entries t JOIN invoices i ON i.id = t.invoice_id
773
+ WHERE t.local_date >= ? AND t.local_date <= ? AND ${VISIBLE.replace("project_id", "t.project_id")}
774
+ ORDER BY i.number
765
775
  `).all(from, to);
766
776
  const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
767
777
  const clients = new Map(store.listClients(db).map((c) => [c.id, c]));
@@ -784,6 +794,15 @@ function buildSummary(db, from, to) {
784
794
  aiMicroUsd: r.micro,
785
795
  amountMinor: rate ? Math.round((rate.amount * r.seconds) / 3600) : null,
786
796
  hourlyMinor: rate?.amount ?? null,
797
+ // Lo trabajado en el periodo, por dónde está: ya en una factura, pendiente
798
+ // de facturar (lo mismo que cuenta Informes, con su misma cuenta) o
799
+ // marcado como no facturable. Sin esto, el total del Resumen y lo
800
+ // pendiente de Informes parecen no cuadrar.
801
+ invoicedSeconds: r.invoiced,
802
+ invoices: invoiceNumbers.filter((i) => i.project_id === r.project_id).map((i) => i.number),
803
+ unbilledSeconds: r.unbilled,
804
+ unbilledMinor: rate ? Math.round((rate.amount * r.unbilled) / 3600) : null,
805
+ notBillableSeconds: r.not_billable,
787
806
  };
788
807
  }).sort((a, b) => b.seconds - a.seconds);
789
808
  // Actividad reciente entre todos los proyectos, para ver el pulso del conjunto.
@@ -10,7 +10,7 @@ export interface MergeRecord {
10
10
  readonly hash: string;
11
11
  readonly at: Date;
12
12
  readonly sourceBranch: string | null;
13
- /** `github:12` o `gitlab:45`. `null` en un merge local sin PR. */
13
+ /** `github:12` o `gitlab:!45` (las MR de GitLab llevan `!`). `null` en un merge local sin PR. */
14
14
  readonly ref: string | null;
15
15
  readonly subject: string;
16
16
  }
@@ -163,7 +163,7 @@ function parseMergeMessage(subject, body = "") {
163
163
  if (branch) {
164
164
  const gitlab = /See merge request \S*!(\d+)/.exec(body);
165
165
  const name = branch[1].replace(/^origin\//, "");
166
- return { sourceBranch: name, ref: gitlab ? `gitlab:${gitlab[1]}` : null };
166
+ return { sourceBranch: name, ref: gitlab ? `gitlab:!${gitlab[1]}` : null };
167
167
  }
168
168
  return { sourceBranch: null, ref: null };
169
169
  }
@@ -42,11 +42,12 @@ export interface WorkItemTracker {
42
42
  * el gestor no se sabe de quién es.
43
43
  */
44
44
  export declare function extractWorkItems(branch: string | null, subjects: readonly string[], tracker?: WorkItemTracker | null): string[];
45
- /** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12`. */
45
+ /** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12`, `!45` (merge request de GitLab). */
46
46
  export declare function formatWorkItem(key: string): string;
47
47
  export declare function splitWorkItem(key: string): [TrackerSystem, string];
48
48
  /**
49
- * Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234`) a clave
49
+ * Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234`, y `!45`
50
+ * para una merge request de GitLab) a clave
50
51
  * normalizada, con el gestor del proyecto para lo ambiguo. `null` si no se
51
52
  * puede saber de qué gestor es.
52
53
  */
@@ -1 +1 @@
1
- {"version":3,"file":"workitems.d.ts","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEnE,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAA0C,CAAC;AAE/F,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAC/B,MAAM,EAAE,CAqBV;AA0CD,6DAA6D;AAC7D,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKlD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,CAGlE;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAS3F"}
1
+ {"version":3,"file":"workitems.d.ts","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEnE,eAAO,MAAM,eAAe,EAAE,SAAS,aAAa,EAA0C,CAAC;AAE/F,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAWD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,GAAG,IAAI,EACrB,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAC/B,MAAM,EAAE,CAqBV;AA0CD,8FAA8F;AAC9F,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMlD;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,CAGlE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAW3F"}
@@ -103,11 +103,13 @@ function branchNumber(branch) {
103
103
  return null;
104
104
  return m[1].replace(/^0+/, "") || null;
105
105
  }
106
- /** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12`. */
106
+ /** Cómo se enseña una clave: `PROJ-12`, `AB#1234`, `#12`, `!45` (merge request de GitLab). */
107
107
  function formatWorkItem(key) {
108
108
  const [system, id] = splitWorkItem(key);
109
109
  if (system === "azure")
110
110
  return `AB#${id}`;
111
+ if (system === "gitlab" && id.startsWith("!"))
112
+ return id;
111
113
  if (system === "github" || system === "gitlab")
112
114
  return `#${id}`;
113
115
  return id;
@@ -117,7 +119,8 @@ function splitWorkItem(key) {
117
119
  return [key.slice(0, i), key.slice(i + 1)];
118
120
  }
119
121
  /**
120
- * Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234`) a clave
122
+ * Lo que escribe alguien a mano (`PROJ-12`, `AB#1234`, `#12`, `1234`, y `!45`
123
+ * para una merge request de GitLab) a clave
121
124
  * normalizada, con el gestor del proyecto para lo ambiguo. `null` si no se
122
125
  * puede saber de qué gestor es.
123
126
  */
@@ -129,6 +132,9 @@ function parseWorkItem(text, tracker) {
129
132
  m = /^([A-Za-z][A-Za-z0-9]+)-(\d+)$/.exec(t);
130
133
  if (m)
131
134
  return `jira:${m[1].toUpperCase()}-${m[2]}`;
135
+ m = /^!(\d+)$/.exec(t);
136
+ if (m)
137
+ return tracker?.system === "gitlab" ? `gitlab:!${m[1]}` : null;
132
138
  m = /^#?(\d+)$/.exec(t);
133
139
  if (m && tracker && tracker.system !== "jira")
134
140
  return `${tracker.system}:${m[1]}`;
@@ -1 +1 @@
1
- {"version":3,"file":"workitems.js","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"workitems.js","sourceRoot":"","sources":["../src/workitems.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;AA6CH,4CAyBC;AA2CD,wCAMC;AAED,sCAGC;AAQD,sCAWC;AA3IY,QAAA,eAAe,GAA6B,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAuB/F,0EAA0E;AAC1E,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC;IACvB,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK;IACpE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO;CACnE,CAAC,CAAC;AAEH,wEAAwE;AACxE,MAAM,kBAAkB,GAAG,IAAI,GAAG,CAAC,CAAC,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC;AAE3F;;;;;;;;GAQG;AACH,SAAgB,gBAAgB,CAC9B,MAAqB,EACrB,QAA2B,EAC3B,OAAgC;IAEhC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,GAAG,GAAG,CAAC,GAAW,EAAE,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAE5E,MAAM,IAAI,GAAG,MAAM,IAAI,EAAE,CAAC;IAC1B,KAAK,MAAM,GAAG,IAAI,SAAS,CAAC,IAAI,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5C,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC;IACpD,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QACzC,MAAM,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,CAAC;YAAE,GAAG,CAAC,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,KAAK,MAAM,GAAG,IAAI,SAAS,CAAC,OAAO,CAAC;YAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/C,IAAI,OAAO,EAAE,MAAM,KAAK,MAAM;YAAE,KAAK,MAAM,GAAG,IAAI,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;gBAAE,GAAG,CAAC,GAAG,CAAC,CAAC;QACvF,IAAI,OAAO,EAAE,MAAM,KAAK,QAAQ,IAAI,OAAO,EAAE,MAAM,KAAK,QAAQ,EAAE,CAAC;YACjE,mEAAmE;YACnE,KAAK,MAAM,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC;gBAAE,GAAG,CAAC,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC7F,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,SAAS,CAAC,IAAY;IAC7B,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AAC1E,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY,EAAE,OAAgC;IAC9D,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,EAAE,CAAC;IACpD,MAAM,QAAQ,GAAG,OAAO,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,CAAC;IAErE,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpB,0EAA0E;QAC1E,MAAM,YAAY,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAChG,IAAI,CAAC,YAAY;YAAE,OAAO,EAAE,CAAC;QAC7B,MAAM,EAAE,GAAG,IAAI,MAAM,CAAC,oBAAoB,YAAY,yBAAyB,EAAE,IAAI,CAAC,CAAC;QACvF,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAClF,CAAC;IAED,OAAO,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,wDAAwD,CAAC,CAAC;SAChF,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC;SACnC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AACxC,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,YAAY,CAAC,MAAc;IAClC,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACjD,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;IACzC,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1F,MAAM,CAAC,GAAG,sBAAsB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC9C,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACxD,OAAO,CAAC,CAAC,CAAC,CAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC;AAC1C,CAAC;AAED,8FAA8F;AAC9F,SAAgB,cAAc,CAAC,GAAW;IACxC,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IACxC,IAAI,MAAM,KAAK,OAAO;QAAE,OAAO,MAAM,EAAE,EAAE,CAAC;IAC1C,IAAI,MAAM,KAAK,QAAQ,IAAI,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IACzD,IAAI,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,QAAQ;QAAE,OAAO,IAAI,EAAE,EAAE,CAAC;IAChE,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,SAAgB,aAAa,CAAC,GAAW;IACvC,MAAM,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAC3B,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAkB,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;GAKG;AACH,SAAgB,aAAa,CAAC,IAAY,EAAE,OAAgC;IAC1E,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;IACtB,IAAI,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC9B,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9B,CAAC,GAAG,gCAAgC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7C,IAAI,CAAC;QAAE,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAE,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACpD,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACvB,IAAI,CAAC;QAAE,OAAO,OAAO,EAAE,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IACtE,CAAC,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACxB,IAAI,CAAC,IAAI,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAClF,OAAO,IAAI,CAAC;AACd,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "estela",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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
@@ -433,6 +433,11 @@ main.wide { max-width: 1180px; }
433
433
  font-size: .83rem; color: var(--ink-3); margin-top: 3px;
434
434
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
435
435
  }
436
+ /* Lo facturado y lo pendiente del periodo: debajo, en pequeño, porque es la
437
+ explicación del total y no otro total. */
438
+ .prow-split {
439
+ font-size: .8rem; color: var(--ink-3); margin-top: 4px; font-variant-numeric: tabular-nums;
440
+ }
436
441
  .prow-figs { text-align: right; flex: none; }
437
442
  .prow-hours {
438
443
  font-size: 1.05rem; font-weight: 580; font-variant-numeric: tabular-nums;
package/web/app.js CHANGED
@@ -11,7 +11,11 @@ const state = {
11
11
  date: localToday(),
12
12
  day: null,
13
13
  summary: null,
14
+ // Cada pantalla con su periodo: compartirlo hacía que "Todo lo pendiente"
15
+ // de Informes cambiara en silencio el Resumen a 3 meses, con "Este mes"
16
+ // todavía marcado.
14
17
  period: "month",
18
+ reportsPeriod: "month",
15
19
  open: new Set(),
16
20
  };
17
21
 
@@ -427,7 +431,7 @@ function haceCuanto(days) {
427
431
  // ── Resumen: todos los proyectos a la vez ──────────────────────────────
428
432
 
429
433
  async function loadSummary() {
430
- const { from, to } = periodRange(state.period === "all" ? "quarter" : state.period);
434
+ const { from, to } = periodRange(state.period);
431
435
  // La cuota es siempre de esta semana, sea cual sea el periodo elegido: si
432
436
  // fallara, el resumen se pinta igual y la tarjeta simplemente no sale.
433
437
  const [summary, quota] = await Promise.all([
@@ -441,7 +445,10 @@ async function loadSummary() {
441
445
 
442
446
  function renderSummary() {
443
447
  const s = state.summary;
448
+ // Las fechas, siempre: un total sin su periodo al lado se compara con otro
449
+ // que tiene uno distinto y parece que no cuadra.
444
450
  $("#summary-range").textContent =
451
+ `${tr("summary.range", { from: s.from, to: s.to })} · ` +
445
452
  `${tr("summary.projects", { n: s.activeProjects })} · ${tr("summary.days", { n: s.activeDays })}`;
446
453
 
447
454
  // Con varias monedas se enseñan todas: un guion parecería que falta un dato.
@@ -490,7 +497,7 @@ function renderSummary() {
490
497
  $("#summary-body").innerHTML = parts.join("");
491
498
 
492
499
  $("#approve-all")?.addEventListener("click", async () => {
493
- const { from, to } = periodRange(state.period === "all" ? "quarter" : state.period);
500
+ const { from, to } = periodRange(state.period);
494
501
  try {
495
502
  const r = await api("/api/approve-range", {
496
503
  method: "POST", body: JSON.stringify({ from, to }),
@@ -646,6 +653,7 @@ function renderProjectRow(p) {
646
653
  <div class="prow-name">${esc(p.projectName)} ${flag}${notBillable}</div>
647
654
  <div class="prow-meta">${esc(p.clientName)}${rate ? ` · ${rate}/h` : ""} ·
648
655
  ${tr("project.days", { n: p.days })} · ${tr("project.lastSeen", { date: esc(p.lastDay) })}</div>
656
+ ${billingSplit(p)}
649
657
  </div>
650
658
  <div class="prow-figs">
651
659
  <div class="prow-hours">${fmtDuration(p.seconds)}</div>
@@ -654,6 +662,29 @@ function renderProjectRow(p) {
654
662
  </div>`;
655
663
  }
656
664
 
665
+ /* De lo trabajado en el periodo, qué ya está en una factura y qué falta por
666
+ facturar. Lo pendiente es la misma cifra que da Informes para esas fechas:
667
+ sin esta línea, el total del Resumen (que incluye lo facturado) y lo
668
+ pendiente de Informes parecen dos cuentas que no cuadran. Solo sale cuando
669
+ hay mezcla; si todo está pendiente, el total ya lo dice. */
670
+ function billingSplit(p) {
671
+ if (p.kind !== "client") return "";
672
+ if (!p.invoicedSeconds && !p.notBillableSeconds) return "";
673
+ const parts = [];
674
+ if (p.invoicedSeconds) {
675
+ parts.push(tr("project.split.invoiced", { duration: fmtDuration(p.invoicedSeconds),
676
+ invoices: p.invoices?.length ? ` (${p.invoices.join(", ")})` : "" }));
677
+ }
678
+ if (p.unbilledSeconds) {
679
+ const money = p.unbilledMinor === null ? "" : ` · ${fmtMoney(p.unbilledMinor, p.currency)}`;
680
+ parts.push(tr("project.split.pending", { duration: fmtDuration(p.unbilledSeconds), amount: money }));
681
+ } else {
682
+ parts.push(tr("project.split.nothingPending"));
683
+ }
684
+ if (p.notBillableSeconds) parts.push(tr("project.split.notBillable", { duration: fmtDuration(p.notBillableSeconds) }));
685
+ return `<div class="prow-split">${esc(parts.join(" · "))}</div>`;
686
+ }
687
+
657
688
  function renderActivity(c) {
658
689
  const when = new Date(c.at).toLocaleDateString(localeTag(), { day: "numeric", month: "short" });
659
690
  return `
@@ -1409,7 +1440,7 @@ state.reportsOpen = new Set();
1409
1440
  async function loadReports() {
1410
1441
  // Mismo periodo que Resumen: si las dos pantallas contestan a la misma
1411
1442
  // pregunta, tienen que dar el mismo número.
1412
- const { from, to } = periodRange(state.period);
1443
+ const { from, to } = periodRange(state.reportsPeriod);
1413
1444
  const [overview, facturado] = await Promise.all([
1414
1445
  api(`/api/overview?from=${from}&to=${to}`),
1415
1446
  // Lo emitido no depende del periodo elegido: una factura de agosto sigue
@@ -1426,10 +1457,10 @@ function renderReports() {
1426
1457
  const parts = [];
1427
1458
 
1428
1459
  parts.push(`<div class="filters" id="rep-periods">
1429
- <button type="button" class="period${state.period === "week" ? " is-on" : ""}" data-period="week">${tr("period.week")}</button>
1430
- <button type="button" class="period${state.period === "month" ? " is-on" : ""}" data-period="month">${tr("period.month")}</button>
1431
- <button type="button" class="period${state.period === "quarter" ? " is-on" : ""}" data-period="quarter">${tr("period.quarter")}</button>
1432
- <button type="button" class="period${state.period === "all" ? " is-on" : ""}" data-period="all">${tr("period.all")}</button>
1460
+ <button type="button" class="period${state.reportsPeriod === "week" ? " is-on" : ""}" data-period="week">${tr("period.week")}</button>
1461
+ <button type="button" class="period${state.reportsPeriod === "month" ? " is-on" : ""}" data-period="month">${tr("period.month")}</button>
1462
+ <button type="button" class="period${state.reportsPeriod === "quarter" ? " is-on" : ""}" data-period="quarter">${tr("period.quarter")}</button>
1463
+ <button type="button" class="period${state.reportsPeriod === "all" ? " is-on" : ""}" data-period="all">${tr("period.all")}</button>
1433
1464
  </div>`);
1434
1465
 
1435
1466
  const fuera = overview.outsideRange;
@@ -1550,7 +1581,7 @@ function renderReports() {
1550
1581
 
1551
1582
  document.querySelectorAll("#rep-periods .period").forEach((btn) => {
1552
1583
  btn.addEventListener("click", () => {
1553
- state.period = btn.dataset.period; state.reportsAll = false; loadReports();
1584
+ state.reportsPeriod = btn.dataset.period; state.reportsAll = false; loadReports();
1554
1585
  });
1555
1586
  });
1556
1587
 
@@ -1710,10 +1741,11 @@ function showView(name) {
1710
1741
  if (name === "projects") loadProjects();
1711
1742
  }
1712
1743
 
1713
- document.querySelectorAll(".period").forEach((btn) => {
1744
+ // Solo los del Resumen: los de Equipo e Informes llevan su propio periodo.
1745
+ document.querySelectorAll("#periods .period").forEach((btn) => {
1714
1746
  btn.addEventListener("click", () => {
1715
1747
  state.period = btn.dataset.period;
1716
- document.querySelectorAll(".period").forEach((b) => b.classList.toggle("is-on", b === btn));
1748
+ document.querySelectorAll("#periods .period").forEach((b) => b.classList.toggle("is-on", b === btn));
1717
1749
  loadSummary();
1718
1750
  });
1719
1751
  });
package/web/i18n.js CHANGED
@@ -128,6 +128,7 @@
128
128
  "toast.blocksApproved": { one: "1 bloque aprobado", other: "{n} bloques aprobados" },
129
129
 
130
130
  // ── Resumen ──
131
+ "summary.range": "{from} → {to}",
131
132
  "summary.projects": { one: "1 proyecto con actividad", other: "{n} proyectos con actividad" },
132
133
  "summary.days": { one: "1 día trabajado", other: "{n} días trabajados" },
133
134
  "summary.value": "valor del trabajo",
@@ -162,6 +163,10 @@
162
163
  "project.pendingTitle": "Ir al día más reciente con bloques sin revisar",
163
164
  "project.days": { one: "1 día", other: "{n} días" },
164
165
  "project.lastSeen": "última vez {date}",
166
+ "project.split.invoiced": "Ya facturado: {duration}{invoices}",
167
+ "project.split.pending": "Pendiente de facturar: {duration}{amount}",
168
+ "project.split.nothingPending": "Nada pendiente de facturar",
169
+ "project.split.notBillable": "No facturable: {duration}",
165
170
 
166
171
  // ── Equipo ──
167
172
  "team.people": { one: "1 persona", other: "{n} personas" },
@@ -446,6 +451,7 @@
446
451
  "toast.blocksApproved": { one: "1 block approved", other: "{n} blocks approved" },
447
452
 
448
453
  // ── Summary ──
454
+ "summary.range": "{from} → {to}",
449
455
  "summary.projects": { one: "1 project with activity", other: "{n} projects with activity" },
450
456
  "summary.days": { one: "1 day worked", other: "{n} days worked" },
451
457
  "summary.value": "value of work",
@@ -480,6 +486,10 @@
480
486
  "project.pendingTitle": "Go to the most recent day with unreviewed blocks",
481
487
  "project.days": { one: "1 day", other: "{n} days" },
482
488
  "project.lastSeen": "last {date}",
489
+ "project.split.invoiced": "Already invoiced: {duration}{invoices}",
490
+ "project.split.pending": "Not yet invoiced: {duration}{amount}",
491
+ "project.split.nothingPending": "Nothing left to invoice",
492
+ "project.split.notBillable": "Not billable: {duration}",
483
493
 
484
494
  // ── Team ──
485
495
  "team.people": { one: "1 person", other: "{n} people" },