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.
- package/README.md +138 -0
- package/dist/billing/amortize.d.ts +44 -0
- package/dist/billing/amortize.d.ts.map +1 -0
- package/dist/billing/amortize.js +83 -0
- package/dist/billing/amortize.js.map +1 -0
- package/dist/billing/amortize.test.d.ts +2 -0
- package/dist/billing/amortize.test.d.ts.map +1 -0
- package/dist/billing/amortize.test.js +121 -0
- package/dist/billing/amortize.test.js.map +1 -0
- package/dist/billing/format.test.d.ts +2 -0
- package/dist/billing/format.test.d.ts.map +1 -0
- package/dist/billing/format.test.js +110 -0
- package/dist/billing/format.test.js.map +1 -0
- package/dist/billing/invoice.d.ts +52 -0
- package/dist/billing/invoice.d.ts.map +1 -0
- package/dist/billing/invoice.js +148 -0
- package/dist/billing/invoice.js.map +1 -0
- package/dist/billing/invoice.test.d.ts +2 -0
- package/dist/billing/invoice.test.d.ts.map +1 -0
- package/dist/billing/invoice.test.js +200 -0
- package/dist/billing/invoice.test.js.map +1 -0
- package/dist/billing/sessionize.d.ts +104 -0
- package/dist/billing/sessionize.d.ts.map +1 -0
- package/dist/billing/sessionize.js +0 -0
- package/dist/billing/sessionize.js.map +1 -0
- package/dist/billing/sessionize.test.d.ts +2 -0
- package/dist/billing/sessionize.test.d.ts.map +1 -0
- package/dist/billing/sessionize.test.js +67 -0
- package/dist/billing/sessionize.test.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1091 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloud/auth.d.ts +4 -0
- package/dist/cloud/auth.d.ts.map +1 -0
- package/dist/cloud/auth.js +98 -0
- package/dist/cloud/auth.js.map +1 -0
- package/dist/cloud/client.d.ts +12 -0
- package/dist/cloud/client.d.ts.map +1 -0
- package/dist/cloud/client.js +34 -0
- package/dist/cloud/client.js.map +1 -0
- package/dist/db/schema.d.ts +11 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +424 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/schema.test.d.ts +2 -0
- package/dist/db/schema.test.d.ts.map +1 -0
- package/dist/db/schema.test.js +335 -0
- package/dist/db/schema.test.js.map +1 -0
- package/dist/db/store.d.ts +113 -0
- package/dist/db/store.d.ts.map +1 -0
- package/dist/db/store.js +550 -0
- package/dist/db/store.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +229 -0
- package/dist/doctor.js.map +1 -0
- package/dist/export/csv.d.ts +9 -0
- package/dist/export/csv.d.ts.map +1 -0
- package/dist/export/csv.js +81 -0
- package/dist/export/csv.js.map +1 -0
- package/dist/export/invoice-pdf.d.ts +21 -0
- package/dist/export/invoice-pdf.d.ts.map +1 -0
- package/dist/export/invoice-pdf.js +135 -0
- package/dist/export/invoice-pdf.js.map +1 -0
- package/dist/export/panel.d.ts +78 -0
- package/dist/export/panel.d.ts.map +1 -0
- package/dist/export/panel.js +693 -0
- package/dist/export/panel.js.map +1 -0
- package/dist/export/panel.test.d.ts +2 -0
- package/dist/export/panel.test.d.ts.map +1 -0
- package/dist/export/panel.test.js +273 -0
- package/dist/export/panel.test.js.map +1 -0
- package/dist/export/pdf.d.ts +32 -0
- package/dist/export/pdf.d.ts.map +1 -0
- package/dist/export/pdf.js +229 -0
- package/dist/export/pdf.js.map +1 -0
- package/dist/export/pdf.test.d.ts +2 -0
- package/dist/export/pdf.test.d.ts.map +1 -0
- package/dist/export/pdf.test.js +151 -0
- package/dist/export/pdf.test.js.map +1 -0
- package/dist/export/share.d.ts +42 -0
- package/dist/export/share.d.ts.map +1 -0
- package/dist/export/share.js +181 -0
- package/dist/export/share.js.map +1 -0
- package/dist/export/share.test.d.ts +2 -0
- package/dist/export/share.test.d.ts.map +1 -0
- package/dist/export/share.test.js +116 -0
- package/dist/export/share.test.js.map +1 -0
- package/dist/metrics/index.d.ts +101 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +221 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/metrics.test.d.ts +2 -0
- package/dist/metrics/metrics.test.d.ts.map +1 -0
- package/dist/metrics/metrics.test.js +162 -0
- package/dist/metrics/metrics.test.js.map +1 -0
- package/dist/metrics/people.d.ts +46 -0
- package/dist/metrics/people.d.ts.map +1 -0
- package/dist/metrics/people.js +126 -0
- package/dist/metrics/people.js.map +1 -0
- package/dist/metrics/people.test.d.ts +2 -0
- package/dist/metrics/people.test.d.ts.map +1 -0
- package/dist/metrics/people.test.js +84 -0
- package/dist/metrics/people.test.js.map +1 -0
- package/dist/metrics/team.d.ts +41 -0
- package/dist/metrics/team.d.ts.map +1 -0
- package/dist/metrics/team.js +0 -0
- package/dist/metrics/team.js.map +1 -0
- package/dist/metrics/team.test.d.ts +2 -0
- package/dist/metrics/team.test.d.ts.map +1 -0
- package/dist/metrics/team.test.js +90 -0
- package/dist/metrics/team.test.js.map +1 -0
- package/dist/pricing/catalog.d.ts +39 -0
- package/dist/pricing/catalog.d.ts.map +1 -0
- package/dist/pricing/catalog.js +59 -0
- package/dist/pricing/catalog.js.map +1 -0
- package/dist/pricing/cost.d.ts +25 -0
- package/dist/pricing/cost.d.ts.map +1 -0
- package/dist/pricing/cost.js +50 -0
- package/dist/pricing/cost.js.map +1 -0
- package/dist/pricing/cost.test.d.ts +2 -0
- package/dist/pricing/cost.test.d.ts.map +1 -0
- package/dist/pricing/cost.test.js +74 -0
- package/dist/pricing/cost.test.js.map +1 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +1141 -0
- package/dist/server.js.map +1 -0
- package/dist/setup.d.ts +70 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +223 -0
- package/dist/setup.js.map +1 -0
- package/dist/setup.test.d.ts +2 -0
- package/dist/setup.test.d.ts.map +1 -0
- package/dist/setup.test.js +131 -0
- package/dist/setup.test.js.map +1 -0
- package/dist/watchers/claude.d.ts +34 -0
- package/dist/watchers/claude.d.ts.map +1 -0
- package/dist/watchers/claude.js +235 -0
- package/dist/watchers/claude.js.map +1 -0
- package/dist/watchers/claude.test.d.ts +2 -0
- package/dist/watchers/claude.test.d.ts.map +1 -0
- package/dist/watchers/claude.test.js +180 -0
- package/dist/watchers/claude.test.js.map +1 -0
- package/dist/watchers/git.d.ts +42 -0
- package/dist/watchers/git.d.ts.map +1 -0
- package/dist/watchers/git.js +218 -0
- package/dist/watchers/git.js.map +1 -0
- package/dist/watchers/git.test.d.ts +2 -0
- package/dist/watchers/git.test.d.ts.map +1 -0
- package/dist/watchers/git.test.js +73 -0
- package/dist/watchers/git.test.js.map +1 -0
- package/dist/watchers/identity.d.ts +34 -0
- package/dist/watchers/identity.d.ts.map +1 -0
- package/dist/watchers/identity.js +85 -0
- package/dist/watchers/identity.js.map +1 -0
- package/dist/watchers/identity.test.d.ts +2 -0
- package/dist/watchers/identity.test.d.ts.map +1 -0
- package/dist/watchers/identity.test.js +48 -0
- package/dist/watchers/identity.test.js.map +1 -0
- package/dist/web-ui.test.d.ts +2 -0
- package/dist/web-ui.test.d.ts.map +1 -0
- package/dist/web-ui.test.js +312 -0
- package/dist/web-ui.test.js.map +1 -0
- package/node_modules/@estela/shared/dist/index.d.ts +3 -0
- package/node_modules/@estela/shared/dist/index.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/index.js +19 -0
- package/node_modules/@estela/shared/dist/index.js.map +1 -0
- package/node_modules/@estela/shared/dist/money.d.ts +71 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/money.js +171 -0
- package/node_modules/@estela/shared/dist/money.js.map +1 -0
- package/node_modules/@estela/shared/dist/types.d.ts +365 -0
- package/node_modules/@estela/shared/dist/types.d.ts.map +1 -0
- package/node_modules/@estela/shared/dist/types.js +24 -0
- package/node_modules/@estela/shared/dist/types.js.map +1 -0
- package/node_modules/@estela/shared/package.json +5 -0
- package/package.json +41 -0
- package/web/app.css +749 -0
- package/web/app.js +1257 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/favicon.png +0 -0
- package/web/index.html +132 -0
- package/web/lib.js +94 -0
- 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
|