estela 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +138 -0
  2. package/dist/billing/amortize.d.ts +44 -0
  3. package/dist/billing/amortize.d.ts.map +1 -0
  4. package/dist/billing/amortize.js +83 -0
  5. package/dist/billing/amortize.js.map +1 -0
  6. package/dist/billing/amortize.test.d.ts +2 -0
  7. package/dist/billing/amortize.test.d.ts.map +1 -0
  8. package/dist/billing/amortize.test.js +121 -0
  9. package/dist/billing/amortize.test.js.map +1 -0
  10. package/dist/billing/format.test.d.ts +2 -0
  11. package/dist/billing/format.test.d.ts.map +1 -0
  12. package/dist/billing/format.test.js +110 -0
  13. package/dist/billing/format.test.js.map +1 -0
  14. package/dist/billing/invoice.d.ts +52 -0
  15. package/dist/billing/invoice.d.ts.map +1 -0
  16. package/dist/billing/invoice.js +148 -0
  17. package/dist/billing/invoice.js.map +1 -0
  18. package/dist/billing/invoice.test.d.ts +2 -0
  19. package/dist/billing/invoice.test.d.ts.map +1 -0
  20. package/dist/billing/invoice.test.js +200 -0
  21. package/dist/billing/invoice.test.js.map +1 -0
  22. package/dist/billing/sessionize.d.ts +104 -0
  23. package/dist/billing/sessionize.d.ts.map +1 -0
  24. package/dist/billing/sessionize.js +0 -0
  25. package/dist/billing/sessionize.js.map +1 -0
  26. package/dist/billing/sessionize.test.d.ts +2 -0
  27. package/dist/billing/sessionize.test.d.ts.map +1 -0
  28. package/dist/billing/sessionize.test.js +67 -0
  29. package/dist/billing/sessionize.test.js.map +1 -0
  30. package/dist/cli.d.ts +4 -0
  31. package/dist/cli.d.ts.map +1 -0
  32. package/dist/cli.js +1091 -0
  33. package/dist/cli.js.map +1 -0
  34. package/dist/cloud/auth.d.ts +4 -0
  35. package/dist/cloud/auth.d.ts.map +1 -0
  36. package/dist/cloud/auth.js +98 -0
  37. package/dist/cloud/auth.js.map +1 -0
  38. package/dist/cloud/client.d.ts +12 -0
  39. package/dist/cloud/client.d.ts.map +1 -0
  40. package/dist/cloud/client.js +34 -0
  41. package/dist/cloud/client.js.map +1 -0
  42. package/dist/db/schema.d.ts +11 -0
  43. package/dist/db/schema.d.ts.map +1 -0
  44. package/dist/db/schema.js +424 -0
  45. package/dist/db/schema.js.map +1 -0
  46. package/dist/db/schema.test.d.ts +2 -0
  47. package/dist/db/schema.test.d.ts.map +1 -0
  48. package/dist/db/schema.test.js +335 -0
  49. package/dist/db/schema.test.js.map +1 -0
  50. package/dist/db/store.d.ts +113 -0
  51. package/dist/db/store.d.ts.map +1 -0
  52. package/dist/db/store.js +550 -0
  53. package/dist/db/store.js.map +1 -0
  54. package/dist/doctor.d.ts +24 -0
  55. package/dist/doctor.d.ts.map +1 -0
  56. package/dist/doctor.js +229 -0
  57. package/dist/doctor.js.map +1 -0
  58. package/dist/export/csv.d.ts +9 -0
  59. package/dist/export/csv.d.ts.map +1 -0
  60. package/dist/export/csv.js +81 -0
  61. package/dist/export/csv.js.map +1 -0
  62. package/dist/export/invoice-pdf.d.ts +21 -0
  63. package/dist/export/invoice-pdf.d.ts.map +1 -0
  64. package/dist/export/invoice-pdf.js +135 -0
  65. package/dist/export/invoice-pdf.js.map +1 -0
  66. package/dist/export/panel.d.ts +78 -0
  67. package/dist/export/panel.d.ts.map +1 -0
  68. package/dist/export/panel.js +693 -0
  69. package/dist/export/panel.js.map +1 -0
  70. package/dist/export/panel.test.d.ts +2 -0
  71. package/dist/export/panel.test.d.ts.map +1 -0
  72. package/dist/export/panel.test.js +273 -0
  73. package/dist/export/panel.test.js.map +1 -0
  74. package/dist/export/pdf.d.ts +32 -0
  75. package/dist/export/pdf.d.ts.map +1 -0
  76. package/dist/export/pdf.js +229 -0
  77. package/dist/export/pdf.js.map +1 -0
  78. package/dist/export/pdf.test.d.ts +2 -0
  79. package/dist/export/pdf.test.d.ts.map +1 -0
  80. package/dist/export/pdf.test.js +151 -0
  81. package/dist/export/pdf.test.js.map +1 -0
  82. package/dist/export/share.d.ts +42 -0
  83. package/dist/export/share.d.ts.map +1 -0
  84. package/dist/export/share.js +181 -0
  85. package/dist/export/share.js.map +1 -0
  86. package/dist/export/share.test.d.ts +2 -0
  87. package/dist/export/share.test.d.ts.map +1 -0
  88. package/dist/export/share.test.js +116 -0
  89. package/dist/export/share.test.js.map +1 -0
  90. package/dist/metrics/index.d.ts +101 -0
  91. package/dist/metrics/index.d.ts.map +1 -0
  92. package/dist/metrics/index.js +221 -0
  93. package/dist/metrics/index.js.map +1 -0
  94. package/dist/metrics/metrics.test.d.ts +2 -0
  95. package/dist/metrics/metrics.test.d.ts.map +1 -0
  96. package/dist/metrics/metrics.test.js +162 -0
  97. package/dist/metrics/metrics.test.js.map +1 -0
  98. package/dist/metrics/people.d.ts +46 -0
  99. package/dist/metrics/people.d.ts.map +1 -0
  100. package/dist/metrics/people.js +126 -0
  101. package/dist/metrics/people.js.map +1 -0
  102. package/dist/metrics/people.test.d.ts +2 -0
  103. package/dist/metrics/people.test.d.ts.map +1 -0
  104. package/dist/metrics/people.test.js +84 -0
  105. package/dist/metrics/people.test.js.map +1 -0
  106. package/dist/metrics/team.d.ts +41 -0
  107. package/dist/metrics/team.d.ts.map +1 -0
  108. package/dist/metrics/team.js +0 -0
  109. package/dist/metrics/team.js.map +1 -0
  110. package/dist/metrics/team.test.d.ts +2 -0
  111. package/dist/metrics/team.test.d.ts.map +1 -0
  112. package/dist/metrics/team.test.js +90 -0
  113. package/dist/metrics/team.test.js.map +1 -0
  114. package/dist/pricing/catalog.d.ts +39 -0
  115. package/dist/pricing/catalog.d.ts.map +1 -0
  116. package/dist/pricing/catalog.js +59 -0
  117. package/dist/pricing/catalog.js.map +1 -0
  118. package/dist/pricing/cost.d.ts +25 -0
  119. package/dist/pricing/cost.d.ts.map +1 -0
  120. package/dist/pricing/cost.js +50 -0
  121. package/dist/pricing/cost.js.map +1 -0
  122. package/dist/pricing/cost.test.d.ts +2 -0
  123. package/dist/pricing/cost.test.d.ts.map +1 -0
  124. package/dist/pricing/cost.test.js +74 -0
  125. package/dist/pricing/cost.test.js.map +1 -0
  126. package/dist/server.d.ts +15 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +1141 -0
  129. package/dist/server.js.map +1 -0
  130. package/dist/setup.d.ts +70 -0
  131. package/dist/setup.d.ts.map +1 -0
  132. package/dist/setup.js +223 -0
  133. package/dist/setup.js.map +1 -0
  134. package/dist/setup.test.d.ts +2 -0
  135. package/dist/setup.test.d.ts.map +1 -0
  136. package/dist/setup.test.js +131 -0
  137. package/dist/setup.test.js.map +1 -0
  138. package/dist/watchers/claude.d.ts +34 -0
  139. package/dist/watchers/claude.d.ts.map +1 -0
  140. package/dist/watchers/claude.js +235 -0
  141. package/dist/watchers/claude.js.map +1 -0
  142. package/dist/watchers/claude.test.d.ts +2 -0
  143. package/dist/watchers/claude.test.d.ts.map +1 -0
  144. package/dist/watchers/claude.test.js +180 -0
  145. package/dist/watchers/claude.test.js.map +1 -0
  146. package/dist/watchers/git.d.ts +42 -0
  147. package/dist/watchers/git.d.ts.map +1 -0
  148. package/dist/watchers/git.js +218 -0
  149. package/dist/watchers/git.js.map +1 -0
  150. package/dist/watchers/git.test.d.ts +2 -0
  151. package/dist/watchers/git.test.d.ts.map +1 -0
  152. package/dist/watchers/git.test.js +73 -0
  153. package/dist/watchers/git.test.js.map +1 -0
  154. package/dist/watchers/identity.d.ts +34 -0
  155. package/dist/watchers/identity.d.ts.map +1 -0
  156. package/dist/watchers/identity.js +85 -0
  157. package/dist/watchers/identity.js.map +1 -0
  158. package/dist/watchers/identity.test.d.ts +2 -0
  159. package/dist/watchers/identity.test.d.ts.map +1 -0
  160. package/dist/watchers/identity.test.js +48 -0
  161. package/dist/watchers/identity.test.js.map +1 -0
  162. package/dist/web-ui.test.d.ts +2 -0
  163. package/dist/web-ui.test.d.ts.map +1 -0
  164. package/dist/web-ui.test.js +312 -0
  165. package/dist/web-ui.test.js.map +1 -0
  166. package/node_modules/@estela/shared/dist/index.d.ts +3 -0
  167. package/node_modules/@estela/shared/dist/index.d.ts.map +1 -0
  168. package/node_modules/@estela/shared/dist/index.js +19 -0
  169. package/node_modules/@estela/shared/dist/index.js.map +1 -0
  170. package/node_modules/@estela/shared/dist/money.d.ts +71 -0
  171. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -0
  172. package/node_modules/@estela/shared/dist/money.js +171 -0
  173. package/node_modules/@estela/shared/dist/money.js.map +1 -0
  174. package/node_modules/@estela/shared/dist/types.d.ts +365 -0
  175. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -0
  176. package/node_modules/@estela/shared/dist/types.js +24 -0
  177. package/node_modules/@estela/shared/dist/types.js.map +1 -0
  178. package/node_modules/@estela/shared/package.json +5 -0
  179. package/package.json +41 -0
  180. package/web/app.css +749 -0
  181. package/web/app.js +1257 -0
  182. package/web/apple-touch-icon.png +0 -0
  183. package/web/favicon.png +0 -0
  184. package/web/index.html +132 -0
  185. package/web/lib.js +94 -0
  186. package/web/logo-mark.png +0 -0
package/dist/server.js ADDED
@@ -0,0 +1,1141 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getImportStatus = getImportStatus;
37
+ exports.startServer = startServer;
38
+ const node_http_1 = require("node:http");
39
+ const node_fs_1 = require("node:fs");
40
+ const promises_1 = require("node:fs/promises");
41
+ const node_os_1 = require("node:os");
42
+ const node_path_1 = require("node:path");
43
+ const shared_1 = require("@estela/shared");
44
+ const amortize_js_1 = require("./billing/amortize.js");
45
+ const invoice_js_1 = require("./billing/invoice.js");
46
+ const sessionize_js_1 = require("./billing/sessionize.js");
47
+ const schema_js_1 = require("./db/schema.js");
48
+ const store = __importStar(require("./db/store.js"));
49
+ const panel_js_1 = require("./export/panel.js");
50
+ const share_js_1 = require("./export/share.js");
51
+ const index_js_1 = require("./metrics/index.js");
52
+ const team_js_1 = require("./metrics/team.js");
53
+ const claude_js_1 = require("./watchers/claude.js");
54
+ const git_js_1 = require("./watchers/git.js");
55
+ const identity_js_1 = require("./watchers/identity.js");
56
+ /**
57
+ * Servidor local del dashboard.
58
+ *
59
+ * Escucha solo en 127.0.0.1. No es un servicio: es tu propia máquina
60
+ * enseñándote tus propios datos, y nada de esto debe ser alcanzable desde fuera.
61
+ */
62
+ /**
63
+ * Dónde están los ficheros del panel.
64
+ *
65
+ * Cambia de sitio según cómo se ejecute esto. En el monorepo son
66
+ * `packages/web`, dos niveles por encima de `packages/daemon/dist`. Instalado
67
+ * desde npm no hay monorepo: la carpeta viaja dentro del propio paquete.
68
+ *
69
+ * Se prueban las dos y se comprueba que exista el `index.html`, en vez de
70
+ * confiar en una ruta relativa. Sin esto, `estela web` instalado desde npm
71
+ * servía 404 a todo sin decir por qué.
72
+ */
73
+ const WEB_ROOT = resolveWebRoot();
74
+ function resolveWebRoot() {
75
+ const candidates = [
76
+ (0, node_path_1.join)(__dirname, "..", "web"), // publicado: estela/web
77
+ (0, node_path_1.join)(__dirname, "..", "..", "web"), // monorepo: packages/web
78
+ ];
79
+ for (const dir of candidates) {
80
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(dir, "index.html")))
81
+ return dir;
82
+ }
83
+ return candidates[candidates.length - 1];
84
+ }
85
+ const MIME = {
86
+ ".html": "text/html; charset=utf-8",
87
+ ".css": "text/css; charset=utf-8",
88
+ ".js": "text/javascript; charset=utf-8",
89
+ ".svg": "image/svg+xml",
90
+ ".json": "application/json; charset=utf-8",
91
+ ".png": "image/png",
92
+ ".jpg": "image/jpeg",
93
+ };
94
+ /**
95
+ * Una pasada de importación. Devuelve cuántos bloques quedaron imputados.
96
+ *
97
+ * Es la misma lógica que `estela import`, extraída para que el panel pueda
98
+ * mantenerse al día solo.
99
+ */
100
+ async function importOnce(dbPath) {
101
+ const db = (0, schema_js_1.openDatabase)(dbPath);
102
+ try {
103
+ const scan = await (0, claude_js_1.scanClaudeCode)({});
104
+ const turns = (0, claude_js_1.resolveScratchpads)(scan.turns);
105
+ store.saveTurns(db, turns);
106
+ store.logScan(db, "claude-code", scan.report);
107
+ const repos = new Set();
108
+ for (const turn of turns)
109
+ if (turn.repoPath)
110
+ repos.add(turn.repoPath);
111
+ // Todos los autores, para la vista de equipo. Filtrar es cosa de más abajo.
112
+ for (const repo of repos) {
113
+ const root = await (0, git_js_1.repoRoot)(repo);
114
+ if (!root)
115
+ continue;
116
+ store.saveCommits(db, await (0, git_js_1.readCommits)(root, {}));
117
+ }
118
+ const mine = await (0, identity_js_1.myEmailsByRepo)(db, repos);
119
+ const allRepoCommits = db.prepare("SELECT * FROM commits").all()
120
+ .map((r) => ({
121
+ repoPath: r["repo_path"], hash: r["hash"],
122
+ at: new Date(r["at"]), authorEmail: r["author_email"],
123
+ authorName: r["author_name"] ?? "",
124
+ branch: r["branch"] ?? null, subject: r["subject"],
125
+ linesAdded: r["lines_added"], linesDeleted: r["lines_deleted"],
126
+ files: String(r["files"] ?? "").split("\n").filter(Boolean),
127
+ }));
128
+ let imputed = 0;
129
+ // Solo tus commits producen horas tuyas.
130
+ const commits = (0, identity_js_1.onlyMine)(allRepoCommits, mine);
131
+ const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(turns), commits);
132
+ const fromCommits = (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), fromAgents);
133
+ for (const block of (0, sessionize_js_1.groupByBranchAndDay)([...fromAgents, ...fromCommits])) {
134
+ const projectId = block.repoPath ? store.projectForRepo(db, block.repoPath) : null;
135
+ if (!projectId)
136
+ continue;
137
+ const project = store.getProject(db, projectId);
138
+ const day = (0, shared_1.localDate)(block.startedAt);
139
+ const slug = (block.branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
140
+ store.saveTimeEntry(db, {
141
+ id: `te_${projectId}_${day}_${slug}`,
142
+ projectId,
143
+ startedAt: block.startedAt, endedAt: block.endedAt, seconds: block.seconds,
144
+ description: (0, sessionize_js_1.describeBlock)(block),
145
+ billable: project.billable, invoiceId: null,
146
+ aiCost: block.aiCost,
147
+ // Sin agente no hay segundos de agente que atribuir.
148
+ agentSeconds: block.turnCount > 0 ? block.seconds : 0,
149
+ commitHashes: block.commits.map((c) => c.hash),
150
+ agents: block.turnCount > 0 ? ["claude-code"] : [],
151
+ source: block.turnCount > 0 ? "agent" : "commit", kind: "development",
152
+ branch: block.branch,
153
+ });
154
+ imputed++;
155
+ }
156
+ return imputed;
157
+ }
158
+ finally {
159
+ db.close();
160
+ }
161
+ }
162
+ /**
163
+ * Resultado del último import.
164
+ *
165
+ * Existe porque un fallo aquí era invisible: el aviso iba al stdout del
166
+ * servidor, que nadie mira, y la web seguía enseñando datos viejos sin decir
167
+ * que llevaba horas sin poder actualizarse. Un error silencioso en una
168
+ * herramienta de tiempo es peor que un error ruidoso: te hace confiar en un
169
+ * número que ya no es cierto.
170
+ */
171
+ let lastImport = null;
172
+ let importing = false;
173
+ function getImportStatus() {
174
+ return lastImport;
175
+ }
176
+ /** Una pasada, sin solaparse con otra en curso. Devuelve el resultado. */
177
+ async function runImport(dbPath) {
178
+ if (importing)
179
+ return lastImport ?? { at: new Date().toISOString(), ok: true, blocks: 0, error: null };
180
+ importing = true;
181
+ try {
182
+ const blocks = await importOnce(dbPath);
183
+ if (blocks > 0)
184
+ console.log(` · ${blocks} bloques actualizados`);
185
+ lastImport = { at: new Date().toISOString(), ok: true, blocks, error: null };
186
+ }
187
+ catch (error) {
188
+ const message = error instanceof Error ? error.message : String(error);
189
+ console.warn(" ⚠ no se pudo importar:", message);
190
+ lastImport = { at: new Date().toISOString(), ok: false, blocks: 0, error: message };
191
+ }
192
+ finally {
193
+ importing = false;
194
+ }
195
+ return lastImport;
196
+ }
197
+ /**
198
+ * Importa periódicamente mientras el panel está abierto.
199
+ *
200
+ * Sin esto, abrir el panel y no ver el trabajo de hoy es lo normal, porque el
201
+ * import era manual. Nadie se acuerda de ejecutar un comando antes de mirar sus
202
+ * propias horas, y una herramienta de tiempo que enseña datos viejos no sirve.
203
+ *
204
+ * Se ejecuta al arrancar y cada `intervalMinutes`. Un fallo no tumba el
205
+ * servidor: preferimos datos algo atrasados a un panel caído. Pero queda
206
+ * registrado en `lastImport` para que la web pueda decirlo.
207
+ */
208
+ function startAutoImport(dbPath, intervalMinutes) {
209
+ void runImport(dbPath);
210
+ const timer = setInterval(() => void runImport(dbPath), intervalMinutes * 60_000);
211
+ timer.unref();
212
+ }
213
+ function startServer(options = {}) {
214
+ const port = options.port ?? 4319;
215
+ const dbPath = options.dbPath ?? schema_js_1.DEFAULT_DB_PATH;
216
+ const autoMinutes = options.autoImportMinutes ?? 5;
217
+ if (autoMinutes > 0)
218
+ startAutoImport(dbPath, autoMinutes);
219
+ const server = (0, node_http_1.createServer)((req, res) => {
220
+ handle(req, res, dbPath).catch((error) => {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ json(res, 500, { error: message });
223
+ });
224
+ });
225
+ return new Promise((resolve) => {
226
+ server.listen(port, "127.0.0.1", () => resolve(`http://127.0.0.1:${port}`));
227
+ });
228
+ }
229
+ async function handle(req, res, dbPath) {
230
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
231
+ const path = url.pathname;
232
+ // Import bajo demanda: acabas de commitear y quieres verlo ahora, no dentro de
233
+ // cinco minutos. Va antes que api() porque importOnce abre su propia conexión
234
+ // y no hace falta una segunda sobre el mismo fichero.
235
+ if (path === "/api/import" && req.method === "POST") {
236
+ return json(res, 200, await runImport(dbPath));
237
+ }
238
+ if (path === "/api/import-status" && req.method === "GET") {
239
+ return json(res, 200, getImportStatus() ?? { at: null, ok: true, blocks: 0, error: null });
240
+ }
241
+ if (path.startsWith("/api/"))
242
+ return api(req, res, url, dbPath);
243
+ return serveStatic(res, path);
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // API
247
+ // ---------------------------------------------------------------------------
248
+ async function api(req, res, url, dbPath) {
249
+ const db = (0, schema_js_1.openDatabase)(dbPath);
250
+ try {
251
+ const path = url.pathname;
252
+ if (path === "/api/overview" && req.method === "GET") {
253
+ return json(res, 200, buildOverview(db, url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? ""));
254
+ }
255
+ if (path === "/api/team" && req.method === "GET") {
256
+ return json(res, 200, await buildTeam(db, url.searchParams.get("from") ?? "", url.searchParams.get("to") ?? "", url.searchParams.get("project") || null));
257
+ }
258
+ if (path === "/api/summary" && req.method === "GET") {
259
+ const from = url.searchParams.get("from") ?? "";
260
+ const to = url.searchParams.get("to") ?? "";
261
+ return json(res, 200, buildSummary(db, from, to));
262
+ }
263
+ if (path === "/api/day" && req.method === "GET") {
264
+ const date = url.searchParams.get("date") ?? todayIso();
265
+ return json(res, 200, buildDay(db, date));
266
+ }
267
+ if (path.startsWith("/api/entry/") && req.method === "PATCH") {
268
+ const id = decodeURIComponent(path.slice("/api/entry/".length));
269
+ const patch = await readJson(req);
270
+ applyEntryPatch(db, id, patch);
271
+ return json(res, 200, { ok: true });
272
+ }
273
+ if (path === "/api/projects" && req.method === "GET") {
274
+ return json(res, 200, buildProjects(db));
275
+ }
276
+ if (path === "/api/projects" && req.method === "POST") {
277
+ const body = await readJson(req);
278
+ return json(res, 200, { id: createProject(db, body) });
279
+ }
280
+ if (path === "/api/publish" && req.method === "POST") {
281
+ const body = await readJson(req);
282
+ return json(res, 200, await publishPanel(db, body));
283
+ }
284
+ if (path === "/api/authors" && req.method === "GET") {
285
+ const repo = url.searchParams.get("repo") ?? "";
286
+ return json(res, 200, { authors: await (0, git_js_1.repoAuthors)(repo) });
287
+ }
288
+ if (path === "/api/authors" && req.method === "POST") {
289
+ const body = await readJson(req);
290
+ store.setProjectAuthors(db, String(body["projectId"] ?? ""), String(body["emails"] ?? "").split(",").map((e) => e.trim()).filter(Boolean));
291
+ return json(res, 200, { ok: true });
292
+ }
293
+ if (path === "/api/rate" && req.method === "POST") {
294
+ const body = await readJson(req);
295
+ setRate(db, body);
296
+ return json(res, 200, { ok: true });
297
+ }
298
+ if (path === "/api/unassigned" && req.method === "GET") {
299
+ return json(res, 200, { repos: unassignedRepos(db) });
300
+ }
301
+ if (path === "/api/report" && req.method === "GET") {
302
+ const projectId = url.searchParams.get("project") ?? "";
303
+ const from = url.searchParams.get("from") ?? "";
304
+ const to = url.searchParams.get("to") ?? todayIso();
305
+ const withAmounts = url.searchParams.get("amounts") === "1";
306
+ return sendReport(db, res, projectId, from, to, withAmounts, url.searchParams.get("author") ?? "");
307
+ }
308
+ if (path === "/api/entry" && req.method === "POST") {
309
+ const body = await readJson(req);
310
+ const id = createManualEntry(db, body);
311
+ return json(res, 200, { id });
312
+ }
313
+ if (path === "/api/approve-range" && req.method === "POST") {
314
+ const body = await readJson(req);
315
+ const n = approveRange(db, String(body["from"] ?? ""), String(body["to"] ?? ""));
316
+ return json(res, 200, { approved: n });
317
+ }
318
+ if (path === "/api/approve-day" && req.method === "POST") {
319
+ const { date } = await readJson(req);
320
+ const n = approveDay(db, date ?? todayIso());
321
+ return json(res, 200, { approved: n });
322
+ }
323
+ return json(res, 404, { error: `Ruta desconocida: ${path}` });
324
+ }
325
+ finally {
326
+ db.close();
327
+ }
328
+ }
329
+ /**
330
+ * Datos de la pantalla principal.
331
+ *
332
+ * `from`/`to` acotan lo pendiente al mismo periodo que el resumen. Sin ese
333
+ * acotado, las dos pantallas enseñaban cifras distintas para lo que el lector
334
+ * entiende como lo mismo, y dos números que no cuadran destruyen la confianza
335
+ * en la herramienta aunque los dos sean correctos.
336
+ *
337
+ * Vacío significa "todo lo pendiente, sin importar la fecha".
338
+ */
339
+ function buildOverview(db, from = "", to = "") {
340
+ const days = db.prepare(`
341
+ SELECT local_date AS date,
342
+ COUNT(*) AS blocks,
343
+ SUM(seconds) AS seconds,
344
+ SUM(ai_micro_usd) AS micro,
345
+ SUM(approved) AS approved
346
+ FROM time_entries
347
+ GROUP BY date
348
+ ORDER BY date DESC
349
+ LIMIT 30
350
+ `).all();
351
+ const projects = store.listProjects(db).map((p) => {
352
+ const client = store.getClient(db, p.clientId);
353
+ const rate = (0, invoice_js_1.rateAt)(store.getRates(db, p.id), p.id, new Date());
354
+ return {
355
+ id: p.id, name: p.name, billable: p.billable,
356
+ clientName: client?.name ?? "—",
357
+ currency: client?.currency ?? "EUR",
358
+ hourlyMinor: rate?.amount ?? null,
359
+ };
360
+ });
361
+ // Lo pendiente no tiene periodo: es todo lo que aún no se ha informado. Se
362
+ // devuelve su rango real para poder decirlo en pantalla — un total sin fechas
363
+ // al lado de otro que sí las tiene se lee como una contradicción.
364
+ const ranged = from !== "" && to !== "";
365
+ const pending = db.prepare(`
366
+ SELECT project_id, SUM(seconds) AS seconds, SUM(ai_micro_usd) AS micro, COUNT(*) AS blocks,
367
+ MIN(local_date) AS first_day, MAX(local_date) AS last_day
368
+ FROM time_entries
369
+ WHERE billable = 1 AND invoice_id IS NULL
370
+ ${ranged ? "AND local_date >= ? AND local_date <= ?" : ""}
371
+ GROUP BY project_id
372
+ `).all(...(ranged ? [from, to] : []));
373
+ const byProject = new Map(projects.map((p) => [p.id, p]));
374
+ const unbilled = pending.map((row) => {
375
+ const p = byProject.get(row.project_id);
376
+ const amountMinor = p?.hourlyMinor
377
+ ? Math.round((p.hourlyMinor * row.seconds) / 3600)
378
+ : null;
379
+ return {
380
+ projectId: row.project_id,
381
+ projectName: p?.name ?? row.project_id,
382
+ clientName: p?.clientName ?? "—",
383
+ currency: p?.currency ?? "EUR",
384
+ seconds: row.seconds,
385
+ blocks: row.blocks,
386
+ amountMinor,
387
+ aiMicroUsd: row.micro,
388
+ firstDay: row.first_day,
389
+ lastDay: row.last_day,
390
+ };
391
+ }).sort((a, b) => (b.amountMinor ?? 0) - (a.amountMinor ?? 0));
392
+ const scan = db.prepare("SELECT * FROM scan_log ORDER BY id DESC LIMIT 1")
393
+ .get();
394
+ const outside = ranged
395
+ ? db.prepare(`
396
+ SELECT COALESCE(SUM(seconds),0) AS seconds, COUNT(*) AS blocks
397
+ FROM time_entries
398
+ WHERE billable = 1 AND invoice_id IS NULL
399
+ AND (local_date < ? OR local_date > ?)
400
+ `).get(from, to)
401
+ : { seconds: 0, blocks: 0 };
402
+ return {
403
+ range: ranged ? { from, to } : null,
404
+ outsideRange: outside,
405
+ days: days.map((d) => ({
406
+ date: d.date, blocks: d.blocks, seconds: d.seconds,
407
+ aiMicroUsd: d.micro, fullyApproved: d.approved === d.blocks,
408
+ })),
409
+ projects,
410
+ unbilled,
411
+ hasSubscription: store.listSubscriptions(db).length > 0,
412
+ warning: scan && String(scan["warnings"]) ? String(scan["warnings"]) : null,
413
+ };
414
+ }
415
+ /**
416
+ * Resumen de gestión: todos los proyectos a la vez, en un periodo.
417
+ *
418
+ * Es la vista de quien lleva varios proyectos y necesita ver el conjunto. El
419
+ * detalle diario vive en otra pantalla: son dos preguntas distintas y mezclarlas
420
+ * produce un panel que no sirve para ninguna de las dos.
421
+ */
422
+ function buildSummary(db, from, to) {
423
+ const rows = db.prepare(`
424
+ SELECT project_id,
425
+ SUM(seconds) AS seconds,
426
+ SUM(ai_micro_usd) AS micro,
427
+ COUNT(*) AS blocks,
428
+ SUM(approved) AS approved,
429
+ MIN(local_date) AS first_day,
430
+ MAX(local_date) AS last_day,
431
+ COUNT(DISTINCT local_date) AS days
432
+ FROM time_entries
433
+ WHERE local_date >= ? AND local_date <= ?
434
+ GROUP BY project_id
435
+ `).all(from, to);
436
+ const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
437
+ const clients = new Map(store.listClients(db).map((c) => [c.id, c]));
438
+ const items = rows.map((r) => {
439
+ const project = projects.get(r.project_id);
440
+ const client = project ? clients.get(project.clientId) : undefined;
441
+ const rate = project ? (0, invoice_js_1.rateAt)(store.getRates(db, r.project_id), r.project_id, new Date()) : null;
442
+ return {
443
+ projectId: r.project_id,
444
+ projectName: project?.name ?? r.project_id,
445
+ clientName: client?.name ?? "—",
446
+ currency: client?.currency ?? "EUR",
447
+ billable: project?.billable ?? false,
448
+ kind: project?.kind ?? "client",
449
+ seconds: r.seconds,
450
+ days: r.days,
451
+ blocks: r.blocks,
452
+ pendingApproval: r.blocks - r.approved,
453
+ lastDay: r.last_day,
454
+ aiMicroUsd: r.micro,
455
+ amountMinor: rate ? Math.round((rate.amount * r.seconds) / 3600) : null,
456
+ hourlyMinor: rate?.amount ?? null,
457
+ };
458
+ }).sort((a, b) => b.seconds - a.seconds);
459
+ // Actividad reciente entre todos los proyectos, para ver el pulso del conjunto.
460
+ // El rango llega en fechas locales, pero `at` está en UTC. Comparar el
461
+ // recorte del UTC deja fuera todo lo que hiciste a partir de las 19:00: un
462
+ // commit de las 23:20 aquí es de las 04:20 del día siguiente en Greenwich.
463
+ const activity = db.prepare(`
464
+ SELECT c.hash, c.subject, c.at, c.repo_path, c.lines_added, c.lines_deleted
465
+ FROM commits c
466
+ WHERE date(c.at, 'localtime') >= ? AND date(c.at, 'localtime') <= ?
467
+ ORDER BY c.at DESC
468
+ LIMIT 12
469
+ `).all(from, to);
470
+ const byDay = db.prepare(`
471
+ SELECT local_date AS date, SUM(seconds) AS seconds
472
+ FROM time_entries
473
+ WHERE local_date >= ? AND local_date <= ?
474
+ GROUP BY local_date ORDER BY local_date
475
+ `).all(from, to);
476
+ const totalSeconds = items.reduce((s, i) => s + i.seconds, 0);
477
+ const billableSeconds = items.filter((i) => i.kind === "client")
478
+ .reduce((s, i) => s + i.seconds, 0);
479
+ // Con clientes en varias monedas no hay un total único, y enseñar un guion
480
+ // parece que el panel está roto. Se devuelve un subtotal por moneda: sumar
481
+ // euros con dólares exigiría un tipo de cambio que nadie ha declarado.
482
+ const perCurrency = new Map();
483
+ for (const item of items) {
484
+ if (!item.billable || item.amountMinor === null)
485
+ continue;
486
+ perCurrency.set(item.currency, (perCurrency.get(item.currency) ?? 0) + item.amountMinor);
487
+ }
488
+ const totals = [...perCurrency.entries()]
489
+ .map(([currency, amountMinor]) => ({ currency, amountMinor }))
490
+ .sort((a, b) => b.amountMinor - a.amountMinor);
491
+ const currency = totals.length === 1 ? totals[0].currency : null;
492
+ return {
493
+ from, to,
494
+ projects: items,
495
+ totalSeconds,
496
+ billableSeconds,
497
+ totalAmountMinor: currency ? totals[0].amountMinor : null,
498
+ currency,
499
+ totals,
500
+ aiMicroUsd: items.reduce((s, i) => s + i.aiMicroUsd, 0),
501
+ activeProjects: items.filter((i) => i.seconds > 0).length,
502
+ activeDays: byDay.length,
503
+ pendingApproval: items.reduce((s, i) => s + i.pendingApproval, 0),
504
+ byDay,
505
+ activity: activity.map((c) => ({
506
+ hash: c.hash.slice(0, 7),
507
+ subject: c.subject,
508
+ at: c.at,
509
+ repo: c.repo_path.split("/").pop() ?? c.repo_path,
510
+ linesAdded: c.lines_added,
511
+ linesDeleted: c.lines_deleted,
512
+ })),
513
+ unassignedBlocks: db.prepare("SELECT COUNT(*) AS n FROM agent_turns WHERE repo_path IS NOT NULL").get().n,
514
+ };
515
+ }
516
+ /** Los bloques de un día, listos para revisar y aprobar. */
517
+ /**
518
+ * La vista de equipo.
519
+ *
520
+ * Reúne en una sola llamada lo que la pantalla necesita, porque son cifras que
521
+ * solo significan algo juntas: 138h no dice nada sin saber si son más o menos
522
+ * que el mes pasado, ni $137 sin saber cuántas horas costaron.
523
+ *
524
+ * Es la corrección al Resumen actual, donde cada número vive solo en su tarjeta
525
+ * y hay que hacer la cuenta de cabeza.
526
+ */
527
+ async function buildTeam(db, from, to, projectId) {
528
+ const summary = buildSummary(db, from, to);
529
+ // Periodo anterior de la misma duración, para poder comparar. Sin esto, un
530
+ // número suelto no dice si la cosa va mejor o peor.
531
+ const days = Math.max(1, Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000) + 1);
532
+ const prevTo = shiftIso(from, -1);
533
+ const prevFrom = shiftIso(prevTo, -(days - 1));
534
+ const previous = buildSummary(db, prevFrom, prevTo);
535
+ // Composición por procedencia: cuánto de cada proyecto está medido con un
536
+ // agente y cuánto estimado desde commits. Es lo que permite al lector saber
537
+ // de qué fiarse, y ninguna otra cifra de la pantalla lo dice.
538
+ const composition = db.prepare(`
539
+ SELECT project_id, source, SUM(seconds) AS seconds
540
+ FROM time_entries WHERE local_date >= ? AND local_date <= ?
541
+ GROUP BY project_id, source
542
+ `).all(from, to);
543
+ const byProject = new Map();
544
+ for (const row of composition) {
545
+ const bucket = byProject.get(row.project_id) ?? {};
546
+ bucket[row.source] = row.seconds;
547
+ byProject.set(row.project_id, bucket);
548
+ }
549
+ const budgets = db.prepare("SELECT id, ai_budget_micro_usd AS budget FROM projects WHERE ai_budget_micro_usd IS NOT NULL").all();
550
+ const budgetOf = new Map(budgets.map((b) => [b.id, b.budget]));
551
+ const projects = summary.projects
552
+ .filter((p) => !projectId || p.projectId === projectId)
553
+ .map((p) => {
554
+ const parts = byProject.get(p.projectId) ?? {};
555
+ const budget = budgetOf.get(p.projectId) ?? null;
556
+ return {
557
+ ...p,
558
+ composition: {
559
+ agent: parts["agent"] ?? 0,
560
+ commit: parts["commit"] ?? 0,
561
+ manual: parts["manual"] ?? 0,
562
+ },
563
+ budgetMicroUsd: budget,
564
+ budgetPct: budget && budget > 0
565
+ ? Math.round((p.aiMicroUsd / budget) * 100) : null,
566
+ };
567
+ });
568
+ // Personas y riesgo, calculados al vuelo desde los commits. Nada de esto se
569
+ // persiste: las horas de un compañero no son imputaciones tuyas.
570
+ const repoRows = projectId
571
+ ? db.prepare("SELECT repo_path FROM project_repos WHERE project_id = ?").all(projectId)
572
+ : db.prepare("SELECT repo_path FROM project_repos").all();
573
+ const repos = repoRows.map((r) => r.repo_path);
574
+ const commits = repos.length
575
+ ? db.prepare(`
576
+ SELECT * FROM commits
577
+ WHERE date(at, 'localtime') >= ? AND date(at, 'localtime') <= ?
578
+ AND repo_path IN (${repos.map(() => "?").join(",")})
579
+ `).all(from, to, ...repos).map(rowToCommit)
580
+ : [];
581
+ const mine = await (0, identity_js_1.myEmailsByRepo)(db, repos);
582
+ const myEmails = new Set();
583
+ for (const set of mine.values())
584
+ for (const email of set)
585
+ myEmails.add(email);
586
+ const myEntries = store.listEntriesBetween(db, from, to)
587
+ .filter((e) => !projectId || e.projectId === projectId);
588
+ const people = (0, team_js_1.teamView)(commits, { myEmails, myEntries });
589
+ // Ramas sin integrar. Riesgo repartido, no culpa de nadie: una rama lleva
590
+ // ocho días abierta tanto si va lenta como si el cliente no la revisa.
591
+ const merged = new Set();
592
+ for (const repo of repos) {
593
+ for (const branch of await (0, git_js_1.mergedBranches)(repo))
594
+ merged.add(branch);
595
+ }
596
+ const open = (0, index_js_1.openWork)(commits, merged);
597
+ // Los totales salen de los proyectos ya filtrados. Tomarlos de `summary`
598
+ // enseñaría las horas de toda tu cartera bajo el nombre de un solo cliente.
599
+ const sum = (rows) => rows.reduce((acc, r) => ({
600
+ seconds: acc.seconds + r.seconds, ai: acc.ai + r.aiMicroUsd,
601
+ }), { seconds: 0, ai: 0 });
602
+ const now = sum(projects);
603
+ const before = sum(previous.projects.filter((p) => !projectId || p.projectId === projectId));
604
+ const totals = projectId
605
+ ? projects.filter((p) => p.billable && p.amountMinor !== null)
606
+ .reduce((acc, p) => {
607
+ const seen = acc.find((t) => t.currency === p.currency);
608
+ if (seen)
609
+ seen.amountMinor += p.amountMinor;
610
+ else
611
+ acc.push({ currency: p.currency, amountMinor: p.amountMinor });
612
+ return acc;
613
+ }, [])
614
+ : summary.totals;
615
+ return {
616
+ from, to,
617
+ totalSeconds: now.seconds,
618
+ previousSeconds: before.seconds,
619
+ billableSeconds: projects.filter((p) => p.kind === "client")
620
+ .reduce((acc, p) => acc + p.seconds, 0),
621
+ aiMicroUsd: now.ai,
622
+ previousAiMicroUsd: before.ai,
623
+ totals,
624
+ pendingApproval: projects.reduce((acc, p) => acc + p.pendingApproval, 0),
625
+ activeDays: summary.activeDays,
626
+ byDay: summary.byDay,
627
+ activity: summary.activity,
628
+ projects,
629
+ people,
630
+ openWork: open,
631
+ };
632
+ }
633
+ /** Suma días a una fecha ISO local sin pasar por husos horarios. */
634
+ function shiftIso(iso, days) {
635
+ const d = new Date(`${iso}T00:00:00Z`);
636
+ d.setUTCDate(d.getUTCDate() + days);
637
+ return d.toISOString().slice(0, 10);
638
+ }
639
+ function rowToCommit(r) {
640
+ return {
641
+ repoPath: r["repo_path"], hash: r["hash"],
642
+ at: new Date(r["at"]), authorEmail: r["author_email"],
643
+ authorName: r["author_name"] ?? "",
644
+ branch: r["branch"] ?? null, subject: r["subject"],
645
+ linesAdded: r["lines_added"], linesDeleted: r["lines_deleted"],
646
+ files: String(r["files"] ?? "").split("\n").filter(Boolean),
647
+ };
648
+ }
649
+ function buildDay(db, date) {
650
+ const rows = db.prepare(`
651
+ SELECT * FROM time_entries
652
+ WHERE local_date = ?
653
+ ORDER BY started_at
654
+ `).all(date);
655
+ const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
656
+ const clients = new Map(store.listClients(db).map((c) => [c.id, c]));
657
+ const entries = rows.map((r) => {
658
+ const projectId = r["project_id"];
659
+ const project = projects.get(projectId);
660
+ const client = project ? clients.get(project.clientId) : undefined;
661
+ const seconds = r["seconds"];
662
+ const startedAt = r["started_at"];
663
+ const rate = project
664
+ ? (0, invoice_js_1.rateAt)(store.getRates(db, projectId), projectId, new Date(startedAt))
665
+ : null;
666
+ const rounded = project ? (0, shared_1.roundSeconds)(seconds, project.roundingMinutes) : seconds;
667
+ const amount = rate ? (0, shared_1.billableAmount)(rounded, rate) : null;
668
+ const hashes = String(r["commit_hashes"] ?? "").split(",").filter(Boolean);
669
+ const commits = hashes.length
670
+ ? db.prepare(`SELECT hash, subject FROM commits WHERE hash IN (${hashes.map(() => "?").join(",")})`).all(...hashes)
671
+ : [];
672
+ return {
673
+ id: r["id"],
674
+ projectId,
675
+ projectName: project?.name ?? projectId,
676
+ clientName: client?.name ?? null,
677
+ currency: client?.currency ?? "EUR",
678
+ startedAt,
679
+ endedAt: r["ended_at"],
680
+ seconds,
681
+ description: r["description"],
682
+ billable: Boolean(r["billable"]),
683
+ approved: Boolean(r["approved"]),
684
+ invoiced: r["invoice_id"] !== null,
685
+ aiMicroUsd: r["ai_micro_usd"],
686
+ amountMinor: amount?.amount ?? null,
687
+ hourlyMinor: rate?.amount ?? null,
688
+ source: r["source"] ?? "agent",
689
+ kind: r["kind"] ?? "development",
690
+ commits: commits.map((c) => ({ hash: c.hash.slice(0, 7), subject: c.subject })),
691
+ };
692
+ });
693
+ const billable = entries.filter((e) => e.billable);
694
+ const totalSeconds = entries.reduce((s, e) => s + e.seconds, 0);
695
+ // Un subtotal por moneda, igual que en el resumen. Sumar euros con dólares
696
+ // exigiría un tipo de cambio que nadie ha declarado, pero con monedas
697
+ // mezcladas el total salía nulo y la pantalla decía "sin tarifa definida"
698
+ // aunque todas las tarifas estuvieran puestas: una explicación falsa es peor
699
+ // que no dar ninguna.
700
+ const perCurrency = new Map();
701
+ for (const entry of billable) {
702
+ if (entry.amountMinor === null)
703
+ continue;
704
+ perCurrency.set(entry.currency, (perCurrency.get(entry.currency) ?? 0) + entry.amountMinor);
705
+ }
706
+ const totals = [...perCurrency.entries()]
707
+ .map(([currency, amountMinor]) => ({ currency, amountMinor }))
708
+ .sort((a, b) => b.amountMinor - a.amountMinor);
709
+ const currency = totals.length === 1 ? totals[0].currency : null;
710
+ const totalAmount = currency ? totals[0].amountMinor : null;
711
+ // Sin tarifa de verdad: hay trabajo facturable y ni un solo importe.
712
+ const missingRate = billable.length > 0 && totals.length === 0;
713
+ return {
714
+ date,
715
+ entries,
716
+ totalSeconds,
717
+ totalAmountMinor: totalAmount,
718
+ currency,
719
+ totals,
720
+ missingRate,
721
+ aiMicroUsd: entries.reduce((s, e) => s + e.aiMicroUsd, 0),
722
+ pendingApproval: entries.filter((e) => !e.approved && !e.invoiced).length,
723
+ projects: [...projects.values()].map((p) => ({ id: p.id, name: p.name })),
724
+ realAiCost: realAiCostFor(db, date),
725
+ };
726
+ }
727
+ /**
728
+ * Coste real de IA del día: la parte de tu cuota que le toca.
729
+ *
730
+ * Se calcula sobre el mes completo y se prorratea por el consumo del día, que
731
+ * es lo más honesto que se puede decir de un día suelto: la cuota se paga por
732
+ * meses, no por días.
733
+ */
734
+ function realAiCostFor(db, date) {
735
+ const subs = store.listSubscriptions(db);
736
+ if (subs.length === 0)
737
+ return null;
738
+ const month = date.slice(0, 7);
739
+ const shares = (0, amortize_js_1.amortize)(store.consumptionByProjectMonth(db), subs, store.totalConsumptionByMonth(db));
740
+ const monthMicro = store.totalConsumptionByMonth(db).get(month)?.microUsd ?? 0;
741
+ if (monthMicro === 0)
742
+ return null;
743
+ const dayMicro = db.prepare("SELECT SUM(cost_micro_usd) AS micro FROM agent_turns WHERE substr(at,1,10) = ?").get(date).micro ?? 0;
744
+ const projectIds = store.listProjects(db).map((p) => p.id);
745
+ let monthMinor = 0;
746
+ let currency = "USD";
747
+ for (const id of projectIds) {
748
+ const share = (0, amortize_js_1.shareForProject)(shares, id, [month]);
749
+ if (share) {
750
+ monthMinor += share.amount;
751
+ currency = share.currency;
752
+ }
753
+ }
754
+ return { minor: Math.round(monthMinor * (dayMicro / monthMicro)), currency };
755
+ }
756
+ function applyEntryPatch(db, id, patch) {
757
+ const sets = [];
758
+ const values = [];
759
+ if (typeof patch["description"] === "string") {
760
+ sets.push("description = ?");
761
+ values.push(patch["description"]);
762
+ }
763
+ if (typeof patch["projectId"] === "string") {
764
+ sets.push("project_id = ?");
765
+ values.push(patch["projectId"]);
766
+ }
767
+ if (typeof patch["billable"] === "boolean") {
768
+ sets.push("billable = ?");
769
+ values.push(patch["billable"] ? 1 : 0);
770
+ }
771
+ if (typeof patch["approved"] === "boolean") {
772
+ sets.push("approved = ?");
773
+ values.push(patch["approved"] ? 1 : 0);
774
+ }
775
+ if (typeof patch["seconds"] === "number" && patch["seconds"] >= 0) {
776
+ sets.push("seconds = ?");
777
+ values.push(Math.round(patch["seconds"]));
778
+ }
779
+ if (sets.length === 0)
780
+ return;
781
+ // Una imputación ya facturada es historia: la factura que la contiene ya
782
+ // salió, y cambiarla la dejaría descuadrada.
783
+ const row = db.prepare("SELECT invoice_id FROM time_entries WHERE id = ?")
784
+ .get(id);
785
+ if (!row)
786
+ throw new Error(`No existe la imputación ${id}`);
787
+ if (row.invoice_id)
788
+ throw new Error("Esta imputación ya está facturada y no se puede editar.");
789
+ values.push(id);
790
+ db.prepare(`UPDATE time_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values);
791
+ }
792
+ /**
793
+ * Crea una imputación a mano: reuniones, viajes, investigación.
794
+ *
795
+ * Queda marcada como `manual`, así que un reimport nunca la sobrescribe.
796
+ */
797
+ function createManualEntry(db, body) {
798
+ const projectId = String(body["projectId"] ?? "");
799
+ if (!store.getProject(db, projectId))
800
+ throw new Error(`No existe el proyecto "${projectId}"`);
801
+ const minutes = Number(body["minutes"]);
802
+ if (!Number.isFinite(minutes) || minutes <= 0)
803
+ throw new Error("Indica cuántos minutos");
804
+ const date = String(body["date"] ?? todayIso());
805
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
806
+ throw new Error(`Fecha inválida: ${date}`);
807
+ const kind = String(body["kind"] ?? "meeting");
808
+ if (!(kind in shared_1.WORK_KIND_LABELS))
809
+ throw new Error(`Tipo de trabajo desconocido: ${kind}`);
810
+ const seconds = Math.round(minutes * 60);
811
+ const startedAt = new Date(`${date}T10:00:00`);
812
+ const description = String(body["description"] ?? "").trim() || shared_1.WORK_KIND_LABELS[kind];
813
+ return store.saveTimeEntry(db, {
814
+ projectId, startedAt,
815
+ endedAt: new Date(startedAt.getTime() + seconds * 1000),
816
+ seconds, description,
817
+ billable: body["billable"] !== false,
818
+ invoiceId: null,
819
+ aiCost: { microUsd: 0 },
820
+ agentSeconds: 0,
821
+ commitHashes: [],
822
+ agents: [],
823
+ source: "manual",
824
+ kind,
825
+ branch: null,
826
+ });
827
+ }
828
+ // ---------------------------------------------------------------------------
829
+ // Proyectos y tarifas
830
+ // ---------------------------------------------------------------------------
831
+ function buildProjects(db) {
832
+ const clients = new Map(store.listClients(db).map((c) => [c.id, c]));
833
+ return {
834
+ clients: [...clients.values()],
835
+ publications: store.listPublications(db).map((pub) => ({
836
+ projectId: pub.projectId,
837
+ token: pub.token,
838
+ publishedAt: pub.publishedAt.toISOString(),
839
+ baseUrl: pub.baseUrl,
840
+ staleBlocks: pub.staleBlocks,
841
+ daysAgo: Math.floor((Date.now() - pub.publishedAt.getTime()) / 86_400_000),
842
+ })),
843
+ projects: store.listProjects(db).map((p) => {
844
+ const client = clients.get(p.clientId);
845
+ const rates = store.getRates(db, p.id);
846
+ const current = (0, invoice_js_1.rateAt)(rates, p.id, new Date());
847
+ const totals = db.prepare("SELECT COUNT(*) AS blocks, SUM(seconds) AS seconds FROM time_entries WHERE project_id = ?").get(p.id);
848
+ return {
849
+ id: p.id, name: p.name, billable: p.billable,
850
+ clientId: p.clientId,
851
+ clientName: client?.name ?? "—",
852
+ currency: client?.currency ?? "EUR",
853
+ repoPaths: p.repoPaths,
854
+ hourlyMinor: current?.amount ?? null,
855
+ blocks: totals.blocks,
856
+ seconds: totals.seconds ?? 0,
857
+ // El historial completo: subir la tarifa no reescribe lo ya trabajado,
858
+ // así que conviene poder verlo.
859
+ rateHistory: rates.map((r) => ({
860
+ minor: r.hourlyRate.amount,
861
+ currency: r.hourlyRate.currency,
862
+ from: r.effectiveFrom.toISOString().slice(0, 10),
863
+ to: r.effectiveTo ? r.effectiveTo.toISOString().slice(0, 10) : null,
864
+ })),
865
+ };
866
+ }),
867
+ };
868
+ }
869
+ /** Repos con actividad capturada que aún no pertenecen a ningún proyecto. */
870
+ function unassignedRepos(db) {
871
+ const rows = db.prepare(`
872
+ SELECT repo_path, COUNT(*) AS turns, MAX(at) AS last_at
873
+ FROM agent_turns WHERE repo_path IS NOT NULL
874
+ GROUP BY repo_path ORDER BY turns DESC
875
+ `).all();
876
+ return rows
877
+ .filter((r) => store.projectForRepo(db, r.repo_path) === null)
878
+ .slice(0, 40)
879
+ .map((r) => ({
880
+ path: r.repo_path,
881
+ name: r.repo_path.split("/").pop() ?? r.repo_path,
882
+ turns: r.turns,
883
+ lastAt: r.last_at.slice(0, 10),
884
+ }));
885
+ }
886
+ function createProject(db, body) {
887
+ const name = String(body["name"] ?? "").trim();
888
+ if (!name)
889
+ throw new Error("El proyecto necesita un nombre");
890
+ const clientName = String(body["clientName"] ?? "").trim();
891
+ let clientId = String(body["clientId"] ?? "").trim();
892
+ if (!clientId) {
893
+ if (!clientName)
894
+ throw new Error("Elige un cliente o escribe uno nuevo");
895
+ clientId = slug(clientName);
896
+ store.upsertClient(db, {
897
+ id: clientId, name: clientName,
898
+ currency: (String(body["currency"] ?? "EUR").toUpperCase()),
899
+ });
900
+ }
901
+ const id = String(body["id"] ?? "").trim() || slug(name);
902
+ const repo = String(body["repoPath"] ?? "").trim();
903
+ const kind = (String(body["kind"] ?? "client"));
904
+ store.upsertProject(db, {
905
+ id, clientId, name,
906
+ repoPaths: repo ? [repo] : [],
907
+ // Un empleo o un proyecto interno no son facturables por definición: no es
908
+ // una casilla que puedas marcar por error.
909
+ billable: kind === "client" && body["billable"] !== false,
910
+ roundingMinutes: Number(body["roundingMinutes"] ?? 0),
911
+ aiCostPolicy: "absorbed",
912
+ kind,
913
+ });
914
+ const rate = body["hourlyMinor"];
915
+ if (typeof rate === "number" && rate > 0) {
916
+ const client = store.getClient(db, clientId);
917
+ store.addRatePeriod(db, {
918
+ projectId: id,
919
+ hourlyRate: { amount: Math.round(rate), currency: client.currency },
920
+ effectiveFrom: new Date(0),
921
+ effectiveTo: null,
922
+ });
923
+ }
924
+ // Reasignar lo ya capturado de ese repositorio, que si no queda huérfano.
925
+ if (repo)
926
+ reassignOrphans(db, id, repo);
927
+ return id;
928
+ }
929
+ /**
930
+ * Cambia la tarifa a partir de una fecha.
931
+ *
932
+ * Sin `from`, la nueva tarifa arranca hoy: lo ya trabajado conserva la suya y un
933
+ * informe de agosto reimpreso en diciembre sigue dando lo que dio en agosto.
934
+ */
935
+ function setRate(db, body) {
936
+ const projectId = String(body["projectId"] ?? "");
937
+ const project = store.getProject(db, projectId);
938
+ if (!project)
939
+ throw new Error(`No existe el proyecto "${projectId}"`);
940
+ const minor = Number(body["hourlyMinor"]);
941
+ if (!Number.isFinite(minor) || minor <= 0)
942
+ throw new Error("Tarifa inválida");
943
+ const fromRaw = String(body["from"] ?? "").trim();
944
+ const client = store.getClient(db, project.clientId);
945
+ store.addRatePeriod(db, {
946
+ projectId,
947
+ hourlyRate: { amount: Math.round(minor), currency: client.currency },
948
+ effectiveFrom: fromRaw ? new Date(`${fromRaw}T00:00:00`) : new Date(),
949
+ effectiveTo: null,
950
+ });
951
+ }
952
+ /**
953
+ * Genera el panel publicable de un proyecto.
954
+ *
955
+ * Deja los ficheros en `~/.estela/publicar/e/<token>/` y devuelve el enlace ya
956
+ * montado más el comando para subirlo. No sube nada: enviar datos de un cliente
957
+ * a un servidor es una acción con consecuencias, y la lanza una persona a
958
+ * conciencia, no un botón de una página.
959
+ *
960
+ * El token se reutiliza si el proyecto ya se publicó: el cliente tiene ese
961
+ * enlace guardado y cambiárselo sin avisar le rompe el marcador.
962
+ */
963
+ async function publishPanel(db, body) {
964
+ const projectId = String(body["projectId"] ?? "");
965
+ const project = store.getProject(db, projectId);
966
+ if (!project)
967
+ throw new Error(`No existe el proyecto "${projectId}"`);
968
+ const client = store.getClient(db, project.clientId);
969
+ const rates = store.getRates(db, projectId);
970
+ const token = String(body["token"] ?? "").trim() || (0, panel_js_1.newPanelToken)();
971
+ const projectCommits = commitsOfProject(db, project.repoPaths);
972
+ const merged = project.repoPaths[0]
973
+ ? await (0, git_js_1.mergedBranches)(project.repoPaths[0]) : null;
974
+ const baseUrl = String(body["baseUrl"] ?? "").trim().replace(/\/+$/, "");
975
+ const html = (0, panel_js_1.buildPanel)({
976
+ project, client,
977
+ entries: store.getTimeEntries(db, projectId),
978
+ ...(body["author"] ? { authorName: String(body["author"]) } : {}),
979
+ withAmounts: body["withAmounts"] === true,
980
+ rateAt: (at) => (0, invoice_js_1.rateAt)(rates, projectId, at),
981
+ aiPayer: "self",
982
+ commits: projectCommits,
983
+ ...(merged ? { merged } : {}),
984
+ commitsOf: (hashes) => hashes.length
985
+ ? db.prepare(`SELECT hash, subject FROM commits WHERE hash IN (${hashes.map(() => "?").join(",")})`).all(...hashes)
986
+ : [],
987
+ });
988
+ store.recordPublication(db, projectId, token, baseUrl || null);
989
+ const root = (0, node_path_1.join)((0, node_os_1.homedir)(), ".estela", "publicar");
990
+ const dir = (0, node_path_1.join)(root, "e", token);
991
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
992
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, "index.html"), html, "utf8");
993
+ return {
994
+ token,
995
+ dir,
996
+ url: baseUrl ? `${baseUrl}/e/${token}/` : null,
997
+ uploadCommand: `rsync -av ${root}/ root@157.230.104.120:/var/www/estela/`,
998
+ sizeKb: Math.round(Buffer.byteLength(html) / 102.4) / 10,
999
+ };
1000
+ }
1001
+ /** Rehace las imputaciones de un repo que acaba de asignarse a un proyecto. */
1002
+ function reassignOrphans(db, projectId, repoPath) {
1003
+ const turns = db.prepare("SELECT COUNT(*) AS n FROM agent_turns WHERE repo_path = ? OR repo_path LIKE ?").get(repoPath, `${repoPath}/%`);
1004
+ if (turns.n === 0)
1005
+ return;
1006
+ // El import siguiente construye los bloques; aquí solo se deja constancia.
1007
+ db.prepare("INSERT INTO scan_log (ran_at, source, files_read, records_seen, turns_accepted, " +
1008
+ "duplicates_dropped, unknown_records, malformed_records, producer_versions, warnings) " +
1009
+ "VALUES (?,?,0,?,0,0,0,0,'','')")
1010
+ .run(new Date().toISOString(), `assign:${projectId}`, turns.n);
1011
+ }
1012
+ function slug(text) {
1013
+ return text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
1014
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "proyecto";
1015
+ }
1016
+ /** Devuelve el informe compartible como descarga. */
1017
+ function sendReport(db, res, projectId, from, to, withAmounts, author) {
1018
+ const project = store.getProject(db, projectId);
1019
+ if (!project)
1020
+ throw new Error(`No existe el proyecto "${projectId}"`);
1021
+ const client = store.getClient(db, project.clientId);
1022
+ const rates = store.getRates(db, projectId);
1023
+ const html = (0, share_js_1.buildShareReport)({
1024
+ project, client,
1025
+ entries: store.getTimeEntries(db, projectId),
1026
+ from: from || `${to.slice(0, 8)}01`,
1027
+ to,
1028
+ ...(author ? { authorName: author } : {}),
1029
+ withAmounts,
1030
+ rateAt: (at) => (0, invoice_js_1.rateAt)(rates, projectId, at),
1031
+ aiPayer: "self",
1032
+ commitsOf: (hashes) => hashes.length
1033
+ ? db.prepare(`SELECT hash, subject FROM commits WHERE hash IN (${hashes.map(() => "?").join(",")})`).all(...hashes)
1034
+ : [],
1035
+ });
1036
+ const filename = `informe-${projectId}-${to}.html`;
1037
+ const body = Buffer.from(html, "utf8");
1038
+ res.writeHead(200, {
1039
+ "Content-Type": "text/html; charset=utf-8",
1040
+ "Content-Disposition": `attachment; filename="${filename}"`,
1041
+ "Content-Length": body.length,
1042
+ });
1043
+ res.end(body);
1044
+ }
1045
+ /** Commits de los repositorios de un proyecto, con sus ficheros. */
1046
+ function commitsOfProject(db, repoPaths) {
1047
+ if (repoPaths.length === 0)
1048
+ return [];
1049
+ const like = repoPaths.map(() => "repo_path LIKE ?").join(" OR ");
1050
+ const rows = db.prepare(`SELECT * FROM commits WHERE ${like}`)
1051
+ .all(...repoPaths.map((p) => `${p}%`));
1052
+ return rows.map((r) => ({
1053
+ repoPath: r["repo_path"], hash: r["hash"],
1054
+ at: new Date(r["at"]), authorEmail: r["author_email"],
1055
+ authorName: r["author_name"] ?? "",
1056
+ branch: r["branch"] ?? null, subject: r["subject"],
1057
+ linesAdded: r["lines_added"], linesDeleted: r["lines_deleted"],
1058
+ files: String(r["files"] ?? "").split("\n").filter(Boolean),
1059
+ }));
1060
+ }
1061
+ /**
1062
+ * Aprueba todo lo pendiente de un periodo.
1063
+ *
1064
+ * Al configurar un proyecto entra de golpe todo su histórico sin revisar, y
1065
+ * repasarlo día a día no lo hace nadie. Lo ya facturado no se toca: esas horas
1066
+ * salieron en un documento y cambiarles el estado lo descuadraría.
1067
+ */
1068
+ function approveRange(db, from, to) {
1069
+ const ranged = from !== "" && to !== "";
1070
+ const result = db.prepare(`
1071
+ UPDATE time_entries SET approved = 1
1072
+ WHERE approved = 0 AND invoice_id IS NULL
1073
+ ${ranged ? "AND local_date >= ? AND local_date <= ?" : ""}
1074
+ `).run(...(ranged ? [from, to] : []));
1075
+ return Number(result.changes);
1076
+ }
1077
+ function approveDay(db, date) {
1078
+ const result = db.prepare(`
1079
+ UPDATE time_entries SET approved = 1
1080
+ WHERE local_date = ? AND invoice_id IS NULL
1081
+ `).run(date);
1082
+ return Number(result.changes);
1083
+ }
1084
+ // ---------------------------------------------------------------------------
1085
+ function todayIso() {
1086
+ return new Date().toISOString().slice(0, 10);
1087
+ }
1088
+ async function readJson(req) {
1089
+ const chunks = [];
1090
+ let size = 0;
1091
+ for await (const chunk of req) {
1092
+ size += chunk.length;
1093
+ if (size > 1_000_000)
1094
+ throw new Error("Cuerpo de petición demasiado grande");
1095
+ chunks.push(chunk);
1096
+ }
1097
+ if (chunks.length === 0)
1098
+ return {};
1099
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1100
+ }
1101
+ function json(res, status, body) {
1102
+ const payload = JSON.stringify(body);
1103
+ res.writeHead(status, {
1104
+ "Content-Type": "application/json; charset=utf-8",
1105
+ "Content-Length": Buffer.byteLength(payload),
1106
+ "Cache-Control": "no-store",
1107
+ });
1108
+ res.end(payload);
1109
+ }
1110
+ async function serveStatic(res, path) {
1111
+ // normalize() colapsa los ".." antes de comprobar, así que una ruta como
1112
+ // /../../etc/passwd no puede escapar de WEB_ROOT.
1113
+ const relative = (0, node_path_1.normalize)(path === "/" ? "/index.html" : path).replace(/^(\.\.[/\\])+/, "");
1114
+ const file = (0, node_path_1.join)(WEB_ROOT, relative);
1115
+ if (!file.startsWith(WEB_ROOT)) {
1116
+ res.writeHead(403).end("Prohibido");
1117
+ return;
1118
+ }
1119
+ try {
1120
+ const content = await (0, promises_1.readFile)(file);
1121
+ res.writeHead(200, {
1122
+ "Content-Type": MIME[(0, node_path_1.extname)(file)] ?? "application/octet-stream",
1123
+ "Content-Length": content.length,
1124
+ });
1125
+ res.end(content);
1126
+ }
1127
+ catch {
1128
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("No encontrado");
1129
+ }
1130
+ }
1131
+ if (require.main === module) {
1132
+ const port = Number(process.env["ESTELA_PORT"] ?? 4319);
1133
+ const dbPath = process.env["ESTELA_DB"] ?? schema_js_1.DEFAULT_DB_PATH;
1134
+ startServer({ port, dbPath }).then((address) => {
1135
+ console.log(`\n Estela ${address}`);
1136
+ console.log(` Import automático cada 5 min.`);
1137
+ console.log(` Datos: ${dbPath}\n`);
1138
+ console.log(" Ctrl+C para parar.\n");
1139
+ });
1140
+ }
1141
+ //# sourceMappingURL=server.js.map