estela 0.1.3 → 0.1.6

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 (76) hide show
  1. package/dist/billing/sessionize.d.ts +10 -1
  2. package/dist/billing/sessionize.d.ts.map +1 -1
  3. package/dist/billing/sessionize.js +0 -0
  4. package/dist/billing/sessionize.js.map +1 -1
  5. package/dist/billing/sessionize.test.js +28 -0
  6. package/dist/billing/sessionize.test.js.map +1 -1
  7. package/dist/billing.d.ts +16 -0
  8. package/dist/billing.d.ts.map +1 -0
  9. package/dist/billing.js +71 -0
  10. package/dist/billing.js.map +1 -0
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +345 -93
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cloud/client.d.ts +6 -0
  15. package/dist/cloud/client.d.ts.map +1 -1
  16. package/dist/cloud/client.js +26 -0
  17. package/dist/cloud/client.js.map +1 -1
  18. package/dist/db/store.d.ts +56 -0
  19. package/dist/db/store.d.ts.map +1 -1
  20. package/dist/db/store.js +101 -0
  21. package/dist/db/store.js.map +1 -1
  22. package/dist/export/panel.d.ts +3 -1
  23. package/dist/export/panel.d.ts.map +1 -1
  24. package/dist/export/panel.js +24 -19
  25. package/dist/export/panel.js.map +1 -1
  26. package/dist/export/panel.test.js +46 -5
  27. package/dist/export/panel.test.js.map +1 -1
  28. package/dist/metrics/team.d.ts +9 -0
  29. package/dist/metrics/team.d.ts.map +1 -1
  30. package/dist/metrics/team.js +0 -0
  31. package/dist/metrics/team.js.map +1 -1
  32. package/dist/publish.d.ts +37 -0
  33. package/dist/publish.d.ts.map +1 -0
  34. package/dist/publish.js +185 -0
  35. package/dist/publish.js.map +1 -0
  36. package/dist/publish.test.d.ts +2 -0
  37. package/dist/publish.test.d.ts.map +1 -0
  38. package/dist/publish.test.js +91 -0
  39. package/dist/publish.test.js.map +1 -0
  40. package/dist/server.d.ts.map +1 -1
  41. package/dist/server.js +88 -58
  42. package/dist/server.js.map +1 -1
  43. package/dist/sync/team.d.ts +21 -0
  44. package/dist/sync/team.d.ts.map +1 -0
  45. package/dist/sync/team.js +68 -0
  46. package/dist/sync/team.js.map +1 -0
  47. package/dist/sync/team.test.d.ts +2 -0
  48. package/dist/sync/team.test.d.ts.map +1 -0
  49. package/dist/sync/team.test.js +102 -0
  50. package/dist/sync/team.test.js.map +1 -0
  51. package/dist/sync.d.ts +20 -0
  52. package/dist/sync.d.ts.map +1 -0
  53. package/dist/sync.js +79 -0
  54. package/dist/sync.js.map +1 -0
  55. package/dist/sync.test.d.ts +2 -0
  56. package/dist/sync.test.d.ts.map +1 -0
  57. package/dist/sync.test.js +151 -0
  58. package/dist/sync.test.js.map +1 -0
  59. package/dist/team.d.ts +51 -0
  60. package/dist/team.d.ts.map +1 -0
  61. package/dist/team.js +122 -0
  62. package/dist/team.js.map +1 -0
  63. package/dist/team.test.d.ts +2 -0
  64. package/dist/team.test.d.ts.map +1 -0
  65. package/dist/team.test.js +163 -0
  66. package/dist/team.test.js.map +1 -0
  67. package/dist/web-ui.test.js +14 -1
  68. package/dist/web-ui.test.js.map +1 -1
  69. package/package.json +1 -1
  70. package/web/app.css +74 -16
  71. package/web/app.js +198 -38
  72. package/web/apple-touch-icon.png +0 -0
  73. package/web/favicon.png +0 -0
  74. package/web/index.html +18 -2
  75. package/web/logo.png +0 -0
  76. package/web/logo-mark.png +0 -0
package/web/app.js CHANGED
@@ -36,7 +36,15 @@ async function api(path, options) {
36
36
  ...options,
37
37
  });
38
38
  const body = await res.json();
39
- if (!res.ok) throw new Error(body.error || `Error ${res.status}`);
39
+ if (!res.ok) {
40
+ // El cuerpo entero viaja con el error, no solo el mensaje: quien llama a
41
+ // veces necesita distinguir un tipo de fallo de otro (por ejemplo,
42
+ // "falta vincular cuenta" de "cuota agotada"), y con solo el texto no
43
+ // hay forma fiable de hacerlo sin analizar frases.
44
+ const error = new Error(body.error || `Error ${res.status}`);
45
+ error.body = body;
46
+ throw error;
47
+ }
40
48
  return body;
41
49
  }
42
50
 
@@ -937,38 +945,55 @@ function wireProjects() {
937
945
  document.querySelectorAll(".share-btn").forEach((btn) => {
938
946
  btn.addEventListener("click", async () => {
939
947
  const card = btn.closest(".pcard");
948
+ const projectId = card.dataset.project;
940
949
  const out = card.querySelector(".share-out");
941
950
  btn.disabled = true;
942
951
  try {
952
+ // El token ya vive en la base de datos local (una fila por proyecto,
953
+ // se lee de state.projects.publications): guardarlo también en
954
+ // localStorage sería una segunda copia que puede desincronizarse de
955
+ // la real, y lo hizo — un token de una prueba vieja en el navegador
956
+ // pisó el que de verdad estaba publicado.
957
+ const known = state.projects?.publications?.find((p) => p.projectId === projectId);
943
958
  const r = await api("/api/publish", {
944
959
  method: "POST",
945
- body: JSON.stringify({
946
- projectId: card.dataset.project,
947
- baseUrl: localStorage.getItem("estela.baseUrl") || "",
948
- token: localStorage.getItem("estela.token." + card.dataset.project) || "",
949
- }),
960
+ body: JSON.stringify({ projectId, token: known?.token || "" }),
950
961
  });
951
- // Guardar el token: republicar con otro le rompería el enlace al cliente.
952
- localStorage.setItem("estela.token." + card.dataset.project, r.token);
953
962
 
954
963
  out.hidden = false;
955
964
  out.innerHTML =
956
- '<p class="share-line"><b>Panel generado</b> — ' + r.sizeKb + ' KB, un solo fichero.</p>' +
957
- (r.url
958
- ? '<p class="share-line">Enlace: <code>' + esc(r.url) + '</code></p>'
959
- : '<label class="field"><span>Tu dominio (se recuerda)</span>' +
960
- '<input class="base-url" type="text" placeholder="https://estela.tudominio.com"></label>') +
961
- '<p class="share-line">1. Súbelo:</p><code class="cmd">' + esc(r.uploadCommand) + '</code>' +
962
- '<p class="share-warn">Quien tenga el enlace, entra: no hay contraseña. Para revocarlo, ' +
963
- 'borra <code>e/' + esc(r.token) + '/</code> del servidor.</p>';
964
-
965
- out.querySelector(".base-url")?.addEventListener("change", (e) => {
966
- localStorage.setItem("estela.baseUrl", e.target.value.trim());
967
- btn.click();
968
- });
969
- toast("Panel generado en ~/.estela/publicar");
970
- } catch (error) { toast(error.message); }
971
- finally { btn.disabled = false; }
965
+ '<p class="share-line"><b>' + (r.adopted ? "Panel vinculado" : "Panel publicado") +
966
+ '</b></p>' +
967
+ '<p class="share-line">Enlace: <code>' + esc(r.url) + '</code></p>' +
968
+ '<p class="share-warn">Quien tenga el enlace, entra: no hay contraseña.</p>';
969
+ toast(r.adopted ? "Vinculado a tu cuenta" : "Panel publicado");
970
+
971
+ // Publicar pone a cero los "bloques nuevos desde que lo publicaste",
972
+ // pero ese número vive en `state`, que se quedaba con el valor de
973
+ // antes: el aviso seguía ahí después de resolverlo, y solo se iba
974
+ // recargando la página a mano. Se vuelve a pedir el estado y se
975
+ // repinta, conservando el mensaje del enlace recién generado.
976
+ const recien = out.innerHTML;
977
+ await loadProjects();
978
+ const card2 = document.querySelector(`.pcard[data-project="${CSS.escape(projectId)}"]`);
979
+ const out3 = card2?.querySelector(".share-out");
980
+ if (out3) { out3.hidden = false; out3.innerHTML = recien; }
981
+ } catch (error) {
982
+ const out2 = card.querySelector(".share-out");
983
+ // "Sin cuenta" no es un error cualquiera: se enseña con el comando
984
+ // exacto para resolverlo, no como un aviso genérico que hay que
985
+ // interpretar. `needsLogin` viaja en el cuerpo desde /api/publish.
986
+ if (error.body?.needsLogin) {
987
+ out2.hidden = false;
988
+ out2.innerHTML =
989
+ '<p class="share-line"><b>Hace falta vincular una cuenta</b></p>' +
990
+ '<p class="share-line">Publicar aloja el panel en getestela.dev, y eso exige sesión:</p>' +
991
+ '<code class="cmd">estela login --email tu@correo.com</code>' +
992
+ '<p class="share-warn">Se ejecuta una vez, en la terminal. Luego vuelve a pulsar aquí.</p>';
993
+ } else {
994
+ toast(error.message);
995
+ }
996
+ } finally { btn.disabled = false; }
972
997
  });
973
998
  });
974
999
 
@@ -996,11 +1021,24 @@ function wireProjects() {
996
1021
 
997
1022
  // ── Informes ───────────────────────────────────────────────────────────
998
1023
 
1024
+ /* Plegado por defecto: fecha y botón de descarga solo aparecen al pulsar el
1025
+ proyecto, igual que un bloque de Mi día. Con muchos proyectos, la lista
1026
+ entera desplegada era una columna larga sin forma de encontrar nada. */
1027
+ const REPORT_ROWS = 5;
1028
+ state.reportsQuery = "";
1029
+ state.reportsAll = false;
1030
+ state.reportsOpen = new Set();
1031
+
999
1032
  async function loadReports() {
1000
1033
  // Mismo periodo que Resumen: si las dos pantallas contestan a la misma
1001
1034
  // pregunta, tienen que dar el mismo número.
1002
1035
  const { from, to } = periodRange(state.period);
1003
- const overview = await api(`/api/overview?from=${from}&to=${to}`);
1036
+ state.reportsOverview = await api(`/api/overview?from=${from}&to=${to}`);
1037
+ renderReports();
1038
+ }
1039
+
1040
+ function renderReports() {
1041
+ const overview = state.reportsOverview;
1004
1042
  const parts = [];
1005
1043
 
1006
1044
  parts.push(`<div class="filters" id="rep-periods">
@@ -1026,19 +1064,42 @@ async function loadReports() {
1026
1064
  <code>estela status</code>
1027
1065
  </div>`);
1028
1066
  } else {
1029
- for (const row of overview.unbilled) {
1067
+ // Filtra en el propio navegador: ya se tiene la lista entera cargada, y
1068
+ // un buscador que hiciera una petición por letra tecleada sería más lento
1069
+ // que no tenerlo.
1070
+ const q = state.reportsQuery.trim().toLowerCase();
1071
+ const matches = !q ? overview.unbilled : overview.unbilled.filter((row) =>
1072
+ row.projectName.toLowerCase().includes(q) || row.clientName.toLowerCase().includes(q));
1073
+
1074
+ if (overview.unbilled.length > REPORT_ROWS || q) {
1075
+ parts.push(`<input type="search" class="search" id="rep-search"
1076
+ placeholder="Buscar proyecto o cliente…" value="${esc(state.reportsQuery)}">`);
1077
+ }
1078
+
1079
+ if (q && matches.length === 0) {
1080
+ parts.push(`<p class="note">Ningún proyecto coincide con «${esc(state.reportsQuery)}».</p>`);
1081
+ }
1082
+
1083
+ const shown = state.reportsAll ? matches : matches.slice(0, REPORT_ROWS);
1084
+
1085
+ for (const row of shown) {
1030
1086
  const amount = fmtMoney(row.amountMinor, row.currency);
1087
+ const open = state.reportsOpen.has(row.projectId);
1031
1088
  parts.push(`
1032
- <div class="report">
1033
- <div class="report-text">
1034
- <div class="report-name">${esc(row.projectName)}</div>
1035
- <div class="report-client">${esc(row.clientName)} · ${row.blocks} bloques ·
1036
- todo lo pendiente desde ${esc(row.firstDay)}</div>
1037
- </div>
1038
- <div class="report-figs">
1039
- <div class="report-hours">${fmtDuration(row.seconds)}</div>
1040
- <div class="report-amount">${amount || "—"}</div>
1041
- </div>
1089
+ <div class="report${open ? " is-open" : ""}">
1090
+ <button type="button" class="report-head" data-toggle-report="${esc(row.projectId)}" aria-expanded="${open}">
1091
+ <div class="report-text">
1092
+ <div class="report-name">${esc(row.projectName)}</div>
1093
+ <div class="report-client">${esc(row.clientName)} · ${row.blocks} bloques ·
1094
+ todo lo pendiente desde ${esc(row.firstDay)}</div>
1095
+ </div>
1096
+ <div class="report-figs">
1097
+ <div class="report-hours">${fmtDuration(row.seconds)}</div>
1098
+ <div class="report-amount">${amount || "—"}</div>
1099
+ </div>
1100
+ <span class="chev">›</span>
1101
+ </button>
1102
+ ${open ? `
1042
1103
  <form class="report-actions" data-project="${esc(row.projectId)}">
1043
1104
  <label class="field"><span>Desde</span>
1044
1105
  <input type="date" class="rep-from" value="${esc(row.firstDay)}"></label>
@@ -1049,9 +1110,17 @@ async function loadReports() {
1049
1110
  <label class="checkline"><input type="checkbox" class="rep-amounts">
1050
1111
  <span>Incluir importes</span></label>
1051
1112
  <button type="submit" class="btn-main">Descargar informe</button>
1052
- </form>
1113
+ </form>` : ""}
1053
1114
  </div>`);
1054
1115
  }
1116
+
1117
+ if (!state.reportsAll && matches.length > REPORT_ROWS) {
1118
+ parts.push(`<button type="button" class="more" id="reports-more">
1119
+ Ver ${matches.length - REPORT_ROWS} proyecto${matches.length - REPORT_ROWS === 1 ? "" : "s"} más</button>`);
1120
+ } else if (state.reportsAll && matches.length > REPORT_ROWS) {
1121
+ parts.push(`<button type="button" class="more" id="reports-more">Ver menos</button>`);
1122
+ }
1123
+
1055
1124
  parts.push(`<p class="note">El informe recoge horas y commits. No es una factura:
1056
1125
  Estela no emite documentos fiscales, adjunta este respaldo a la tuya.</p>`);
1057
1126
  }
@@ -1059,7 +1128,35 @@ async function loadReports() {
1059
1128
  $("#reports-body").innerHTML = parts.join("");
1060
1129
 
1061
1130
  document.querySelectorAll("#rep-periods .period").forEach((btn) => {
1062
- btn.addEventListener("click", () => { state.period = btn.dataset.period; loadReports(); });
1131
+ btn.addEventListener("click", () => {
1132
+ state.period = btn.dataset.period; state.reportsAll = false; loadReports();
1133
+ });
1134
+ });
1135
+
1136
+ const search = $("#rep-search");
1137
+ search?.addEventListener("input", () => {
1138
+ state.reportsQuery = search.value;
1139
+ state.reportsAll = false;
1140
+ renderReports();
1141
+ // renderReports() reconstruye el input entero: sin esto, el foco salta
1142
+ // fuera de la caja de búsqueda con cada letra que se escribe.
1143
+ const again = $("#rep-search");
1144
+ again.focus();
1145
+ again.setSelectionRange(again.value.length, again.value.length);
1146
+ });
1147
+
1148
+ $("#reports-more")?.addEventListener("click", () => {
1149
+ state.reportsAll = !state.reportsAll;
1150
+ renderReports();
1151
+ });
1152
+
1153
+ document.querySelectorAll("[data-toggle-report]").forEach((btn) => {
1154
+ btn.addEventListener("click", () => {
1155
+ const id = btn.dataset.toggleReport;
1156
+ if (state.reportsOpen.has(id)) state.reportsOpen.delete(id);
1157
+ else state.reportsOpen.add(id);
1158
+ renderReports();
1159
+ });
1063
1160
  });
1064
1161
 
1065
1162
  document.querySelectorAll(".report-actions").forEach((form) => {
@@ -1239,11 +1336,74 @@ document.addEventListener("keydown", (e) => {
1239
1336
  if (e.key === "ArrowRight" && !$("#next-day").disabled) moveDay(1);
1240
1337
  });
1241
1338
 
1339
+ // ── Upgrade y banner de ofertas ───────────────────────────────────────
1340
+
1341
+ async function loadAccountUpgrade() {
1342
+ try {
1343
+ const account = await api("/api/account");
1344
+ $("#upgrade-wrap").hidden = account.plan === "pro" || account.plan === "teams";
1345
+ } catch {
1346
+ // Si no se puede saber el plan, mejor no ofrecer un botón que no va a
1347
+ // completar la compra.
1348
+ $("#upgrade-wrap").hidden = true;
1349
+ }
1350
+ }
1351
+
1352
+ $("#upgrade-btn").addEventListener("click", () => {
1353
+ $("#upgrade-menu").hidden = !$("#upgrade-menu").hidden;
1354
+ });
1355
+
1356
+ document.querySelectorAll(".upgrade-option").forEach((btn) => {
1357
+ btn.addEventListener("click", async () => {
1358
+ btn.disabled = true;
1359
+ try {
1360
+ const { url } = await api("/api/billing/checkout", {
1361
+ method: "POST", body: JSON.stringify({ plan: btn.dataset.plan }),
1362
+ });
1363
+ window.open(url, "_blank", "noopener");
1364
+ $("#upgrade-menu").hidden = true;
1365
+ } catch (error) {
1366
+ // needsLogin viaja en el cuerpo desde /api/billing/checkout, mismo
1367
+ // criterio que ya usa /api/publish para distinguir "hace falta
1368
+ // vincular cuenta" de un error genérico.
1369
+ toast(error.body?.needsLogin
1370
+ ? "Vincula tu cuenta primero: estela login --email tu@correo.com"
1371
+ : error.message);
1372
+ } finally {
1373
+ btn.disabled = false;
1374
+ }
1375
+ });
1376
+ });
1377
+
1378
+ async function loadBanner() {
1379
+ try {
1380
+ const banner = await api("/api/banner");
1381
+ if (!banner.enabled || !banner.text) return;
1382
+ // Recordado por texto, no por un id: si el admin cambia el mensaje,
1383
+ // vuelve a aparecer aunque el anterior ya se hubiera cerrado.
1384
+ if (localStorage.getItem("estela.bannerDismissed") === banner.text) return;
1385
+
1386
+ $("#banner-text").textContent = banner.text;
1387
+ const link = $("#banner-link");
1388
+ if (banner.link) { link.href = banner.link; link.hidden = false; } else { link.hidden = true; }
1389
+ $("#banner-promo").hidden = false;
1390
+ } catch {
1391
+ // Sin conexión, o getestela.dev caído: el banner simplemente no sale.
1392
+ }
1393
+ }
1394
+
1395
+ $("#banner-close").addEventListener("click", () => {
1396
+ localStorage.setItem("estela.bannerDismissed", $("#banner-text").textContent);
1397
+ $("#banner-promo").hidden = true;
1398
+ });
1399
+
1242
1400
  // ── Arranque ───────────────────────────────────────────────────────────
1243
1401
 
1244
1402
  /* Arranca en el resumen: la primera pregunta al abrir es "¿cómo va todo?",
1245
1403
  no "¿qué hice hoy?". El día concreto está a un clic. */
1246
1404
  (async function boot() {
1405
+ loadAccountUpgrade();
1406
+ loadBanner();
1247
1407
  await loadSummary();
1248
1408
 
1249
1409
  /* Si hoy no tiene actividad, "Mi día" apunta al último día con trabajo:
Binary file
package/web/favicon.png CHANGED
Binary file
package/web/index.html CHANGED
@@ -12,8 +12,7 @@
12
12
 
13
13
  <header class="top">
14
14
  <div class="brand">
15
- <img class="mark" src="/logo-mark.png" alt="" aria-hidden="true">
16
- Estela
15
+ <img class="brand-logo" src="/logo.png" alt="getestela.dev">
17
16
  </div>
18
17
  <nav class="tabs" role="tablist">
19
18
  <button type="button" class="tab is-on" data-view="summary" role="tab">Resumen</button>
@@ -23,6 +22,17 @@
23
22
  <button type="button" class="tab" data-view="projects" role="tab">Proyectos</button>
24
23
  </nav>
25
24
  <div class="top-right" id="top-right">
25
+ <div class="upgrade-wrap" id="upgrade-wrap" hidden>
26
+ <button type="button" class="btn-main btn-upgrade" id="upgrade-btn">Actualizar a Pro o Teams</button>
27
+ <div class="upgrade-menu" id="upgrade-menu" hidden>
28
+ <button type="button" class="upgrade-option" data-plan="pro">
29
+ <b>Pro</b><span>9€/mes · paneles y sync ilimitados</span>
30
+ </button>
31
+ <button type="button" class="upgrade-option" data-plan="teams">
32
+ <b>Teams</b><span>15€/persona/mes · horas de equipo medidas</span>
33
+ </button>
34
+ </div>
35
+ </div>
26
36
  <span class="sync-warn" id="sync-warn" hidden></span>
27
37
  <button type="button" class="sync" id="sync" title="Buscar trabajo nuevo (R)">
28
38
  <span class="sync-dot" aria-hidden="true"></span>
@@ -31,6 +41,12 @@
31
41
  </div>
32
42
  </header>
33
43
 
44
+ <div class="banner-promo" id="banner-promo" hidden>
45
+ <span id="banner-text"></span>
46
+ <a id="banner-link" href="#" target="_blank" rel="noopener" hidden>Ver más →</a>
47
+ <button type="button" id="banner-close" aria-label="Cerrar">×</button>
48
+ </div>
49
+
34
50
  <main>
35
51
  <!-- ══════════════ EQUIPO — quién trabaja en qué ══════════════ -->
36
52
  <section class="view" id="view-team">
package/web/logo.png ADDED
Binary file
package/web/logo-mark.png DELETED
Binary file