estela 0.1.19 → 0.1.21

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.
Files changed (82) hide show
  1. package/LICENSE +1 -1
  2. package/dist/billing/amortize.d.ts.map +1 -1
  3. package/dist/billing/amortize.js +3 -2
  4. package/dist/billing/amortize.js.map +1 -1
  5. package/dist/billing/invoice.d.ts.map +1 -1
  6. package/dist/billing/invoice.js +19 -18
  7. package/dist/billing/invoice.js.map +1 -1
  8. package/dist/billing/sessionize.d.ts.map +1 -1
  9. package/dist/billing/sessionize.js +3 -2
  10. package/dist/billing/sessionize.js.map +1 -1
  11. package/dist/billing.d.ts.map +1 -1
  12. package/dist/billing.js +5 -4
  13. package/dist/billing.js.map +1 -1
  14. package/dist/bin.js +6 -2
  15. package/dist/bin.js.map +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +311 -164
  18. package/dist/cli.js.map +1 -1
  19. package/dist/cloud/auth.d.ts.map +1 -1
  20. package/dist/cloud/auth.js +9 -8
  21. package/dist/cloud/auth.js.map +1 -1
  22. package/dist/cloud/client.d.ts.map +1 -1
  23. package/dist/cloud/client.js +11 -6
  24. package/dist/cloud/client.js.map +1 -1
  25. package/dist/db/schema.d.ts.map +1 -1
  26. package/dist/db/schema.js +4 -3
  27. package/dist/db/schema.js.map +1 -1
  28. package/dist/doctor.d.ts.map +1 -1
  29. package/dist/doctor.js +50 -45
  30. package/dist/doctor.js.map +1 -1
  31. package/dist/i18n/en.d.ts +10 -0
  32. package/dist/i18n/en.d.ts.map +1 -0
  33. package/dist/i18n/en.js +326 -0
  34. package/dist/i18n/en.js.map +1 -0
  35. package/dist/i18n/i18n.test.d.ts +2 -0
  36. package/dist/i18n/i18n.test.d.ts.map +1 -0
  37. package/dist/i18n/i18n.test.js +201 -0
  38. package/dist/i18n/i18n.test.js.map +1 -0
  39. package/dist/i18n/index.d.ts +54 -0
  40. package/dist/i18n/index.d.ts.map +1 -0
  41. package/dist/i18n/index.js +106 -0
  42. package/dist/i18n/index.js.map +1 -0
  43. package/dist/publish.d.ts.map +1 -1
  44. package/dist/publish.js +6 -5
  45. package/dist/publish.js.map +1 -1
  46. package/dist/server.d.ts +3 -0
  47. package/dist/server.d.ts.map +1 -1
  48. package/dist/server.js +50 -25
  49. package/dist/server.js.map +1 -1
  50. package/dist/server.test.d.ts +2 -0
  51. package/dist/server.test.d.ts.map +1 -0
  52. package/dist/server.test.js +125 -0
  53. package/dist/server.test.js.map +1 -0
  54. package/dist/setup.d.ts.map +1 -1
  55. package/dist/setup.js +8 -5
  56. package/dist/setup.js.map +1 -1
  57. package/dist/sync/team.d.ts.map +1 -1
  58. package/dist/sync/team.js +7 -6
  59. package/dist/sync/team.js.map +1 -1
  60. package/dist/sync.d.ts.map +1 -1
  61. package/dist/sync.js +7 -6
  62. package/dist/sync.js.map +1 -1
  63. package/dist/team.d.ts.map +1 -1
  64. package/dist/team.js +9 -8
  65. package/dist/team.js.map +1 -1
  66. package/dist/updateCheck.d.ts.map +1 -1
  67. package/dist/updateCheck.js +3 -2
  68. package/dist/updateCheck.js.map +1 -1
  69. package/dist/watchers/claude.d.ts.map +1 -1
  70. package/dist/watchers/claude.js +3 -2
  71. package/dist/watchers/claude.js.map +1 -1
  72. package/dist/web-ui.test.js +96 -7
  73. package/dist/web-ui.test.js.map +1 -1
  74. package/node_modules/@estela/shared/dist/money.d.ts +1 -0
  75. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  76. package/node_modules/@estela/shared/dist/money.js +9 -1
  77. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  78. package/package.json +1 -1
  79. package/web/app.js +213 -223
  80. package/web/i18n.js +635 -0
  81. package/web/index.html +40 -34
  82. package/web/lib.js +33 -8
package/web/app.js CHANGED
@@ -18,21 +18,20 @@ const state = {
18
18
  // ── Formato ────────────────────────────────────────────────────────────
19
19
  // Las funciones puras viven en lib.js para poder probarlas sin navegador.
20
20
 
21
- const KINDS = [
22
- ["development", "Desarrollo"], ["meeting", "Reunión"], ["research", "Investigación"],
23
- ["review", "Revisión"], ["travel", "Desplazamiento"], ["support", "Soporte"],
24
- ["other", "Otro"],
25
- ];
21
+ // Los textos salen de i18n.js, que ya decidió el idioma antes de cargar esto.
22
+ const KINDS = ["development", "meeting", "research", "review", "travel", "support", "other"]
23
+ .map((kind) => [kind, tr(`kind.${kind}`)]);
26
24
 
27
25
  function fmtTime(iso) {
28
- return new Date(iso).toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" });
26
+ return new Date(iso).toLocaleTimeString(localeTag(), { hour: "2-digit", minute: "2-digit" });
29
27
  }
30
28
 
31
29
  // ── Red ────────────────────────────────────────────────────────────────
32
30
 
33
31
  async function api(path, options) {
34
32
  const res = await fetch(path, {
35
- headers: { "Content-Type": "application/json" },
33
+ // El servidor local traduce sus pocos mensajes de error con esto.
34
+ headers: { "Content-Type": "application/json", "X-Estela-Lang": getLang() },
36
35
  ...options,
37
36
  });
38
37
  const body = await res.json();
@@ -78,7 +77,7 @@ function renderDay() {
78
77
  if (hasEntries) {
79
78
  $("#day-hours").textContent = fmtDuration(day.totalSeconds);
80
79
  $("#day-blocks").textContent =
81
- day.entries.length === 1 ? "en 1 bloque" : `en ${day.entries.length} bloques`;
80
+ tr("day.inBlocks", { n: day.entries.length });
82
81
 
83
82
  // Con varias monedas se enseñan todas. Decir "sin tarifa" cuando las hay
84
83
  // manda a revisar una configuración que está bien.
@@ -89,10 +88,10 @@ function renderDay() {
89
88
 
90
89
  $("#day-amount").textContent = money || "—";
91
90
  $("#day-amount").parentElement.querySelector(".hero-label").textContent =
92
- totals.length > 1 ? "por facturar (por moneda)"
93
- : totals.length === 1 ? "por facturar"
94
- : day.missingRate ? "sin tarifa definida"
95
- : "sin trabajo facturable";
91
+ totals.length > 1 ? tr("day.toInvoiceByCurrency")
92
+ : totals.length === 1 ? tr("day.toInvoice")
93
+ : day.missingRate ? tr("day.noRate")
94
+ : tr("day.noBillable");
96
95
  } else {
97
96
  // Vaciar aunque esté oculto: una cifra vieja esperando en el DOM es un
98
97
  // número equivocado a la espera de que algo la muestre por accidente.
@@ -108,9 +107,7 @@ function renderDay() {
108
107
  const pending = day.pendingApproval;
109
108
  $("#approve-bar").hidden = !hasEntries || pending === 0;
110
109
  if (pending > 0) {
111
- $("#approve-text").textContent = pending === 1
112
- ? "Queda 1 bloque por revisar — llévame a él"
113
- : `Quedan ${pending} bloques por revisar — llévame al primero`;
110
+ $("#approve-text").textContent = tr("day.pending", { n: pending });
114
111
  }
115
112
  }
116
113
 
@@ -118,15 +115,14 @@ function renderEmpty() {
118
115
  const today = localToday();
119
116
  if (state.date === today) {
120
117
  return `<div class="empty">
121
- <h2>Todavía nada por aquí</h2>
122
- <p>En cuanto trabajes con un agente en un repositorio configurado, aparecerá solo.
123
- Si ya trabajaste, trae lo de hoy:</p>
118
+ <h2>${tr("day.emptyToday.title")}</h2>
119
+ <p>${tr("day.emptyToday.body")}</p>
124
120
  <code>estela import</code>
125
121
  </div>`;
126
122
  }
127
123
  return `<div class="empty">
128
- <h2>Sin actividad este día</h2>
129
- <p>No hay ningún bloque de trabajo registrado.</p>
124
+ <h2>${tr("day.empty.title")}</h2>
125
+ <p>${tr("day.empty.body")}</p>
130
126
  </div>`;
131
127
  }
132
128
 
@@ -151,9 +147,9 @@ function renderRow(entry) {
151
147
  const meta = [
152
148
  entry.projectName,
153
149
  entry.clientName,
154
- entry.source === "manual" ? "añadido a mano"
150
+ entry.source === "manual" ? tr("row.manual")
155
151
  : entry.source === "commit"
156
- ? `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)} · deducido de commits`
152
+ ? `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)} · ${tr("row.fromCommits")}`
157
153
  : `${fmtTime(entry.startedAt)}–${fmtTime(entry.endedAt)}`,
158
154
  ].filter(Boolean).join(" · ");
159
155
 
@@ -167,7 +163,7 @@ function renderRow(entry) {
167
163
  </span>
168
164
  <span class="row-figs">
169
165
  <span class="row-time">${fmtDuration(entry.seconds)}</span>
170
- <span class="row-money ${money ? "" : "none"}">${money || (entry.billable ? "sin tarifa" : "no facturable")}</span>
166
+ <span class="row-money ${money ? "" : "none"}">${money || (entry.billable ? tr("row.noRate") : tr("row.notBillable"))}</span>
171
167
  </span>
172
168
  <span class="chev" aria-hidden="true">›</span>
173
169
  </button>
@@ -186,12 +182,12 @@ function renderRowBody(entry) {
186
182
  : "";
187
183
 
188
184
  const ai = entry.aiMicroUsd > 0
189
- ? `<p class="detail-line">Consumo de IA: <b>$${(entry.aiMicroUsd / 1e6).toFixed(2)}</b> en tarifa API equivalente.</p>`
185
+ ? `<p class="detail-line">${tr("row.ai", { amount: "$" + (entry.aiMicroUsd / 1e6).toFixed(2) })}</p>`
190
186
  : "";
191
187
 
192
188
  if (entry.invoiced) {
193
189
  return `<div class="row-body">
194
- <p class="detail-line">Este bloque ya está facturado, así que no se puede editar.</p>
190
+ <p class="detail-line">${tr("row.invoiced")}</p>
195
191
  ${commits}${ai}
196
192
  </div>`;
197
193
  }
@@ -199,16 +195,16 @@ function renderRowBody(entry) {
199
195
  return `
200
196
  <div class="row-body">
201
197
  <div class="field">
202
- <label for="d-${esc(entry.id)}">Qué hiciste</label>
198
+ <label for="d-${esc(entry.id)}">${tr("field.what")}</label>
203
199
  <input id="d-${esc(entry.id)}" type="text" value="${esc(entry.description)}" data-field="description">
204
200
  </div>
205
201
  <div class="field-pair">
206
202
  <div class="field">
207
- <label for="p-${esc(entry.id)}">Proyecto</label>
203
+ <label for="p-${esc(entry.id)}">${tr("field.project")}</label>
208
204
  <select id="p-${esc(entry.id)}" data-field="projectId">${projects}</select>
209
205
  </div>
210
206
  <div class="field">
211
- <label for="m-${esc(entry.id)}">Minutos</label>
207
+ <label for="m-${esc(entry.id)}">${tr("field.minutes")}</label>
212
208
  <input id="m-${esc(entry.id)}" type="number" min="0" step="1"
213
209
  value="${Math.round(entry.seconds / 60)}" data-field="minutes">
214
210
  </div>
@@ -216,7 +212,7 @@ function renderRowBody(entry) {
216
212
  ${commits}${ai}
217
213
  <div class="row-actions">
218
214
  <button type="button" class="chip ${entry.billable ? "is-on" : ""}" data-billable>
219
- ${entry.billable ? "Facturable" : "No facturable"}
215
+ ${entry.billable ? tr("row.billableOn") : tr("row.billableOff")}
220
216
  </button>
221
217
  </div>
222
218
  </div>`;
@@ -236,31 +232,30 @@ function renderAddForm(day) {
236
232
 
237
233
  return `
238
234
  <div class="addwrap">
239
- <button type="button" class="add-toggle" id="add-toggle">+ Añadir horas sin código</button>
235
+ <button type="button" class="add-toggle" id="add-toggle">${tr("add.toggle")}</button>
240
236
  <form class="addform" id="add-form" hidden>
241
- <p class="addform-hint">Desarrollo sin agente, reuniones, desplazamientos, investigación.
242
- Un import nunca las toca.</p>
237
+ <p class="addform-hint">${tr("add.hint")}</p>
243
238
  <div class="addrow">
244
239
  <label class="field">
245
- <span>Tipo</span>
240
+ <span>${tr("add.kind")}</span>
246
241
  <select id="add-kind">${kinds}</select>
247
242
  </label>
248
243
  <label class="field">
249
- <span>Minutos</span>
244
+ <span>${tr("field.minutes")}</span>
250
245
  <input id="add-minutes" type="number" min="5" step="5" value="60" required>
251
246
  </label>
252
247
  </div>
253
248
  <label class="field">
254
- <span>Qué hiciste</span>
255
- <input id="add-what" type="text" placeholder="Seguimiento semanal con el cliente">
249
+ <span>${tr("field.what")}</span>
250
+ <input id="add-what" type="text" placeholder="${tr("add.whatPlaceholder")}">
256
251
  </label>
257
252
  <label class="field">
258
- <span>Proyecto</span>
253
+ <span>${tr("field.project")}</span>
259
254
  <select id="add-project">${projects}</select>
260
255
  </label>
261
256
  <div class="addactions">
262
- <button type="button" class="chip" id="add-cancel">Cancelar</button>
263
- <button type="submit" class="btn-main">Añadir</button>
257
+ <button type="button" class="chip" id="add-cancel">${tr("common.cancel")}</button>
258
+ <button type="submit" class="btn-main">${tr("add.submit")}</button>
264
259
  </div>
265
260
  </form>
266
261
  </div>`;
@@ -294,7 +289,7 @@ function wireAddForm() {
294
289
  }),
295
290
  });
296
291
  await loadDay();
297
- toast("Horas añadidas");
292
+ toast(tr("toast.hoursAdded"));
298
293
  } catch (error) {
299
294
  toast(error.message);
300
295
  }
@@ -338,12 +333,19 @@ async function save(id, patch) {
338
333
  body: JSON.stringify(patch),
339
334
  });
340
335
  await loadDay();
341
- toast("Guardado");
336
+ toast(tr("toast.saved"));
342
337
  } catch (error) {
343
338
  toast(error.message);
344
339
  }
345
340
  }
346
341
 
342
+ /** "hoy", "ayer" o "hace N días", para decir cuándo se publicó un panel. */
343
+ function haceCuanto(days) {
344
+ if (days === 0) return tr("when.today");
345
+ if (days === 1) return tr("when.yesterday");
346
+ return tr("when.daysAgo", { n: days });
347
+ }
348
+
347
349
  // ── Resumen: todos los proyectos a la vez ──────────────────────────────
348
350
 
349
351
  async function loadSummary() {
@@ -355,50 +357,46 @@ async function loadSummary() {
355
357
  function renderSummary() {
356
358
  const s = state.summary;
357
359
  $("#summary-range").textContent =
358
- `${s.activeProjects} proyecto${s.activeProjects === 1 ? "" : "s"} con actividad · ` +
359
- `${s.activeDays} día${s.activeDays === 1 ? "" : "s"} trabajados`;
360
+ `${tr("summary.projects", { n: s.activeProjects })} · ${tr("summary.days", { n: s.activeDays })}`;
360
361
 
361
362
  // Con varias monedas se enseñan todas: un guion parecería que falta un dato.
362
363
  const money = (s.totals || []).length
363
364
  ? s.totals.map((t) => fmtMoney(t.amountMinor, t.currency)).join(" + ")
364
365
  : null;
365
366
  const moneyLabel = (s.totals || []).length > 1
366
- ? "valor del trabajo (por moneda)" : "valor del trabajo";
367
+ ? tr("summary.valueByCurrency") : tr("summary.value");
367
368
 
368
369
  $("#kpis").innerHTML = `
369
- ${kpi(fmtDuration(s.totalSeconds), "horas registradas")}
370
+ ${kpi(fmtDuration(s.totalSeconds), tr("summary.kpiHours"))}
370
371
  ${kpi(money || "—", moneyLabel, "kpi-money")}
371
372
  ${s.billableSeconds !== s.totalSeconds
372
- ? kpi(fmtDuration(s.billableSeconds), "de ellas facturables", "kpi-quiet") : ""}
373
- ${kpi(String(s.pendingApproval), s.pendingApproval === 1 ? "bloque por revisar" : "bloques por revisar",
373
+ ? kpi(fmtDuration(s.billableSeconds), tr("summary.kpiBillable"), "kpi-quiet") : ""}
374
+ ${kpi(String(s.pendingApproval), tr("summary.kpiPending", { n: s.pendingApproval }),
374
375
  s.pendingApproval > 0 ? "kpi-attention" : "")}
375
- ${kpi("$" + (s.aiMicroUsd / 1e6).toFixed(2), "consumo de IA", "kpi-quiet")}`;
376
+ ${kpi("$" + (s.aiMicroUsd / 1e6).toFixed(2), tr("summary.kpiAi"), "kpi-quiet")}`;
376
377
 
377
378
  const parts = [];
378
379
 
379
380
  if (s.projects.length === 0) {
380
381
  parts.push(`<div class="empty">
381
- <h2>Sin actividad en este periodo</h2>
382
- <p>Prueba con un rango más amplio, o comprueba que tus repositorios estén
383
- asignados a un proyecto.</p>
382
+ <h2>${tr("summary.empty.title")}</h2>
383
+ <p>${tr("summary.empty.body")}</p>
384
384
  <code>estela status</code>
385
385
  </div>`);
386
386
  } else {
387
387
  if (s.pendingApproval > 0) {
388
388
  parts.push(`<div class="approve-all">
389
- <p>Hay <b>${s.pendingApproval}</b> bloques de trabajo <b>tuyo</b> sin revisar
390
- en este periodo. Al configurar un proyecto entra de golpe todo su
391
- histórico. Pulsa el aviso de cada proyecto para ir a ellos.</p>
392
- <button type="button" class="btn-main" id="approve-all">Aprobar el periodo</button>
389
+ <p>${tr("summary.approveAll", { n: s.pendingApproval })}</p>
390
+ <button type="button" class="btn-main" id="approve-all">${tr("summary.approveAll.btn")}</button>
393
391
  </div>`);
394
392
  }
395
393
  parts.push(renderSparkline(s.byDay));
396
- parts.push(`<h2 class="block-title">Proyectos</h2>`);
394
+ parts.push(`<h2 class="block-title">${tr("tab.projects")}</h2>`);
397
395
  parts.push(`<div class="plist">${s.projects.map(renderProjectRow).join("")}</div>`);
398
396
  }
399
397
 
400
398
  if (s.activity.length) {
401
- parts.push(`<h2 class="block-title">Actividad reciente</h2>`);
399
+ parts.push(`<h2 class="block-title">${tr("summary.activity")}</h2>`);
402
400
  parts.push(`<div class="feed">${s.activity.map(renderActivity).join("")}</div>`);
403
401
  }
404
402
 
@@ -411,7 +409,7 @@ function renderSummary() {
411
409
  method: "POST", body: JSON.stringify({ from, to }),
412
410
  });
413
411
  await loadSummary();
414
- toast(`${r.approved} bloques aprobados`);
412
+ toast(tr("toast.blocksApproved", { n: r.approved }));
415
413
  } catch (error) { toast(error.message); }
416
414
  });
417
415
 
@@ -422,13 +420,13 @@ function renderSummary() {
422
420
  btn.addEventListener("click", async () => {
423
421
  try {
424
422
  const r = await api(`/api/pending-days?project=${encodeURIComponent(btn.dataset.pending)}`);
425
- if (!r.days.length) { toast("Ya no queda nada por revisar ahí"); return; }
423
+ if (!r.days.length) { toast(tr("summary.nothingLeft")); return; }
426
424
  state.date = r.days[0].date;
427
425
  showView("day");
428
426
  const otros = r.days.length - 1;
429
427
  toast(otros > 0
430
- ? `${r.days[0].blocks} por revisar aquí · ${otros} ${otros === 1 ? "día más" : "días más"} con pendientes`
431
- : `${r.days[0].blocks} por revisar — es el último día pendiente`);
428
+ ? tr("summary.pendingHere", { blocks: r.days[0].blocks, more: tr("summary.moreDays", { n: otros }) })
429
+ : tr("summary.lastPending", { blocks: r.days[0].blocks }));
432
430
  } catch (error) { toast(error.message); }
433
431
  });
434
432
  });
@@ -465,29 +463,29 @@ function renderSparkline(byDay) {
465
463
  */
466
464
  function renderCompartir(projectId, pub) {
467
465
  const url = (pub.baseUrl || "https://getestela.dev") + "/e/" + pub.token + "/";
468
- const texto = encodeURIComponent("Te comparto el avance del proyecto: " + url);
466
+ const texto = encodeURIComponent(tr("share.message", { url }));
469
467
  const clientes = state.panelClients?.[pub.token] || [];
470
468
 
471
469
  return `
472
470
  <div class="share-ways" data-token="${esc(pub.token)}" data-url="${esc(url)}">
473
471
  <div class="share-row">
474
- <button type="button" class="chip" data-copy-url>Copiar enlace</button>
472
+ <button type="button" class="chip" data-copy-url>${tr("share.copy")}</button>
475
473
  <a class="chip" href="https://wa.me/?text=${texto}" target="_blank" rel="noopener">WhatsApp</a>
476
- <a class="chip" href="mailto:?subject=${encodeURIComponent("Avance del proyecto")}&body=${texto}">Correo</a>
474
+ <a class="chip" href="mailto:?subject=${encodeURIComponent(tr("share.subject"))}&body=${texto}">${tr("share.email")}</a>
477
475
  </div>
478
476
  <label class="share-label" for="cl-${esc(projectId)}">
479
- O dáselo por su correo y se lo mandamos nosotros — además le aparece en su cuenta
477
+ ${tr("share.byEmail")}
480
478
  </label>
481
479
  <div class="share-row">
482
480
  <input id="cl-${esc(projectId)}" class="share-mails" type="text"
483
- placeholder="cliente@empresa.com, otro@empresa.com"
481
+ placeholder="${tr("share.emailsPlaceholder")}"
484
482
  value="${esc(clientes.map((c) => c.email).join(", "))}">
485
- <button type="button" class="chip" data-save-clients>Guardar</button>
483
+ <button type="button" class="chip" data-save-clients>${tr("common.save")}</button>
486
484
  </div>
487
485
  ${clientes.length ? `<ul class="share-who">${clientes.map((c) =>
488
486
  `<li>${esc(c.email)} — ${c.entrado
489
- ? "<b>ya tiene cuenta</b>, lo ve al entrar"
490
- : "aún no ha entrado en getestela.dev/app"}</li>`).join("")}</ul>` : ""}
487
+ ? tr("share.hasAccount")
488
+ : tr("share.notYet")}</li>`).join("")}</ul>` : ""}
491
489
  <div class="share-msg"></div>
492
490
  </div>`;
493
491
  }
@@ -500,8 +498,8 @@ function renderProjectRow(p) {
500
498
  // por día a ciegas. El aviso es el propio camino.
501
499
  const flag = p.pendingApproval > 0
502
500
  ? `<button type="button" class="tag tag-warn tag-go" data-pending="${esc(p.projectId)}"
503
- title="Ir al día más reciente con bloques sin revisar">${p.pendingApproval} por revisar →</button>` : "";
504
- const KIND_TAG = { employment: "nómina", internal: "interno" };
501
+ title="${tr("project.pendingTitle")}">${tr("project.pendingTag", { n: p.pendingApproval })}</button>` : "";
502
+ const KIND_TAG = { employment: tr("kind.employment"), internal: tr("kind.internal") };
505
503
  const notBillable = KIND_TAG[p.kind] ? `<span class="tag">${KIND_TAG[p.kind]}</span>` : "";
506
504
 
507
505
  return `
@@ -509,7 +507,7 @@ function renderProjectRow(p) {
509
507
  <div class="prow-text">
510
508
  <div class="prow-name">${esc(p.projectName)} ${flag}${notBillable}</div>
511
509
  <div class="prow-meta">${esc(p.clientName)}${rate ? ` · ${rate}/h` : ""} ·
512
- ${p.days} día${p.days === 1 ? "" : "s"} · última vez ${esc(p.lastDay)}</div>
510
+ ${tr("project.days", { n: p.days })} · ${tr("project.lastSeen", { date: esc(p.lastDay) })}</div>
513
511
  </div>
514
512
  <div class="prow-figs">
515
513
  <div class="prow-hours">${fmtDuration(p.seconds)}</div>
@@ -519,7 +517,7 @@ function renderProjectRow(p) {
519
517
  }
520
518
 
521
519
  function renderActivity(c) {
522
- const when = new Date(c.at).toLocaleDateString("es-ES", { day: "numeric", month: "short" });
520
+ const when = new Date(c.at).toLocaleDateString(localeTag(), { day: "numeric", month: "short" });
523
521
  return `
524
522
  <div class="act">
525
523
  <code>${esc(c.hash)}</code>
@@ -560,8 +558,7 @@ function renderTeam() {
560
558
  const people = t.people.length;
561
559
 
562
560
  $("#team-sub").textContent =
563
- `${people} persona${people === 1 ? "" : "s"} · ` +
564
- `${t.activeDays} día${t.activeDays === 1 ? "" : "s"} con actividad`;
561
+ `${tr("team.people", { n: people })} · ${tr("team.activeDays", { n: t.activeDays })}`;
565
562
 
566
563
  fillProjectFilter(t.projects);
567
564
  $("#team-alerts").innerHTML = renderAlerts(t);
@@ -569,18 +566,18 @@ function renderTeam() {
569
566
 
570
567
  const parts = [];
571
568
  if (t.projects.length) {
572
- parts.push(`<h2 class="block-title">Por proyecto</h2>`);
569
+ parts.push(`<h2 class="block-title">${tr("team.byProject")}</h2>`);
573
570
  parts.push(`<div class="tbl-wrap"><table class="tbl">
574
571
  <thead><tr>
575
- <th>Proyecto</th><th class="th-comp">Composición</th>
576
- <th class="num">Horas</th><th class="num">Coste IA</th>
577
- <th class="num">Valor</th><th>Estado</th>
572
+ <th>${tr("team.th.project")}</th><th class="th-comp">${tr("team.th.composition")}</th>
573
+ <th class="num">${tr("team.th.hours")}</th><th class="num">${tr("team.th.aiCost")}</th>
574
+ <th class="num">${tr("team.th.value")}</th><th>${tr("team.th.status")}</th>
578
575
  </tr></thead>
579
576
  <tbody>${t.projects.map(teamProjectRow).join("")}</tbody></table></div>`);
580
577
  parts.push(`<p class="legend">
581
- <span class="sw sw-agent"></span> medido con agente
582
- <span class="sw sw-commit"></span> estimado de commits
583
- <span class="sw sw-manual"></span> anotado a mano</p>`);
578
+ <span class="sw sw-agent"></span> ${tr("team.legend.agent")}
579
+ <span class="sw sw-commit"></span> ${tr("team.legend.commit")}
580
+ <span class="sw sw-manual"></span> ${tr("team.legend.manual")}</p>`);
584
581
  }
585
582
 
586
583
  if (people) {
@@ -594,30 +591,27 @@ function renderTeam() {
594
591
  // los totales son tu trabajo imputado, y aquí hay horas estimadas de gente
595
592
  // que no usa Estela. Sin esta nota, el primer lector atento encuentra que
596
593
  // los números no cuadran y deja de creerse toda la pantalla.
597
- parts.push(`<h2 class="block-title">Personas
598
- <span class="block-note">por actividad reciente</span></h2>`);
599
- parts.push(`<p class="note-inline">Las horas de tus compañeros se estiman
600
- desde sus commits y <b>no entran</b> en los totales de arriba, que cuentan
601
- solo tu trabajo imputado. Si alguien instala Estela, su fila pasa a
602
- medida.</p>`);
594
+ parts.push(`<h2 class="block-title">${tr("team.peopleTitle")}
595
+ <span class="block-note">${tr("team.byRecent")}</span></h2>`);
596
+ parts.push(`<p class="note-inline">${tr("team.note")}</p>`);
603
597
  parts.push(`<div class="tbl-wrap"><table class="tbl">
604
598
  <thead><tr>
605
- <th>Persona</th><th class="num">Horas</th><th>Precisión</th>
606
- <th>Ramas</th><th class="num">Última vez</th>
599
+ <th>${tr("team.th.person")}</th><th class="num">${tr("team.th.hours")}</th><th>${tr("team.th.precision")}</th>
600
+ <th>${tr("team.th.branches")}</th><th class="num">${tr("team.th.lastSeen")}</th>
607
601
  </tr></thead>
608
602
  <tbody>${shown.map(personRow).join("")}</tbody></table></div>`);
609
603
 
610
604
  if (hidden > 0) {
611
605
  parts.push(`<button type="button" class="more" id="team-more">
612
- Ver ${hidden} persona${hidden === 1 ? "" : "s"} más</button>`);
606
+ ${tr("team.showMore", { n: hidden })}</button>`);
613
607
  } else if (state.teamAll && t.people.length > TEAM_ROWS) {
614
- parts.push(`<button type="button" class="more" id="team-more">Ver menos</button>`);
608
+ parts.push(`<button type="button" class="more" id="team-more">${tr("common.showLess")}</button>`);
615
609
  }
616
610
  }
617
611
 
618
612
  if (!t.projects.length && !people) {
619
- parts.push(`<div class="empty"><h2>Sin actividad en este periodo</h2>
620
- <p>Prueba con un rango más amplio.</p></div>`);
613
+ parts.push(`<div class="empty"><h2>${tr("summary.empty.title")}</h2>
614
+ <p>${tr("team.empty.body")}</p></div>`);
621
615
  }
622
616
 
623
617
  $("#team-body").innerHTML = parts.join("");
@@ -636,22 +630,26 @@ function renderAlerts(t) {
636
630
  if (p.budgetPct !== null && p.budgetPct >= 80) {
637
631
  const over = p.budgetPct >= 100;
638
632
  out.push(alertBox(over ? "danger" : "warn",
639
- `${p.projectName} ha ${over ? "superado" : "consumido el " + p.budgetPct + "% de"} su presupuesto de IA`,
640
- `$${(p.aiMicroUsd / 1e6).toFixed(2)} de $${(p.budgetMicroUsd / 1e6).toFixed(2)} en este periodo.`));
633
+ over
634
+ ? tr("team.alert.budgetOver", { project: p.projectName })
635
+ : tr("team.alert.budgetNear", { project: p.projectName, pct: p.budgetPct }),
636
+ tr("team.alert.budgetDetail", {
637
+ spent: "$" + (p.aiMicroUsd / 1e6).toFixed(2),
638
+ budget: "$" + (p.budgetMicroUsd / 1e6).toFixed(2),
639
+ })));
641
640
  }
642
641
  }
643
642
 
644
643
  const stale = t.openWork.filter((w) => w.ageDays >= 14);
645
644
  if (stale.length) {
646
645
  out.push(alertBox("warn",
647
- `${stale.length} rama${stale.length === 1 ? "" : "s"} sin integrar desde hace más de dos semanas`,
648
- `La más antigua, ${stale[0].name}, lleva ${stale[0].ageDays} días. ` +
649
- `Puede ser trabajo parado o una revisión pendiente en el otro lado.`));
646
+ tr("team.alert.stale", { n: stale.length }),
647
+ tr("team.alert.staleDetail", { name: stale[0].name, days: stale[0].ageDays })));
650
648
  }
651
649
 
652
650
  if (t.pendingApproval > 0) {
653
- out.push(alertBox("info", `${t.pendingApproval} bloques sin revisar`,
654
- "Hasta revisarlos no deberían salir en un informe."));
651
+ out.push(alertBox("info", tr("team.alert.pending", { n: t.pendingApproval }),
652
+ tr("team.alert.pendingDetail")));
655
653
  }
656
654
  return out.join("");
657
655
  }
@@ -667,8 +665,8 @@ function renderTeamKpis(t) {
667
665
  const hours = fmtDuration(t.totalSeconds);
668
666
  const delta = t.totalSeconds - t.previousSeconds;
669
667
  const deltaTxt = t.previousSeconds > 0
670
- ? `${delta >= 0 ? "+" : "−"}${fmtDuration(Math.abs(delta))} frente al periodo anterior`
671
- : "sin periodo anterior con el que comparar";
668
+ ? tr("team.kpi.delta", { delta: `${delta >= 0 ? "+" : "−"}${fmtDuration(Math.abs(delta))}` })
669
+ : tr("team.kpi.noPrevious");
672
670
 
673
671
  const ai = t.aiMicroUsd / 1e6;
674
672
  const perHour = t.totalSeconds > 0 ? (ai / (t.totalSeconds / 3600)) : 0;
@@ -680,13 +678,13 @@ function renderTeamKpis(t) {
680
678
  const oldest = open ? t.openWork[0] : null;
681
679
 
682
680
  return [
683
- teamKpi(hours, "horas del periodo", deltaTxt, delta >= 0 ? "up" : "down"),
684
- teamKpi("$" + ai.toFixed(2), "coste de IA",
685
- `$${perHour.toFixed(2)} por hora trabajada`),
686
- teamKpi(pct + "%", "facturable",
687
- `${fmtDuration(t.billableSeconds)} de ${hours}`),
688
- teamKpi(String(open), open === 1 ? "rama sin integrar" : "ramas sin integrar",
689
- oldest ? `la más antigua, ${oldest.ageDays} días` : "todo integrado",
681
+ teamKpi(hours, tr("team.kpi.hours"), deltaTxt, delta >= 0 ? "up" : "down"),
682
+ teamKpi("$" + ai.toFixed(2), tr("team.kpi.aiCost"),
683
+ tr("team.kpi.perHour", { amount: "$" + perHour.toFixed(2) })),
684
+ teamKpi(pct + "%", tr("team.kpi.billable"),
685
+ tr("team.kpi.ofTotal", { part: fmtDuration(t.billableSeconds), total: hours })),
686
+ teamKpi(String(open), tr("team.kpi.unmerged", { n: open }),
687
+ oldest ? tr("team.kpi.oldest", { n: oldest.ageDays }) : tr("team.kpi.allMerged"),
690
688
  open > 0 ? "warn" : ""),
691
689
  ].join("");
692
690
  }
@@ -707,7 +705,9 @@ function compositionBar(c) {
707
705
  if (!total) return "";
708
706
  const seg = (n, cls) => n > 0
709
707
  ? `<span class="seg ${cls}" style="width:${(n / total) * 100}%"></span>` : "";
710
- const title = `agente ${fmtDuration(c.agent)} · commits ${fmtDuration(c.commit)} · a mano ${fmtDuration(c.manual)}`;
708
+ const title = tr("team.composition", {
709
+ agent: fmtDuration(c.agent), commit: fmtDuration(c.commit), manual: fmtDuration(c.manual),
710
+ });
711
711
  return `<div class="comp" title="${esc(title)}">
712
712
  ${seg(c.agent, "sw-agent")}${seg(c.commit, "sw-commit")}${seg(c.manual, "sw-manual")}
713
713
  </div>`;
@@ -715,11 +715,11 @@ function compositionBar(c) {
715
715
 
716
716
  function teamProjectRow(p) {
717
717
  const STATE = {
718
- employment: ["nómina", "tag"],
719
- internal: ["interno", "tag"],
718
+ employment: [tr("kind.employment"), "tag"],
719
+ internal: [tr("kind.internal"), "tag"],
720
720
  };
721
721
  const st = STATE[p.kind] ??
722
- (p.pendingApproval > 0 ? ["por revisar", "tag tag-warn"] : ["listo", "tag tag-ok"]);
722
+ (p.pendingApproval > 0 ? [tr("team.status.pending"), "tag tag-warn"] : [tr("team.status.ready"), "tag tag-ok"]);
723
723
 
724
724
  return `<tr>
725
725
  <td><div class="td-name">${esc(p.projectName)}</div>
@@ -739,10 +739,10 @@ function personRow(p) {
739
739
  : `<span class="td-sub">—</span>`;
740
740
 
741
741
  return `<tr>
742
- <td><div class="td-name">${esc(p.name)}${p.isMe ? ' <span class="tag">tú</span>' : ""}</div>
742
+ <td><div class="td-name">${esc(p.name)}${p.isMe ? ` <span class="tag">${tr("team.you")}</span>` : ""}</div>
743
743
  <div class="td-sub">${esc(p.emails[0] || "")}</div></td>
744
744
  <td class="num">${fmtDuration(p.seconds)}</td>
745
- <td><span class="prec ${p.measured ? "prec-ok" : ""}">${p.measured ? "medido" : "estimado"}</span></td>
745
+ <td><span class="prec ${p.measured ? "prec-ok" : ""}">${p.measured ? tr("team.measured") : tr("team.estimated")}</span></td>
746
746
  <td>${branches}</td>
747
747
  <td class="num td-sub">${esc(p.lastDay || "—")}</td>
748
748
  </tr>`;
@@ -752,7 +752,7 @@ function fillProjectFilter(projects) {
752
752
  const sel = $("#team-project");
753
753
  if (sel.dataset.filled === String(projects.length) && sel.value === state.teamProject) return;
754
754
  const seen = state.team.projects.map((p) => [p.projectId, p.projectName]);
755
- sel.innerHTML = `<option value="">Todos los proyectos</option>` +
755
+ sel.innerHTML = `<option value="">${tr("team.allProjects")}</option>` +
756
756
  seen.map(([id, name]) => `<option value="${esc(id)}">${esc(name)}</option>`).join("");
757
757
  sel.value = state.teamProject;
758
758
  sel.dataset.filled = String(projects.length);
@@ -803,20 +803,15 @@ function renderProjects(data, orphans) {
803
803
  const atrasados = (data.publications || []).filter((p) => p.staleBlocks > 0);
804
804
  if (atrasados.length) {
805
805
  parts.push(`<div class="stale">
806
- <p><b>${atrasados.length === 1 ? "Un panel compartido está" : atrasados.length + " paneles están"}
807
- desactualizado${atrasados.length === 1 ? "" : "s"}.</b>
808
- Tu cliente está viendo datos anteriores a tu último trabajo. Esto no
809
- tiene que ver con los bloques por revisar: aquí solo salen los
810
- proyectos que has compartido, y cuenta lo que cambió desde que lo
811
- publicaste, no lo que te falta por aprobar.</p>
806
+ <p><b>${tr("projects.stale.head", { n: atrasados.length })}</b>
807
+ ${tr("projects.stale.body")}</p>
812
808
  <ul>${atrasados.map((p) => {
813
809
  const nombre = (data.projects.find((x) => x.id === p.projectId) || {}).name || p.projectId;
814
- return `<li>${esc(nombre)} — ${p.staleBlocks}
815
- ${p.staleBlocks === 1 ? "bloque nuevo" : "bloques nuevos"}
816
- desde que lo publicaste ${p.daysAgo === 0 ? "hoy" :
817
- p.daysAgo === 1 ? "ayer" : "hace " + p.daysAgo + " días"}</li>`;
810
+ return `<li>${tr("projects.stale.item", {
811
+ name: esc(nombre), blocks: tr("blocks.new", { n: p.staleBlocks }), when: haceCuanto(p.daysAgo),
812
+ })}</li>`;
818
813
  }).join("")}</ul>
819
- <p class="stale-fix">Pulsa <b>Compartir con el cliente</b> en cada uno y vuelve a subirlo.</p>
814
+ <p class="stale-fix">${tr("projects.stale.fix")}</p>
820
815
  </div>`);
821
816
  }
822
817
 
@@ -825,7 +820,7 @@ function renderProjects(data, orphans) {
825
820
  const rate = p.kind === "client" ? fmtMoney(p.hourlyMinor, p.currency) : null;
826
821
  const history = p.rateHistory.length > 1
827
822
  ? `<div class="rate-hist">${p.rateHistory.map((r) =>
828
- `<span>${esc(fmtMoney(r.minor, r.currency))}/h desde ${esc(r.from)}</span>`).join("")}</div>`
823
+ `<span>${tr("rate.historyItem", { amount: esc(fmtMoney(r.minor, r.currency)), date: esc(r.from) })}</span>`).join("")}</div>`
829
824
  : "";
830
825
 
831
826
  parts.push(`
@@ -833,59 +828,56 @@ function renderProjects(data, orphans) {
833
828
  <div class="pcard-head">
834
829
  <div>
835
830
  <div class="pcard-name">${esc(p.name)}</div>
836
- <div class="pcard-meta">${esc(p.clientName)} · ${fmtDuration(p.seconds)} en ${p.blocks} bloques</div>
831
+ <div class="pcard-meta">${tr("projects.meta", {
832
+ client: esc(p.clientName), duration: fmtDuration(p.seconds), blocks: tr("blocks", { n: p.blocks }),
833
+ })}</div>
837
834
  </div>
838
835
  <div class="pcard-rate">
839
- <div class="pcard-rate-v">${rate ? esc(rate) + "/h" : "sin tarifa"}</div>
836
+ <div class="pcard-rate-v">${rate ? esc(rate) + "/h" : tr("row.noRate")}</div>
840
837
  </div>
841
838
  </div>
842
839
  ${p.repoPaths.length
843
840
  ? `<div class="repos">${p.repoPaths.map((r) => `<code>${esc(r)}</code>`).join("")}</div>`
844
841
  : `<div class="pcard-warn">
845
- <p>Sin repositorio: no se le imputa nada todavía.</p>
842
+ <p>${tr("projects.noRepo")}</p>
846
843
  ${orphans.length
847
844
  ? `<div class="row-inline">
848
845
  <select class="repo-pick" data-project="${esc(p.id)}">
849
846
  ${orphans.slice(0, 40).map((r) =>
850
- `<option value="${esc(r.path)}">${esc(r.name)} — ${esc(r.turns)} turnos, hasta ${esc(r.lastAt)}</option>`).join("")}
847
+ `<option value="${esc(r.path)}">${tr("projects.orphanOption", { name: esc(r.name), turns: esc(r.turns), last: esc(r.lastAt) })}</option>`).join("")}
851
848
  </select>
852
- <button type="button" class="chip repo-link" data-project="${esc(p.id)}">Vincular</button>
849
+ <button type="button" class="chip repo-link" data-project="${esc(p.id)}">${tr("projects.link")}</button>
853
850
  </div>`
854
- : `<p class="addform-hint">No hemos detectado ningún repositorio sin asignar.
855
- Trabaja un rato con tu agente y vuelve, o vincúlalo con
856
- <code>estela team repo --project ${esc(p.id)} --add &lt;ruta&gt;</code>.</p>`}
851
+ : `<p class="addform-hint">${tr("projects.noOrphans", { id: esc(p.id) })}</p>`}
857
852
  </div>`}
858
853
  ${history}
859
854
  <div class="share-block">
860
855
  <button type="button" class="chip share-btn${pubs.get(p.id) && pubs.get(p.id).staleBlocks > 0 ? " is-stale" : ""}">
861
- ${pubs.has(p.id) ? "Actualizar lo compartido" : "Compartir con el cliente"}
856
+ ${pubs.has(p.id) ? tr("share.update") : tr("share.share")}
862
857
  </button>
863
858
  <span class="share-hint">${
864
- !pubs.has(p.id) ? "Genera un panel de solo lectura con enlace no adivinable."
859
+ !pubs.has(p.id) ? tr("share.hintNew")
865
860
  : pubs.get(p.id).staleBlocks > 0
866
- ? `<b class="stale-mark">${pubs.get(p.id).staleBlocks} bloques nuevos</b> desde que lo publicaste.`
867
- : `Publicado ${
868
- pubs.get(p.id).daysAgo === 0 ? "hoy" :
869
- pubs.get(p.id).daysAgo === 1 ? "ayer" :
870
- "hace " + pubs.get(p.id).daysAgo + " días"} · al día.`
861
+ ? tr("share.hintStale", { blocks: tr("blocks.new", { n: pubs.get(p.id).staleBlocks }) })
862
+ : tr("share.hintFresh", { when: haceCuanto(pubs.get(p.id).daysAgo) })
871
863
  }</span>
872
864
  <div class="share-out" hidden></div>
873
865
  ${pubs.has(p.id) ? renderCompartir(p.id, pubs.get(p.id)) : ""}
874
866
  </div>
875
867
  <form class="rateform">
876
- <span>Cambiar tarifa a</span>
868
+ <span>${tr("rate.changeTo")}</span>
877
869
  <input type="number" class="rate-new" min="0" step="0.5"
878
870
  value="${p.hourlyMinor ? (p.hourlyMinor / 100) : ""}" placeholder="45">
879
- <span>${esc(p.currency)}/h desde</span>
871
+ <span>${tr("rate.from", { currency: esc(p.currency) })}</span>
880
872
  <input type="date" class="rate-from" value="${localToday()}">
881
- <button type="submit" class="chip">Guardar</button>
873
+ <button type="submit" class="chip">${tr("common.save")}</button>
882
874
  </form>
883
875
  </div>`);
884
876
  }
885
877
 
886
878
  if (!data.projects.length) {
887
- parts.push(`<div class="empty"><h2>Todavía no hay proyectos</h2>
888
- <p>Crea el primero abajo y asígnale el repositorio donde trabajas.</p></div>`);
879
+ parts.push(`<div class="empty"><h2>${tr("projects.empty.title")}</h2>
880
+ <p>${tr("projects.empty.body")}</p></div>`);
889
881
  }
890
882
 
891
883
  // Repos con trabajo capturado que no pertenecen a nadie. Es la lista más
@@ -895,51 +887,48 @@ function renderProjects(data, orphans) {
895
887
  const orphanCards = orphans.slice(0, 12).map((r) => `
896
888
  <button type="button" class="orphan" data-path="${esc(r.path)}" data-name="${esc(r.name)}">
897
889
  <span class="orphan-name">${esc(r.name)}</span>
898
- <span class="orphan-meta">${r.turns} turnos · hasta ${esc(r.lastAt)}</span>
890
+ <span class="orphan-meta">${tr("projects.orphanMeta", { turns: r.turns, last: esc(r.lastAt) })}</span>
899
891
  </button>`).join("");
900
892
 
901
893
  const orphanOptions = orphans.slice(0, 40)
902
- .map((r) => `<option value="${esc(r.path)}">${esc(r.name)} — ${r.turns} turnos, hasta ${esc(r.lastAt)}</option>`)
894
+ .map((r) => `<option value="${esc(r.path)}">${tr("projects.orphanOption", { name: esc(r.name), turns: r.turns, last: esc(r.lastAt) })}</option>`)
903
895
  .join("");
904
896
 
905
897
  const clientOptions = data.clients
906
898
  .map((c) => `<option value="${esc(c.id)}">${esc(c.name)} (${esc(c.currency)})</option>`).join("");
907
899
 
908
900
  parts.push(`
909
- <h2 class="block-title">Nuevo proyecto</h2>
901
+ <h2 class="block-title">${tr("newProject.title")}</h2>
910
902
  <form class="newproject" id="new-project">
911
- ${orphans.length ? `<p class="addform-hint">Tienes ${orphans.length} repositorios con trabajo
912
- capturado y sin proyecto. Son horas que ya registraste pero todavía no puedes informar.
913
- Pulsa uno para empezar:</p>
903
+ ${orphans.length ? `<p class="addform-hint">${tr("newProject.orphans", { n: orphans.length })}</p>
914
904
  <div class="orphans">${orphanCards}</div>` : ""}
915
905
  <label class="field">
916
- <span>Repositorio</span>
906
+ <span>${tr("newProject.repo")}</span>
917
907
  <select id="np-repo">
918
- <option value="">— elegir uno de los detectados —</option>
908
+ <option value="">${tr("newProject.pickRepo")}</option>
919
909
  ${orphanOptions}
920
910
  </select>
921
911
  </label>
922
912
  <div class="addrow">
923
- <label class="field"><span>Nombre del proyecto</span>
924
- <input id="np-name" type="text" placeholder="App de Acme" required></label>
925
- <label class="field"><span>Tarifa por hora</span>
913
+ <label class="field"><span>${tr("newProject.name")}</span>
914
+ <input id="np-name" type="text" placeholder="${tr("newProject.namePlaceholder")}" required></label>
915
+ <label class="field"><span>${tr("newProject.rate")}</span>
926
916
  <input id="np-rate" type="number" min="0" step="0.5" placeholder="45"></label>
927
917
  </div>
928
918
  <div class="addrow">
929
- <label class="field"><span>Cliente existente</span>
930
- <select id="np-client"><option value="">— cliente nuevo —</option>${clientOptions}</select></label>
931
- <label class="field"><span>…o cliente nuevo</span>
932
- <input id="np-clientname" type="text" placeholder="Acme S.L."></label>
933
- <label class="field np-cur"><span>Moneda</span>
919
+ <label class="field"><span>${tr("newProject.client")}</span>
920
+ <select id="np-client"><option value="">${tr("newProject.newClientOption")}</option>${clientOptions}</select></label>
921
+ <label class="field"><span>${tr("newProject.newClient")}</span>
922
+ <input id="np-clientname" type="text" placeholder="${tr("newProject.clientPlaceholder")}"></label>
923
+ <label class="field np-cur"><span>${tr("newProject.currency")}</span>
934
924
  <select id="np-currency">
935
925
  <option>EUR</option><option>USD</option><option>GBP</option>
936
926
  <option>MXN</option><option>COP</option><option>BRL</option>
937
927
  </select></label>
938
928
  </div>
939
- <div class="addactions"><button type="submit" class="btn-main">Crear proyecto</button></div>
929
+ <div class="addactions"><button type="submit" class="btn-main">${tr("newProject.submit")}</button></div>
940
930
  </form>
941
- <p class="note">Tras crear el proyecto, ejecuta <code>estela import</code> para que el
942
- trabajo ya capturado de ese repositorio se impute.</p>`);
931
+ <p class="note">${tr("newProject.note")}</p>`);
943
932
 
944
933
  $("#projects-body").innerHTML = parts.join("");
945
934
  wireProjects();
@@ -959,8 +948,7 @@ async function askAuthor(projectId, repoPath) {
959
948
  const box = document.createElement("div");
960
949
  box.className = "authorbox";
961
950
  box.innerHTML =
962
- `<p class="addform-hint">¿Con cuál de estos correos commiteas tú en este repositorio?
963
- Si eliges mal, se capturarán los commits de otra persona.</p>` +
951
+ `<p class="addform-hint">${tr("author.ask")}</p>` +
964
952
  authors.slice(0, 8).map((a) =>
965
953
  `<button type="button" class="chip author-pick" data-email="${esc(a.email)}">
966
954
  ${esc(a.email)} <span class="author-n">${a.commits}</span>
@@ -975,7 +963,7 @@ async function askAuthor(projectId, repoPath) {
975
963
  body: JSON.stringify({ projectId, emails: btn.dataset.email }),
976
964
  });
977
965
  await loadProjects();
978
- toast("Identidad guardada — ejecuta estela import");
966
+ toast(tr("toast.identitySaved"));
979
967
  });
980
968
  });
981
969
  }
@@ -1019,7 +1007,7 @@ function wireProjects() {
1019
1007
  }),
1020
1008
  });
1021
1009
  await loadProjects();
1022
- toast("Proyecto creado");
1010
+ toast(tr("toast.projectCreated"));
1023
1011
  // El correo de commit es lo primero que hay que configurar: sin él se
1024
1012
  // capturan los commits equivocados, o casi ninguno.
1025
1013
  if ($("#np-repo").value) await askAuthor(nuevo.id, $("#np-repo").value);
@@ -1041,8 +1029,8 @@ function wireProjects() {
1041
1029
  body: JSON.stringify({ projectId, repoPath: sel.value }),
1042
1030
  });
1043
1031
  toast(r.importing
1044
- ? "Vinculado · importando, aparecerá en unos segundos"
1045
- : (r.blocks ? `Vinculado · ${r.blocks} bloques imputados` : "Repositorio vinculado"));
1032
+ ? tr("toast.linkedImporting")
1033
+ : (r.blocks ? tr("toast.linkedBlocks", { blocks: tr("blocks", { n: r.blocks }) }) : tr("toast.repoLinked")));
1046
1034
  await loadProjects();
1047
1035
  } catch (error) { toast(error.message); btn.disabled = false; }
1048
1036
  });
@@ -1051,12 +1039,12 @@ function wireProjects() {
1051
1039
  document.querySelectorAll("[data-copy-url]").forEach((btn) => {
1052
1040
  btn.addEventListener("click", async () => {
1053
1041
  const url = btn.closest(".share-ways").dataset.url;
1054
- try { await navigator.clipboard.writeText(url); toast("Enlace copiado"); }
1042
+ try { await navigator.clipboard.writeText(url); toast(tr("toast.linkCopied")); }
1055
1043
  catch {
1056
1044
  const ta = document.createElement("textarea");
1057
1045
  ta.value = url; document.body.appendChild(ta); ta.select();
1058
- try { document.execCommand("copy"); toast("Enlace copiado"); }
1059
- catch { toast("No se pudo copiar"); }
1046
+ try { document.execCommand("copy"); toast(tr("toast.linkCopied")); }
1047
+ catch { toast(tr("toast.copyFailed")); }
1060
1048
  document.body.removeChild(ta);
1061
1049
  }
1062
1050
  });
@@ -1076,11 +1064,11 @@ function wireProjects() {
1076
1064
  method: "POST",
1077
1065
  body: JSON.stringify({ token: caja.dataset.token, clients }),
1078
1066
  });
1079
- toast(clients.length ? "Compartido — le llega un correo" : "Ya no se comparte con nadie");
1067
+ toast(clients.length ? tr("toast.sharedByEmail") : tr("toast.sharedWithNobody"));
1080
1068
  await loadProjects();
1081
1069
  } catch (error) {
1082
1070
  msg.textContent = error.body?.needsLogin
1083
- ? "Hace falta vincular una cuenta: estela login --email tu@correo.com"
1071
+ ? tr("needsLogin.cmd")
1084
1072
  : error.message;
1085
1073
  btn.disabled = false;
1086
1074
  }
@@ -1107,11 +1095,11 @@ function wireProjects() {
1107
1095
 
1108
1096
  out.hidden = false;
1109
1097
  out.innerHTML =
1110
- '<p class="share-line"><b>' + (r.adopted ? "Panel vinculado" : "Panel publicado") +
1098
+ '<p class="share-line"><b>' + (r.adopted ? tr("publish.linked") : tr("publish.published")) +
1111
1099
  '</b></p>' +
1112
- '<p class="share-line">Enlace: <code>' + esc(r.url) + '</code></p>' +
1113
- '<p class="share-warn">Quien tenga el enlace, entra: no hay contraseña.</p>';
1114
- toast(r.adopted ? "Vinculado a tu cuenta" : "Panel publicado");
1100
+ '<p class="share-line">' + tr("publish.link") + ' <code>' + esc(r.url) + '</code></p>' +
1101
+ '<p class="share-warn">' + tr("publish.noPassword") + '</p>';
1102
+ toast(r.adopted ? tr("toast.linkedToAccount") : tr("publish.published"));
1115
1103
 
1116
1104
  // Publicar pone a cero los "bloques nuevos desde que lo publicaste",
1117
1105
  // pero ese número vive en `state`, que se quedaba con el valor de
@@ -1131,10 +1119,10 @@ function wireProjects() {
1131
1119
  if (error.body?.needsLogin) {
1132
1120
  out2.hidden = false;
1133
1121
  out2.innerHTML =
1134
- '<p class="share-line"><b>Hace falta vincular una cuenta</b></p>' +
1135
- '<p class="share-line">Publicar aloja el panel en getestela.dev, y eso exige sesión:</p>' +
1136
- '<code class="cmd">estela login --email tu@correo.com</code>' +
1137
- '<p class="share-warn">Se ejecuta una vez, en la terminal. Luego vuelve a pulsar aquí.</p>';
1122
+ '<p class="share-line"><b>' + tr("publish.needsLogin.title") + '</b></p>' +
1123
+ '<p class="share-line">' + tr("publish.needsLogin.why") + '</p>' +
1124
+ '<code class="cmd">' + tr("publish.needsLogin.cmd") + '</code>' +
1125
+ '<p class="share-warn">' + tr("publish.needsLogin.once") + '</p>';
1138
1126
  } else {
1139
1127
  toast(error.message);
1140
1128
  }
@@ -1147,7 +1135,7 @@ function wireProjects() {
1147
1135
  e.preventDefault();
1148
1136
  const projectId = form.closest(".pcard").dataset.project;
1149
1137
  const value = Number(form.querySelector(".rate-new").value);
1150
- if (!(value > 0)) { toast("Escribe una tarifa"); return; }
1138
+ if (!(value > 0)) { toast(tr("toast.enterRate")); return; }
1151
1139
  try {
1152
1140
  await api("/api/rate", {
1153
1141
  method: "POST",
@@ -1158,7 +1146,7 @@ function wireProjects() {
1158
1146
  }),
1159
1147
  });
1160
1148
  await loadProjects();
1161
- toast("Tarifa guardada — lo ya trabajado conserva la anterior");
1149
+ toast(tr("toast.rateSaved"));
1162
1150
  } catch (error) { toast(error.message); }
1163
1151
  });
1164
1152
  });
@@ -1187,25 +1175,25 @@ function renderReports() {
1187
1175
  const parts = [];
1188
1176
 
1189
1177
  parts.push(`<div class="filters" id="rep-periods">
1190
- <button type="button" class="period${state.period === "week" ? " is-on" : ""}" data-period="week">Esta semana</button>
1191
- <button type="button" class="period${state.period === "month" ? " is-on" : ""}" data-period="month">Este mes</button>
1192
- <button type="button" class="period${state.period === "quarter" ? " is-on" : ""}" data-period="quarter">3 meses</button>
1193
- <button type="button" class="period${state.period === "all" ? " is-on" : ""}" data-period="all">Todo lo pendiente</button>
1178
+ <button type="button" class="period${state.period === "week" ? " is-on" : ""}" data-period="week">${tr("period.week")}</button>
1179
+ <button type="button" class="period${state.period === "month" ? " is-on" : ""}" data-period="month">${tr("period.month")}</button>
1180
+ <button type="button" class="period${state.period === "quarter" ? " is-on" : ""}" data-period="quarter">${tr("period.quarter")}</button>
1181
+ <button type="button" class="period${state.period === "all" ? " is-on" : ""}" data-period="all">${tr("period.all")}</button>
1194
1182
  </div>`);
1195
1183
 
1196
1184
  const fuera = overview.outsideRange;
1197
1185
  if (fuera && fuera.blocks > 0) {
1198
- parts.push(`<p class="note">Hay además <b>${fmtDuration(fuera.seconds)}</b> pendientes
1199
- fuera de este periodo, en ${fuera.blocks} bloques. Pulsa
1200
- <b>Todo lo pendiente</b> para incluirlos.</p>`);
1186
+ parts.push(`<p class="note">${tr("reports.outside", {
1187
+ duration: fmtDuration(fuera.seconds), blocks: tr("blocks", { n: fuera.blocks }), all: tr("period.all"),
1188
+ })}</p>`);
1201
1189
  }
1202
1190
 
1203
1191
  if (overview.warning) parts.push(`<div class="warn">${esc(overview.warning)}</div>`);
1204
1192
 
1205
1193
  if (overview.unbilled.length === 0) {
1206
1194
  parts.push(`<div class="empty">
1207
- <h2>Nada que informar todavía</h2>
1208
- <p>Aquí aparecerán los proyectos con horas aprobadas y sin informar.</p>
1195
+ <h2>${tr("reports.empty.title")}</h2>
1196
+ <p>${tr("reports.empty.body")}</p>
1209
1197
  <code>estela status</code>
1210
1198
  </div>`);
1211
1199
  } else {
@@ -1218,11 +1206,11 @@ function renderReports() {
1218
1206
 
1219
1207
  if (overview.unbilled.length > REPORT_ROWS || q) {
1220
1208
  parts.push(`<input type="search" class="search" id="rep-search"
1221
- placeholder="Buscar proyecto o cliente…" value="${esc(state.reportsQuery)}">`);
1209
+ placeholder="${tr("reports.search")}" value="${esc(state.reportsQuery)}">`);
1222
1210
  }
1223
1211
 
1224
1212
  if (q && matches.length === 0) {
1225
- parts.push(`<p class="note">Ningún proyecto coincide con «${esc(state.reportsQuery)}».</p>`);
1213
+ parts.push(`<p class="note">${tr("reports.noMatch", { q: esc(state.reportsQuery) })}</p>`);
1226
1214
  }
1227
1215
 
1228
1216
  const shown = state.reportsAll ? matches : matches.slice(0, REPORT_ROWS);
@@ -1235,8 +1223,9 @@ function renderReports() {
1235
1223
  <button type="button" class="report-head" data-toggle-report="${esc(row.projectId)}" aria-expanded="${open}">
1236
1224
  <div class="report-text">
1237
1225
  <div class="report-name">${esc(row.projectName)}</div>
1238
- <div class="report-client">${esc(row.clientName)} · ${row.blocks} bloques ·
1239
- todo lo pendiente desde ${esc(row.firstDay)}</div>
1226
+ <div class="report-client">${tr("reports.client", {
1227
+ client: esc(row.clientName), blocks: tr("blocks", { n: row.blocks }), date: esc(row.firstDay),
1228
+ })}</div>
1240
1229
  </div>
1241
1230
  <div class="report-figs">
1242
1231
  <div class="report-hours">${fmtDuration(row.seconds)}</div>
@@ -1246,28 +1235,27 @@ function renderReports() {
1246
1235
  </button>
1247
1236
  ${open ? `
1248
1237
  <form class="report-actions" data-project="${esc(row.projectId)}">
1249
- <label class="field"><span>Desde</span>
1238
+ <label class="field"><span>${tr("reports.from")}</span>
1250
1239
  <input type="date" class="rep-from" value="${esc(row.firstDay)}"></label>
1251
- <label class="field"><span>Hasta</span>
1240
+ <label class="field"><span>${tr("reports.to")}</span>
1252
1241
  <input type="date" class="rep-to" value="${localToday()}"></label>
1253
- <label class="field"><span>Tu nombre</span>
1254
- <input type="text" class="rep-author" placeholder="Jonathan León"></label>
1242
+ <label class="field"><span>${tr("reports.author")}</span>
1243
+ <input type="text" class="rep-author" placeholder="${tr("reports.authorPlaceholder")}"></label>
1255
1244
  <label class="checkline"><input type="checkbox" class="rep-amounts">
1256
- <span>Incluir importes</span></label>
1257
- <button type="submit" class="btn-main">Descargar informe</button>
1245
+ <span>${tr("reports.amounts")}</span></label>
1246
+ <button type="submit" class="btn-main">${tr("reports.download")}</button>
1258
1247
  </form>` : ""}
1259
1248
  </div>`);
1260
1249
  }
1261
1250
 
1262
1251
  if (!state.reportsAll && matches.length > REPORT_ROWS) {
1263
1252
  parts.push(`<button type="button" class="more" id="reports-more">
1264
- Ver ${matches.length - REPORT_ROWS} proyecto${matches.length - REPORT_ROWS === 1 ? "" : "s"} más</button>`);
1253
+ ${tr("reports.more", { n: matches.length - REPORT_ROWS })}</button>`);
1265
1254
  } else if (state.reportsAll && matches.length > REPORT_ROWS) {
1266
- parts.push(`<button type="button" class="more" id="reports-more">Ver menos</button>`);
1255
+ parts.push(`<button type="button" class="more" id="reports-more">${tr("common.showLess")}</button>`);
1267
1256
  }
1268
1257
 
1269
- parts.push(`<p class="note">El informe recoge horas y commits. No es una factura:
1270
- Estela no emite documentos fiscales, adjunta este respaldo a la tuya.</p>`);
1258
+ parts.push(`<p class="note">${tr("reports.note")}</p>`);
1271
1259
  }
1272
1260
 
1273
1261
  $("#reports-body").innerHTML = parts.join("");
@@ -1317,7 +1305,7 @@ function renderReports() {
1317
1305
  // El servidor lo devuelve con Content-Disposition, así que el navegador
1318
1306
  // lo descarga en vez de abrirlo.
1319
1307
  window.location.href = `/api/report?${q}`;
1320
- toast("Informe descargado");
1308
+ toast(tr("toast.reportDownloaded"));
1321
1309
  });
1322
1310
  });
1323
1311
  }
@@ -1346,19 +1334,18 @@ async function wireSync() {
1346
1334
  btn.addEventListener("click", async () => {
1347
1335
  btn.disabled = true;
1348
1336
  const antes = btn.textContent;
1349
- btn.textContent = "Sincronizando…";
1337
+ btn.textContent = tr("sync.syncing");
1350
1338
  try {
1351
1339
  const r = await api("/api/sync", { method: "POST" });
1352
1340
  const bien = r.projects.filter((p) => p.ok).length;
1353
1341
  const mal = r.projects.filter((p) => !p.ok);
1354
1342
  toast(mal.length
1355
- ? `${bien} sincronizados · ${mal[0].projectId}: ${mal[0].detalle}`
1356
- : (bien ? `${bien} ${bien === 1 ? "proyecto sincronizado" : "proyectos sincronizados"}`
1357
- : "No hay proyectos con sync activado"));
1343
+ ? tr("sync.partial", { ok: bien, project: mal[0].projectId, detail: mal[0].detalle })
1344
+ : (bien ? tr("sync.done", { n: bien }) : tr("sync.none")));
1358
1345
  if (state.view === "projects") await loadProjects();
1359
1346
  } catch (error) {
1360
1347
  toast(error.body?.needsLogin
1361
- ? "Hace falta vincular una cuenta: estela login --email tu@correo.com"
1348
+ ? tr("needsLogin.cmd")
1362
1349
  : error.message);
1363
1350
  }
1364
1351
  btn.textContent = antes;
@@ -1419,7 +1406,7 @@ $("#approve-btn").addEventListener("click", async () => {
1419
1406
  body: JSON.stringify({ date: state.date }),
1420
1407
  });
1421
1408
  await loadDay();
1422
- toast(approved === 1 ? "1 bloque aprobado" : `${approved} bloques aprobados`);
1409
+ toast(tr("toast.blocksApproved", { n: approved }));
1423
1410
  } catch (error) {
1424
1411
  toast(error.message);
1425
1412
  }
@@ -1452,7 +1439,7 @@ function reloadView() {
1452
1439
  function showSync(status) {
1453
1440
  const warn = $("#sync-warn");
1454
1441
  if (status && status.ok === false) {
1455
- warn.textContent = "No se pudo actualizar";
1442
+ warn.textContent = tr("refresh.failed");
1456
1443
  warn.title = status.error || "";
1457
1444
  warn.hidden = false;
1458
1445
  } else {
@@ -1468,7 +1455,7 @@ async function sync({ force }) {
1468
1455
  const btn = $("#sync");
1469
1456
  const label = $("#sync-label");
1470
1457
  btn.classList.add("is-busy");
1471
- label.textContent = "Buscando…";
1458
+ label.textContent = tr("refresh.searching");
1472
1459
  try {
1473
1460
  // force = el botón: pide un import nuevo. Sin force solo se consulta cómo
1474
1461
  // fue el último, que es lo que basta al volver a la pestaña.
@@ -1481,7 +1468,7 @@ async function sync({ force }) {
1481
1468
  showSync({ ok: false, error: String(error) });
1482
1469
  } finally {
1483
1470
  btn.classList.remove("is-busy");
1484
- label.textContent = "Actualizar";
1471
+ label.textContent = tr("refresh.label");
1485
1472
  syncing = false;
1486
1473
  }
1487
1474
  }
@@ -1563,7 +1550,7 @@ document.querySelectorAll(".upgrade-option").forEach((btn) => {
1563
1550
  // criterio que ya usa /api/publish para distinguir "hace falta
1564
1551
  // vincular cuenta" de un error genérico.
1565
1552
  toast(error.body?.needsLogin
1566
- ? "Vincula tu cuenta primero: estela login --email tu@correo.com"
1553
+ ? tr("upgrade.needsLogin")
1567
1554
  : error.message);
1568
1555
  } finally {
1569
1556
  btn.disabled = false;
@@ -1593,6 +1580,9 @@ $("#banner-close").addEventListener("click", () => {
1593
1580
  $("#banner-promo").hidden = true;
1594
1581
  });
1595
1582
 
1583
+ // Cambiar de idioma recarga: todo lo ya pintado sale de nuevo en el otro.
1584
+ $("#lang-switch").addEventListener("click", () => switchLang());
1585
+
1596
1586
  // ── Arranque ───────────────────────────────────────────────────────────
1597
1587
 
1598
1588
  /* Arranca en el resumen: la primera pregunta al abrir es "¿cómo va todo?",