estela 0.3.2 → 0.3.4
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 +3 -3
- package/dist/billing/assign.d.ts +27 -5
- package/dist/billing/assign.js +206 -28
- package/dist/billing/sessionize.d.ts +4 -1
- package/dist/billing/sessionize.js +11 -6
- package/dist/cli.js +46 -60
- package/dist/db/schema.js +9 -0
- package/dist/db/store.d.ts +24 -0
- package/dist/db/store.js +75 -4
- package/dist/i18n/en.js +7 -6
- package/dist/server.js +76 -16
- package/dist/setup.js +1 -1
- package/node_modules/@estela/shared/dist/types.d.ts +5 -0
- package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/web/app.css +4 -0
- package/web/app.js +67 -0
- package/web/i18n.js +24 -0
package/README.md
CHANGED
|
@@ -117,9 +117,9 @@ Two things trip people up before Estela even starts:
|
|
|
117
117
|
administrator terminal. `where.exe npm` should list a single path.
|
|
118
118
|
|
|
119
119
|
A session in a subfolder or a git worktree counts for its repository's
|
|
120
|
-
project.
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
project. Two branches at once in the same project share that time, so the total
|
|
121
|
+
is real time; different projects don't share. An import never moves hours between
|
|
122
|
+
projects or touches an approved day.
|
|
123
123
|
|
|
124
124
|
## Reporting to a client
|
|
125
125
|
|
package/dist/billing/assign.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
-
import type { CommitRecord, TimeEntry } from "@estela/shared";
|
|
2
|
+
import type { AgentTurn, CommitRecord, TimeEntry } from "@estela/shared";
|
|
3
3
|
import { type WorkBlock } from "./sessionize.js";
|
|
4
4
|
/**
|
|
5
5
|
* De bloques de trabajo a imputaciones: a qué proyecto va cada uno y con qué
|
|
@@ -13,7 +13,11 @@ import { type WorkBlock } from "./sessionize.js";
|
|
|
13
13
|
* mira primero, así que nada de lo ya asignado cambia de proyecto.
|
|
14
14
|
* 2. Un día aprobado o facturado de ese proyecto no se toca: las horas que se
|
|
15
15
|
* recuperarían ahí se informan (`blocked`), no se aplican.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Y dos ramas a la vez en el mismo proyecto no suman dos horas por cada hora de
|
|
18
|
+
* reloj: lo que registra Estela es tiempo real, y ese rato se reparte entre
|
|
19
|
+
* ellas (ver `sharedSeconds`). Cobrar un precio pactado por encima de lo medido
|
|
20
|
+
* es un ajuste a mano con su motivo, que conserva lo medido al lado.
|
|
17
21
|
*/
|
|
18
22
|
export interface PlannedEntry {
|
|
19
23
|
readonly entry: Omit<TimeEntry, "id"> & {
|
|
@@ -37,6 +41,10 @@ export interface AssignmentPlan {
|
|
|
37
41
|
readonly recovered: ReadonlyMap<string, number>;
|
|
38
42
|
/** Lo que se recuperaría en días aprobados o facturados: no se aplica. */
|
|
39
43
|
readonly blocked: readonly RecoveryBlocked[];
|
|
44
|
+
/** Imputaciones sin horas propias y sin revisar: se retiran. */
|
|
45
|
+
readonly stale: readonly string[];
|
|
46
|
+
/** Las mismas, pero aprobadas, ajustadas o facturadas: no se tocan, se avisa. */
|
|
47
|
+
readonly protectedDuplicates: readonly string[];
|
|
40
48
|
}
|
|
41
49
|
export declare function entryId(projectId: string, day: string, branch: string | null): string;
|
|
42
50
|
/**
|
|
@@ -47,9 +55,23 @@ export declare function entryId(projectId: string, day: string, branch: string |
|
|
|
47
55
|
* lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
|
|
48
56
|
* trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
|
|
49
57
|
* guarda todos los turnos: `saveTurns` no borra nada.
|
|
58
|
+
*
|
|
59
|
+
* `git log --all` desde un worktree trae la historia entera del repo, así que
|
|
60
|
+
* cada commit queda guardado también bajo la ruta del worktree. Por eso se
|
|
61
|
+
* compara por raíz git: sin ella, esa copia no la tapa ninguna sesión del repo
|
|
62
|
+
* y, al recuperar, sumaba horas estimadas a días ya medidos.
|
|
50
63
|
*/
|
|
51
64
|
export declare function commitOnlyBlocks(db: DatabaseSync, commits: readonly CommitRecord[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Todos los turnos conocidos: los de la base más los recién leídos.
|
|
67
|
+
*
|
|
68
|
+
* Una imputación se reescribe entera, así que hay que calcularla con todo lo
|
|
69
|
+
* que hubo ese día. Con solo lo leído del disco, un transcript que Claude Code
|
|
70
|
+
* ya limpió (o un `--since`) dejaba 4 h medidas convertidas en los 25 min del
|
|
71
|
+
* único commit que quedaba. Lo recién leído manda porque el lector sabe cosas
|
|
72
|
+
* que la base no: `saveTurns` no reescribe un turno ya guardado, y una versión
|
|
73
|
+
* nueva puede devolverlo a su carpeta real.
|
|
74
|
+
*/
|
|
75
|
+
export declare function knownTurns(db: DatabaseSync, scanned: readonly AgentTurn[]): AgentTurn[];
|
|
76
|
+
export declare function planEntries(db: DatabaseSync, raw: readonly WorkBlock[]): AssignmentPlan;
|
|
55
77
|
//# sourceMappingURL=assign.d.ts.map
|
package/dist/billing/assign.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.entryId = entryId;
|
|
37
37
|
exports.commitOnlyBlocks = commitOnlyBlocks;
|
|
38
|
+
exports.knownTurns = knownTurns;
|
|
38
39
|
exports.planEntries = planEntries;
|
|
39
40
|
const shared_1 = require("@estela/shared");
|
|
40
41
|
const store = __importStar(require("../db/store.js"));
|
|
@@ -43,8 +44,10 @@ const sessionize_js_1 = require("./sessionize.js");
|
|
|
43
44
|
function entryId(projectId, day, branch) {
|
|
44
45
|
// Determinista (proyecto + día + rama) para que reimportar actualice en vez
|
|
45
46
|
// de duplicar. La fecha, la LOCAL: la misma que usa el servidor.
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
return `te_${projectId}_${day}_${branchSlug(branch)}`;
|
|
48
|
+
}
|
|
49
|
+
function branchSlug(branch) {
|
|
50
|
+
return (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
|
|
48
51
|
}
|
|
49
52
|
/**
|
|
50
53
|
* Los bloques deducidos solo de commits: los que no se solapan con ninguna
|
|
@@ -54,12 +57,105 @@ function entryId(projectId, day, branch) {
|
|
|
54
57
|
* lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
|
|
55
58
|
* trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
|
|
56
59
|
* guarda todos los turnos: `saveTurns` no borra nada.
|
|
60
|
+
*
|
|
61
|
+
* `git log --all` desde un worktree trae la historia entera del repo, así que
|
|
62
|
+
* cada commit queda guardado también bajo la ruta del worktree. Por eso se
|
|
63
|
+
* compara por raíz git: sin ella, esa copia no la tapa ninguna sesión del repo
|
|
64
|
+
* y, al recuperar, sumaba horas estimadas a días ya medidos.
|
|
57
65
|
*/
|
|
58
66
|
function commitOnlyBlocks(db, commits, agentBlocks) {
|
|
59
67
|
const saved = (0, sessionize_js_1.sessionize)(store.turnsBetween(db, new Date(0), new Date("9999-12-31")));
|
|
60
|
-
|
|
68
|
+
const roots = new Map();
|
|
69
|
+
const rootOf = (path) => {
|
|
70
|
+
let root = roots.get(path);
|
|
71
|
+
if (root === undefined) {
|
|
72
|
+
root = (0, project_of_js_1.gitRoot)(path) ?? path;
|
|
73
|
+
roots.set(path, root);
|
|
74
|
+
}
|
|
75
|
+
return root;
|
|
76
|
+
};
|
|
77
|
+
return (0, sessionize_js_1.withoutOverlap)((0, sessionize_js_1.sessionizeCommits)(commits), [...agentBlocks, ...saved], rootOf);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Todos los turnos conocidos: los de la base más los recién leídos.
|
|
81
|
+
*
|
|
82
|
+
* Una imputación se reescribe entera, así que hay que calcularla con todo lo
|
|
83
|
+
* que hubo ese día. Con solo lo leído del disco, un transcript que Claude Code
|
|
84
|
+
* ya limpió (o un `--since`) dejaba 4 h medidas convertidas en los 25 min del
|
|
85
|
+
* único commit que quedaba. Lo recién leído manda porque el lector sabe cosas
|
|
86
|
+
* que la base no: `saveTurns` no reescribe un turno ya guardado, y una versión
|
|
87
|
+
* nueva puede devolverlo a su carpeta real.
|
|
88
|
+
*/
|
|
89
|
+
function knownTurns(db, scanned) {
|
|
90
|
+
const byId = new Map(store.turnsBetween(db, new Date(0), new Date("9999-12-31")).map((t) => [t.turnId, t]));
|
|
91
|
+
for (const turn of scanned)
|
|
92
|
+
byId.set(turn.turnId, turn);
|
|
93
|
+
return [...byId.values()];
|
|
61
94
|
}
|
|
62
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Imputaciones guardadas que ya no salen en el plan y cuyas horas están dentro
|
|
97
|
+
* de otras del mismo proyecto.
|
|
98
|
+
*
|
|
99
|
+
* El id lleva la rama, y la rama de un bloque es la de su último turno: si la
|
|
100
|
+
* sesión sigue en otra rama, la siguiente importación crea otra imputación con
|
|
101
|
+
* el bloque entero y la anterior se quedaba, cobrando dos veces el mismo rato.
|
|
102
|
+
*
|
|
103
|
+
* Retirar horas es peor que no hacerlo si se acierta mal, así que se exige todo:
|
|
104
|
+
*
|
|
105
|
+
* - Ningún bloque de ese día lleva esa rama, tenga proyecto o no. Si lo lleva,
|
|
106
|
+
* esas horas siguen existiendo, como las de un worktree ya borrado.
|
|
107
|
+
* - Los bloques del proyecto cubren lo que dice la imputación, en su horario.
|
|
108
|
+
* Si no, son horas de las que no queda otro rastro.
|
|
109
|
+
*
|
|
110
|
+
* Lo aprobado, ajustado o facturado no se retira nunca: solo se avisa.
|
|
111
|
+
*/
|
|
112
|
+
function findDuplicates(db, raw, planned, projectOf) {
|
|
113
|
+
const rows = db.prepare(`SELECT id, project_id, local_date, branch, started_at, ended_at, seconds, approved, invoice_id, adjust_reason
|
|
114
|
+
FROM time_entries WHERE source IN ('agent', 'commit')`).all();
|
|
115
|
+
const stale = [];
|
|
116
|
+
const protectedDuplicates = [];
|
|
117
|
+
const blocksByDay = new Map();
|
|
118
|
+
for (const block of raw) {
|
|
119
|
+
const day = (0, shared_1.localDate)(block.startedAt);
|
|
120
|
+
const bucket = blocksByDay.get(day);
|
|
121
|
+
if (bucket)
|
|
122
|
+
bucket.push(block);
|
|
123
|
+
else
|
|
124
|
+
blocksByDay.set(day, [block]);
|
|
125
|
+
}
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
if (planned.has(row["id"]))
|
|
128
|
+
continue;
|
|
129
|
+
const day = blocksByDay.get(row["local_date"]) ?? [];
|
|
130
|
+
const slug = branchSlug(row["branch"] ?? null);
|
|
131
|
+
if (day.some((b) => branchSlug(b.branch) === slug))
|
|
132
|
+
continue;
|
|
133
|
+
const from = new Date(row["started_at"]).getTime();
|
|
134
|
+
const to = new Date(row["ended_at"]).getTime();
|
|
135
|
+
const covered = coveredMs(day.filter((b) => projectOf(b.repoPath) === row["project_id"]), from, to) / 1000;
|
|
136
|
+
if (covered < row["seconds"] - 60)
|
|
137
|
+
continue;
|
|
138
|
+
const locked = row["approved"] === 1 || row["invoice_id"] !== null || row["adjust_reason"] !== null;
|
|
139
|
+
(locked ? protectedDuplicates : stale).push(row["id"]);
|
|
140
|
+
}
|
|
141
|
+
return { stale, protectedDuplicates };
|
|
142
|
+
}
|
|
143
|
+
/** Cuánto de [from, to] cae dentro de los bloques, sin contar dos veces un solape. */
|
|
144
|
+
function coveredMs(blocks, from, to) {
|
|
145
|
+
const intervals = blocks
|
|
146
|
+
.map((b) => [Math.max(b.startedAt.getTime(), from), Math.min(b.endedAt.getTime(), to)])
|
|
147
|
+
.sort((a, b) => a[0] - b[0]);
|
|
148
|
+
let ms = 0;
|
|
149
|
+
let until = from;
|
|
150
|
+
for (const [start, end] of intervals) {
|
|
151
|
+
const desde = Math.max(start, until);
|
|
152
|
+
if (end > desde)
|
|
153
|
+
ms += end - desde;
|
|
154
|
+
until = Math.max(until, end);
|
|
155
|
+
}
|
|
156
|
+
return ms;
|
|
157
|
+
}
|
|
158
|
+
function planEntries(db, raw) {
|
|
63
159
|
const ownerOf = (0, project_of_js_1.ownerResolver)(db);
|
|
64
160
|
const direct = (repo) => (repo ? store.projectForRepo(db, repo) : null);
|
|
65
161
|
const viaRoot = (repo) => {
|
|
@@ -68,20 +164,11 @@ function planEntries(db, raw, options) {
|
|
|
68
164
|
const o = ownerOf(repo);
|
|
69
165
|
return o.kind === "project" ? o.projectId : null;
|
|
70
166
|
};
|
|
71
|
-
//
|
|
72
|
-
// `repo
|
|
73
|
-
//
|
|
167
|
+
// La carpeta tal cual. Se agrupa por PROYECTO, no por ruta: `repo/` y
|
|
168
|
+
// `repo/src/UI` son el mismo proyecto, y agrupar por ruta daba dos bloques que
|
|
169
|
+
// luego se pisaban al guardarse con el mismo id.
|
|
74
170
|
const keyDirect = (b) => direct(b.repoPath) ?? b.repoPath ?? "";
|
|
75
|
-
const
|
|
76
|
-
const base = byId(groupedBase, direct);
|
|
77
|
-
if (!options.recover) {
|
|
78
|
-
return {
|
|
79
|
-
entries: [...base.values()].map((g) => ({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: 0 })),
|
|
80
|
-
grouped: groupedBase.length,
|
|
81
|
-
unassigned: groupedBase.filter((b) => !direct(b.repoPath)).length,
|
|
82
|
-
recovered: new Map(), blocked: [],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
171
|
+
const base = byId((0, sessionize_js_1.groupByBranchAndDay)(raw, keyDirect), direct);
|
|
85
172
|
// Con la raíz git, pero solo para lo que no tenía proyecto (regla 1).
|
|
86
173
|
const assigned = (repo) => direct(repo) ?? viaRoot(repo);
|
|
87
174
|
const keyFull = (b) => assigned(b.repoPath) ?? b.repoPath ?? "";
|
|
@@ -105,35 +192,126 @@ function planEntries(db, raw, options) {
|
|
|
105
192
|
}
|
|
106
193
|
return lockedDays.get(k);
|
|
107
194
|
};
|
|
108
|
-
|
|
109
|
-
|
|
195
|
+
// Primero qué filas quedan y con qué bloques, y después el reparto: una sesión
|
|
196
|
+
// que la regla 2 deja fuera no puede restarle tiempo a la que sí cuenta.
|
|
197
|
+
const rows = [];
|
|
110
198
|
const blocked = [];
|
|
199
|
+
const rawByProject = (projectOf) => {
|
|
200
|
+
const out = new Map();
|
|
201
|
+
for (const b of raw) {
|
|
202
|
+
const p = projectOf(b.repoPath);
|
|
203
|
+
if (!p)
|
|
204
|
+
continue;
|
|
205
|
+
const id = entryId(p, (0, shared_1.localDate)(b.startedAt), b.branch);
|
|
206
|
+
const bucket = out.get(id);
|
|
207
|
+
if (bucket)
|
|
208
|
+
bucket.push(b);
|
|
209
|
+
else
|
|
210
|
+
out.set(id, [b]);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
};
|
|
214
|
+
const fullBlocks = rawByProject(assigned);
|
|
215
|
+
const directBlocks = rawByProject(direct);
|
|
111
216
|
for (const [id, g] of full) {
|
|
112
217
|
const before = base.get(id);
|
|
113
|
-
if (!touched.has(id)) {
|
|
114
|
-
entries.push({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: 0 });
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
const gain = Math.max(0, g.block.seconds - (before?.block.seconds ?? 0));
|
|
118
218
|
const day = (0, shared_1.localDate)(g.block.startedAt);
|
|
119
|
-
if (locked(g.projectId, day)) {
|
|
219
|
+
if (touched.has(id) && locked(g.projectId, day)) {
|
|
120
220
|
// Regla 2: el día se queda como estaba; si antes no había nada, nada.
|
|
121
221
|
if (before)
|
|
122
|
-
|
|
222
|
+
rows.push({ id, projectId: before.projectId, block: before.block, blocks: directBlocks.get(id) ?? [], before: null });
|
|
223
|
+
const gain = Math.max(0, g.block.seconds - (before?.block.seconds ?? 0));
|
|
123
224
|
if (gain > 0)
|
|
124
225
|
blocked.push({ projectId: g.projectId, day, seconds: gain });
|
|
125
226
|
continue;
|
|
126
227
|
}
|
|
127
|
-
|
|
228
|
+
rows.push({ id, projectId: g.projectId, block: g.block, blocks: fullBlocks.get(id) ?? [],
|
|
229
|
+
before: touched.has(id) ? (before?.block.seconds ?? 0) : null });
|
|
230
|
+
}
|
|
231
|
+
const shares = sharedSeconds(rows, lockedEntryIds(db));
|
|
232
|
+
const entries = [];
|
|
233
|
+
const recovered = new Map();
|
|
234
|
+
const empty = [];
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
const seconds = shares.get(row.id) ?? row.block.seconds;
|
|
237
|
+
if (seconds <= 0) {
|
|
238
|
+
empty.push(row.id);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const gain = row.before === null ? 0 : Math.max(0, seconds - row.before);
|
|
242
|
+
entries.push({ entry: toEntry(db, row.projectId, { ...row.block, seconds }), recoveredSeconds: gain });
|
|
128
243
|
if (gain > 0)
|
|
129
|
-
recovered.set(
|
|
244
|
+
recovered.set(row.projectId, (recovered.get(row.projectId) ?? 0) + gain);
|
|
130
245
|
}
|
|
246
|
+
const duplicates = findDuplicates(db, raw, new Set(entries.map((e) => e.entry.id)), assigned);
|
|
131
247
|
return {
|
|
132
248
|
entries, grouped: groupedFull.length,
|
|
133
249
|
unassigned: groupedFull.filter((b) => !assigned(b.repoPath)).length,
|
|
134
250
|
recovered, blocked,
|
|
251
|
+
stale: [...duplicates.stale, ...empty],
|
|
252
|
+
protectedDuplicates: duplicates.protectedDuplicates,
|
|
135
253
|
};
|
|
136
254
|
}
|
|
255
|
+
/** Las imputaciones cuya cifra ya no puede cambiar. */
|
|
256
|
+
function lockedEntryIds(db) {
|
|
257
|
+
const rows = db.prepare("SELECT id FROM time_entries WHERE approved = 1 OR invoice_id IS NOT NULL").all();
|
|
258
|
+
return new Set(rows.map((r) => r.id));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Los segundos de cada imputación cuando varias ramas del mismo proyecto
|
|
262
|
+
* coinciden en el reloj: el rato compartido se reparte a partes iguales, y el
|
|
263
|
+
* total del proyecto es el tiempo real.
|
|
264
|
+
*
|
|
265
|
+
* Dos chats en dos worktrees durante una hora son una hora de quien trabaja, no
|
|
266
|
+
* dos. Proyectos distintos no se reparten entre sí: esa decisión es por cliente.
|
|
267
|
+
*
|
|
268
|
+
* Lo aprobado o facturado ya tiene su cifra y no cambia, así que el rato que
|
|
269
|
+
* cubre ya está cobrado o dado por bueno: las demás no reciben nada de él.
|
|
270
|
+
*/
|
|
271
|
+
function sharedSeconds(rows, lockedIds) {
|
|
272
|
+
const byProject = new Map();
|
|
273
|
+
for (const row of rows) {
|
|
274
|
+
const bucket = byProject.get(row.projectId);
|
|
275
|
+
if (bucket)
|
|
276
|
+
bucket.push(row);
|
|
277
|
+
else
|
|
278
|
+
byProject.set(row.projectId, [row]);
|
|
279
|
+
}
|
|
280
|
+
const ms = new Map();
|
|
281
|
+
for (const projectRows of byProject.values()) {
|
|
282
|
+
const events = [];
|
|
283
|
+
for (const row of projectRows) {
|
|
284
|
+
for (const b of row.blocks) {
|
|
285
|
+
events.push({ at: b.startedAt.getTime(), id: row.id, delta: 1 });
|
|
286
|
+
events.push({ at: b.endedAt.getTime(), id: row.id, delta: -1 });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
events.sort((a, b) => a.at - b.at);
|
|
290
|
+
const active = new Map();
|
|
291
|
+
let previous = events[0]?.at ?? 0;
|
|
292
|
+
for (const event of events) {
|
|
293
|
+
if (event.at > previous && active.size > 0) {
|
|
294
|
+
const span = event.at - previous;
|
|
295
|
+
const ids = [...active.keys()];
|
|
296
|
+
const lockedHere = ids.filter((id) => lockedIds.has(id));
|
|
297
|
+
const payees = lockedHere.length ? lockedHere : ids;
|
|
298
|
+
const share = lockedHere.length ? span : span / ids.length;
|
|
299
|
+
for (const id of payees)
|
|
300
|
+
ms.set(id, (ms.get(id) ?? 0) + share);
|
|
301
|
+
}
|
|
302
|
+
previous = event.at;
|
|
303
|
+
const count = (active.get(event.id) ?? 0) + event.delta;
|
|
304
|
+
if (count > 0)
|
|
305
|
+
active.set(event.id, count);
|
|
306
|
+
else
|
|
307
|
+
active.delete(event.id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const seconds = new Map();
|
|
311
|
+
for (const row of rows)
|
|
312
|
+
seconds.set(row.id, Math.round((ms.get(row.id) ?? 0) / 1000));
|
|
313
|
+
return seconds;
|
|
314
|
+
}
|
|
137
315
|
/** Los bloques agrupados con proyecto, por el id con el que se guardarían. */
|
|
138
316
|
function byId(blocks, projectOf) {
|
|
139
317
|
const out = new Map();
|
|
@@ -118,6 +118,9 @@ export declare function sessionizeCommits(commits: readonly CommitRecord[], opti
|
|
|
118
118
|
* del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
|
|
119
119
|
* y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
|
|
120
120
|
* se sumaban 25 minutos estimados encima de una sesión medida.
|
|
121
|
+
*
|
|
122
|
+
* Un worktree vive fuera del repositorio y las rutas no lo dicen: `rootOf` da
|
|
123
|
+
* la raíz de cada carpeta, y es esa la que se compara.
|
|
121
124
|
*/
|
|
122
|
-
export declare function withoutOverlap(commitBlocks: readonly WorkBlock[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
|
|
125
|
+
export declare function withoutOverlap(commitBlocks: readonly WorkBlock[], agentBlocks: readonly WorkBlock[], rootOf?: (path: string) => string): WorkBlock[];
|
|
123
126
|
//# sourceMappingURL=sessionize.d.ts.map
|
|
@@ -118,7 +118,6 @@ function groupByBranchAndDay(blocks, ambito = (b) => b.repoPath ?? "") {
|
|
|
118
118
|
for (const bucket of groups.values()) {
|
|
119
119
|
bucket.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime());
|
|
120
120
|
const first = bucket[0];
|
|
121
|
-
const last = bucket[bucket.length - 1];
|
|
122
121
|
// Ni el lapso de principio a fin —entre bloques hubo huecos en los que no
|
|
123
122
|
// estabas trabajando, y facturarlos sería cobrar de más— ni la suma de
|
|
124
123
|
// duraciones: los segundos de un bloque SON su intervalo de reloj, y dos
|
|
@@ -154,7 +153,9 @@ function groupByBranchAndDay(blocks, ambito = (b) => b.repoPath ?? "") {
|
|
|
154
153
|
}
|
|
155
154
|
merged.push({
|
|
156
155
|
startedAt: first.startedAt,
|
|
157
|
-
|
|
156
|
+
// El último en empezar no es siempre el último en acabar: una sesión en
|
|
157
|
+
// una subcarpeta puede caer entera dentro de la del repo.
|
|
158
|
+
endedAt: new Date(Math.max(...bucket.map((b) => b.endedAt.getTime()))),
|
|
158
159
|
seconds,
|
|
159
160
|
repoPath: first.repoPath,
|
|
160
161
|
branch: first.branch,
|
|
@@ -295,11 +296,15 @@ function sessionizeCommits(commits, options = {}) {
|
|
|
295
296
|
* del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
|
|
296
297
|
* y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
|
|
297
298
|
* se sumaban 25 minutos estimados encima de una sesión medida.
|
|
299
|
+
*
|
|
300
|
+
* Un worktree vive fuera del repositorio y las rutas no lo dicen: `rootOf` da
|
|
301
|
+
* la raíz de cada carpeta, y es esa la que se compara.
|
|
298
302
|
*/
|
|
299
|
-
function withoutOverlap(commitBlocks, agentBlocks) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
c.startedAt.getTime() < a.endedAt.getTime()
|
|
303
|
+
function withoutOverlap(commitBlocks, agentBlocks, rootOf = (path) => path) {
|
|
304
|
+
const root = (path) => (path ? rootOf(path) : null);
|
|
305
|
+
return commitBlocks.filter((c) => !agentBlocks.some((a) => a.startedAt.getTime() < c.endedAt.getTime() &&
|
|
306
|
+
c.startedAt.getTime() < a.endedAt.getTime() &&
|
|
307
|
+
sameRepo(root(a.repoPath), root(c.repoPath))));
|
|
303
308
|
}
|
|
304
309
|
function sameRepo(a, b) {
|
|
305
310
|
if (a === b)
|
package/dist/cli.js
CHANGED
|
@@ -96,12 +96,12 @@ estela — registro de horas para desarrollo asistido por IA
|
|
|
96
96
|
Primera ejecución: detecta tus agentes y repositorios, y reconstruye
|
|
97
97
|
tu historial. Dos minutos, sin preguntas y sin cuenta.
|
|
98
98
|
|
|
99
|
-
estela import [--since YYYY-MM-DD] [--repo <ruta>]
|
|
99
|
+
estela import [--since YYYY-MM-DD] [--repo <ruta>]
|
|
100
100
|
Reconstruye tu historial desde los transcripts de los agentes y git.
|
|
101
101
|
Una sesión en una subcarpeta o en un worktree cuenta para el
|
|
102
|
-
proyecto de su repo.
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
proyecto de su repo. Dos ramas a la vez en un proyecto se reparten
|
|
103
|
+
ese rato: el total es tiempo real. Nunca mueve horas de un proyecto
|
|
104
|
+
a otro ni toca un día aprobado.
|
|
105
105
|
|
|
106
106
|
estela client add --id <id> --name <nombre> --currency <EUR|USD|...>
|
|
107
107
|
[--tax-id <NIF/CIF>] [--email <a@b.com>]
|
|
@@ -307,12 +307,12 @@ estela — time tracking for AI-assisted development
|
|
|
307
307
|
First run: detects your agents and repositories, and rebuilds your
|
|
308
308
|
history. Two minutes, no questions, no account.
|
|
309
309
|
|
|
310
|
-
estela import [--since YYYY-MM-DD] [--repo <path>]
|
|
310
|
+
estela import [--since YYYY-MM-DD] [--repo <path>]
|
|
311
311
|
Rebuilds your history from agent transcripts and git. A session in a
|
|
312
|
-
subfolder or a worktree counts for its repo's project.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
312
|
+
subfolder or a worktree counts for its repo's project. Two branches
|
|
313
|
+
at once in a project share that time: the total is real time. It
|
|
314
|
+
never moves hours from one project to another or touches an approved
|
|
315
|
+
day.
|
|
316
316
|
|
|
317
317
|
estela client add --id <id> --name <name> --currency <EUR|USD|...>
|
|
318
318
|
[--tax-id <tax ID>] [--email <a@b.com>]
|
|
@@ -780,18 +780,6 @@ async function cmdSetup(args, dbPath) {
|
|
|
780
780
|
db.close();
|
|
781
781
|
}
|
|
782
782
|
}
|
|
783
|
-
/** La vista previa de la primera recuperación: horas por proyecto. */
|
|
784
|
-
function printRecoveryPreview(db, plan) {
|
|
785
|
-
console.log((0, index_js_1.tr) `\nEstela ahora reconoce las sesiones en subcarpetas y worktrees de un repositorio.`);
|
|
786
|
-
console.log((0, index_js_1.tr) `Estas horas no estaban imputadas y se sumarían a su proyecto:\n`);
|
|
787
|
-
for (const [projectId, seconds] of [...plan.recovered].sort((a, b) => b[1] - a[1])) {
|
|
788
|
-
const name = store.getProject(db, projectId)?.name ?? projectId;
|
|
789
|
-
console.log(` ${name.padEnd(30)} +${(0, shared_1.formatDuration)(seconds)}`);
|
|
790
|
-
}
|
|
791
|
-
console.log((0, index_js_1.tr) `\n Nada de lo ya imputado cambia de proyecto, y los días aprobados no se tocan.`);
|
|
792
|
-
printRecoveryBlocked(db, plan.blocked);
|
|
793
|
-
console.log("");
|
|
794
|
-
}
|
|
795
783
|
/** Lo que se recuperaría en días aprobados o facturados: se avisa, no se aplica. */
|
|
796
784
|
function printRecoveryBlocked(db, blocked) {
|
|
797
785
|
if (blocked.length === 0)
|
|
@@ -890,7 +878,12 @@ async function cmdImport(args, dbPath) {
|
|
|
890
878
|
continue;
|
|
891
879
|
savedCommits += store.saveCommits(db, await (0, git_js_1.readCommits)(root, since ? { since } : {}));
|
|
892
880
|
}
|
|
893
|
-
|
|
881
|
+
// Todo lo conocido, no solo lo leído ahora (ver knownTurns). Con --repo, solo
|
|
882
|
+
// ese repo, que es lo que se ha pedido.
|
|
883
|
+
const known = (0, assign_js_1.knownTurns)(db, turns).filter((t) => !repoPaths || (t.repoPath !== null && repoPaths.some((p) => (0, paths_js_1.isSameOrInside)(t.repoPath, p))));
|
|
884
|
+
// Los correos también de los repos sin sesión reciente: sin ellos, un día
|
|
885
|
+
// antiguo se recalcularía sin sus commits y perdería la descripción.
|
|
886
|
+
const mine = await (0, identity_js_1.myEmailsByRepo)(db, new Set([...repos, ...known.flatMap((t) => t.repoPath ?? [])]));
|
|
894
887
|
if (rescued > 0) {
|
|
895
888
|
console.log((0, index_js_1.tr) ` ${rescued} turnos de scratchpad devueltos a su repositorio`);
|
|
896
889
|
}
|
|
@@ -905,57 +898,42 @@ async function cmdImport(args, dbPath) {
|
|
|
905
898
|
// Solo TUS commits derivan horas tuyas. Sin este filtro, los commits de un
|
|
906
899
|
// compañero se convertirían en tiempo facturable a tu nombre.
|
|
907
900
|
const commitsAll = (0, identity_js_1.onlyMine)(await allCommits(db), mine);
|
|
908
|
-
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(
|
|
901
|
+
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(known), commitsAll);
|
|
909
902
|
const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commitsAll, fromAgents);
|
|
910
903
|
const raw = [...fromAgents, ...fromCommits];
|
|
911
904
|
if (fromCommits.length) {
|
|
912
905
|
console.log((0, index_js_1.tr) ` ${fromCommits.length} bloques deducidos solo de commits (sin agente)`);
|
|
913
906
|
}
|
|
914
|
-
//
|
|
915
|
-
|
|
916
|
-
// importadas se enseñan antes y se pide confirmación; en una base nueva no
|
|
917
|
-
// hay nada que cambiar, así que se aplica sin preguntar.
|
|
918
|
-
const marca = (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), ".recover-git-root");
|
|
919
|
-
const habiaHoras = db.prepare("SELECT COUNT(*) AS n FROM time_entries").get().n > 0;
|
|
920
|
-
let plan = (0, assign_js_1.planEntries)(db, raw, { recover: true });
|
|
921
|
-
const conRaiz = plan;
|
|
922
|
-
const recuperable = [...plan.recovered.values()].reduce((a, b) => a + b, 0);
|
|
923
|
-
if (!(0, node_fs_1.existsSync)(marca) && habiaHoras && recuperable > 0) {
|
|
924
|
-
printRecoveryPreview(db, plan);
|
|
925
|
-
const ok = args.flags["yes"] === true || await confirm((0, index_js_1.tr) `¿Imputar estas horas? [s/N] `);
|
|
926
|
-
if (!ok) {
|
|
927
|
-
plan = (0, assign_js_1.planEntries)(db, raw, { recover: false });
|
|
928
|
-
console.log((0, index_js_1.tr) ` No se ha tocado nada. Cuando quieras: estela import --yes\n`);
|
|
929
|
-
}
|
|
930
|
-
else {
|
|
931
|
-
(0, node_fs_1.writeFileSync)(marca, new Date().toISOString());
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
else {
|
|
935
|
-
try {
|
|
936
|
-
(0, node_fs_1.writeFileSync)(marca, new Date().toISOString());
|
|
937
|
-
}
|
|
938
|
-
catch { /* sin marca, se volverá a preguntar */ }
|
|
939
|
-
}
|
|
907
|
+
// Sesiones en subcarpetas y worktrees incluidas: ver billing/assign.ts.
|
|
908
|
+
const plan = (0, assign_js_1.planEntries)(db, raw);
|
|
940
909
|
for (const { entry } of plan.entries)
|
|
941
910
|
store.saveTimeEntry(db, entry);
|
|
911
|
+
// Con --repo el plan no ve el resto de proyectos: nada de retirar.
|
|
912
|
+
const retired = repoPaths ? 0 : store.deleteStaleEntries(db, plan.stale);
|
|
942
913
|
const entries = plan.entries.length;
|
|
943
914
|
const unassigned = plan.unassigned;
|
|
944
915
|
console.log((0, index_js_1.tr) `Bloques: ${raw.length} detectados → ${plan.grouped} tras agrupar por rama y día · ` +
|
|
945
916
|
(0, index_js_1.tr) `${entries} imputados · ${unassigned} sin proyecto`);
|
|
946
917
|
const aplicadas = [...plan.recovered.values()].reduce((a, b) => a + b, 0);
|
|
947
|
-
if (aplicadas > 0
|
|
918
|
+
if (aplicadas > 0) {
|
|
948
919
|
console.log((0, index_js_1.tr) ` ${(0, shared_1.formatDuration)(aplicadas)} recuperadas de sesiones en subcarpetas y worktrees`);
|
|
949
920
|
}
|
|
950
921
|
printRecoveryBlocked(db, plan.blocked);
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
922
|
+
if (retired > 0) {
|
|
923
|
+
console.log((0, index_js_1.tr) ` ${retired} imputaciones retiradas: sus horas ya estaban en otra rama del mismo día`);
|
|
924
|
+
}
|
|
925
|
+
// Estas no se tocan, pero conviene saberlo antes de facturar lo que las repite.
|
|
926
|
+
const pendientes = plan.protectedDuplicates.filter((id) => {
|
|
927
|
+
const row = db.prepare("SELECT invoice_id FROM time_entries WHERE id = ?").get(id);
|
|
928
|
+
return row?.invoice_id === null;
|
|
929
|
+
});
|
|
930
|
+
if (pendientes.length) {
|
|
931
|
+
console.log((0, index_js_1.tr) `\n${pendientes.length} imputaciones aprobadas o ajustadas repiten horas de otra rama. Revísalas antes de facturar:`);
|
|
932
|
+
for (const id of pendientes.slice(0, 10))
|
|
933
|
+
console.log(` ${id}`);
|
|
934
|
+
}
|
|
935
|
+
if (unassigned > 0) {
|
|
936
|
+
console.log((0, index_js_1.tr) `\nHay ${unassigned} bloques sin proyecto asignado. Regístralos con:`);
|
|
959
937
|
console.log((0, index_js_1.tr) ` estela project add --id <id> --client <id> --name <nombre> --repo <ruta>`);
|
|
960
938
|
}
|
|
961
939
|
}
|
|
@@ -2476,7 +2454,9 @@ function cmdInvoice(args, dbPath) {
|
|
|
2476
2454
|
const cutoffAt = new Date(`${cutoffRaw}T23:59:59Z`);
|
|
2477
2455
|
if (Number.isNaN(cutoffAt.getTime()))
|
|
2478
2456
|
throw new UserError((0, index_js_1.tr) `Fecha inválida: ${cutoffRaw}`);
|
|
2479
|
-
|
|
2457
|
+
// Si va seguida de otra palabra, parseArgs la toma como su valor. Una opción
|
|
2458
|
+
// que protege no puede dejar de hacerlo por eso: se emitía el informe de verdad.
|
|
2459
|
+
const dryRun = "dry-run" in args.flags;
|
|
2480
2460
|
const number = str(args, "number") ?? store.nextInvoiceNumber(db, "INF");
|
|
2481
2461
|
const fx = str(args, "fx");
|
|
2482
2462
|
const entries = store.getTimeEntries(db, projectId);
|
|
@@ -2531,7 +2511,7 @@ function cmdInvoice(args, dbPath) {
|
|
|
2531
2511
|
},
|
|
2532
2512
|
} : {}),
|
|
2533
2513
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
2534
|
-
withAmounts:
|
|
2514
|
+
withAmounts: !("no-amounts" in args.flags),
|
|
2535
2515
|
}));
|
|
2536
2516
|
(0, node_fs_1.writeFileSync)(pdfPath, pdf);
|
|
2537
2517
|
console.log((0, index_js_1.tr) `\nPDF: ${pdfPath} (${(pdf.length / 1024).toFixed(1)} KB, ${invoice.lines.length} conceptos)`);
|
|
@@ -2577,6 +2557,12 @@ async function main() {
|
|
|
2577
2557
|
console.log(VERSION);
|
|
2578
2558
|
return;
|
|
2579
2559
|
}
|
|
2560
|
+
// Igual de pronto: sin esto, "estela import --help" ignoraba la opción y
|
|
2561
|
+
// ejecutaba el comando de verdad. Pedir ayuda no puede tocar los datos.
|
|
2562
|
+
if ("help" in args.flags || argv.includes("-h")) {
|
|
2563
|
+
console.log(helpText());
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2580
2566
|
(0, client_js_1.setClientVersion)(VERSION);
|
|
2581
2567
|
const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
|
|
2582
2568
|
// `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
|
package/dist/db/schema.js
CHANGED
|
@@ -47,6 +47,8 @@ CREATE TABLE IF NOT EXISTS projects (
|
|
|
47
47
|
ai_budget_micro_usd INTEGER,
|
|
48
48
|
-- NULL = activo. Ver la migración 12 para por qué cerrar no bloquea nada.
|
|
49
49
|
closed_at TEXT,
|
|
50
|
+
-- NULL = visible. Archivado: fuera del panel y sin captar horas.
|
|
51
|
+
archived_at TEXT,
|
|
50
52
|
-- Gestor de tareas en JSON ({"system":"jira","prefixes":["PROJ"]}). NULL =
|
|
51
53
|
-- sin configurar: solo se reconocen los tickets inequívocos.
|
|
52
54
|
tracker TEXT
|
|
@@ -292,6 +294,13 @@ CREATE TABLE IF NOT EXISTS claude_plans (
|
|
|
292
294
|
* empezar no es una opción de mantenimiento.
|
|
293
295
|
*/
|
|
294
296
|
const MIGRATIONS = [
|
|
297
|
+
{
|
|
298
|
+
version: 17,
|
|
299
|
+
describe: "archivar proyectos",
|
|
300
|
+
run: (db) => {
|
|
301
|
+
addColumn(db, "projects", "archived_at", "TEXT");
|
|
302
|
+
},
|
|
303
|
+
},
|
|
295
304
|
{
|
|
296
305
|
version: 16,
|
|
297
306
|
describe: "rutas de Windows iguales vengan de donde vengan",
|
package/dist/db/store.d.ts
CHANGED
|
@@ -32,6 +32,19 @@ export declare function setProjectTracker(db: DatabaseSync, projectId: string, t
|
|
|
32
32
|
export declare function closeProject(db: DatabaseSync, id: string, at?: Date): void;
|
|
33
33
|
/** Reabre un proyecto cerrado. Sin efecto si ya estaba abierto. */
|
|
34
34
|
export declare function reopenProject(db: DatabaseSync, id: string): void;
|
|
35
|
+
/** Archiva un proyecto: sale del panel y deja de captar. No borra nada. */
|
|
36
|
+
export declare function archiveProject(db: DatabaseSync, id: string, at?: Date): void;
|
|
37
|
+
export declare function restoreProject(db: DatabaseSync, id: string): void;
|
|
38
|
+
export declare function listArchivedProjects(db: DatabaseSync): Project[];
|
|
39
|
+
/** Por qué no se puede eliminar un proyecto, o null si se puede. */
|
|
40
|
+
export declare function projectDeleteBlocker(db: DatabaseSync, id: string): "invoiced" | "shared" | null;
|
|
41
|
+
/**
|
|
42
|
+
* Elimina un proyecto y sus imputaciones, tarifas y vínculos. Los turnos y los
|
|
43
|
+
* commits se quedan: son de la máquina, no del proyecto, y si el repo se vuelve
|
|
44
|
+
* a registrar sus horas reaparecen. Lo facturado o compartido no se elimina.
|
|
45
|
+
*/
|
|
46
|
+
export declare function deleteProject(db: DatabaseSync, id: string): void;
|
|
47
|
+
/** Los proyectos a la vista. Los archivados, con `listArchivedProjects`. */
|
|
35
48
|
export declare function listProjects(db: DatabaseSync): Project[];
|
|
36
49
|
export declare function listClients(db: DatabaseSync): Client[];
|
|
37
50
|
export declare function getRates(db: DatabaseSync, projectId: string): RatePeriod[];
|
|
@@ -76,6 +89,11 @@ export declare function listPublications(db: DatabaseSync): Publication[];
|
|
|
76
89
|
export declare function authorsByRepo(db: DatabaseSync): Map<string, string[]>;
|
|
77
90
|
/** Encuentra el proyecto al que pertenece una ruta de repositorio. */
|
|
78
91
|
export declare function projectForRepo(db: DatabaseSync, repoPath: string): string | null;
|
|
92
|
+
/**
|
|
93
|
+
* Como `projectForRepo`, contando los archivados. Para lo que ofrece registrar
|
|
94
|
+
* un repo: uno archivado ya tiene dueño, y ofrecerlo otra vez lo resucitaría.
|
|
95
|
+
*/
|
|
96
|
+
export declare function ownerOfRepo(db: DatabaseSync, repoPath: string): string | null;
|
|
79
97
|
/**
|
|
80
98
|
* Todos los repositorios que ya están vinculados a algún proyecto, sin
|
|
81
99
|
* importar cómo llegaron ahí (un transcript de agente, `estela project add`,
|
|
@@ -135,6 +153,12 @@ export declare class AdjustError extends Error {
|
|
|
135
153
|
export declare function adjustEntrySeconds(db: DatabaseSync, id: string, seconds: number, reason: string, at?: Date): void;
|
|
136
154
|
/** Quita el ajuste y devuelve el bloque a lo que mide el agente. */
|
|
137
155
|
export declare function clearEntryAdjustment(db: DatabaseSync, id: string, at?: Date): void;
|
|
156
|
+
/**
|
|
157
|
+
* Retira imputaciones repetidas (ver `findDuplicates` en billing/assign.ts).
|
|
158
|
+
* Repite aquí las condiciones de `saveTimeEntry`: lo escrito a mano, lo aprobado,
|
|
159
|
+
* lo facturado y lo ajustado no se borra desde ningún camino.
|
|
160
|
+
*/
|
|
161
|
+
export declare function deleteStaleEntries(db: DatabaseSync, ids: readonly string[]): number;
|
|
138
162
|
export declare function saveTimeEntry(db: DatabaseSync, entry: Omit<TimeEntry, "id"> & {
|
|
139
163
|
id?: string;
|
|
140
164
|
}, at?: Date): string;
|
package/dist/db/store.js
CHANGED
|
@@ -10,6 +10,11 @@ exports.getProject = getProject;
|
|
|
10
10
|
exports.setProjectTracker = setProjectTracker;
|
|
11
11
|
exports.closeProject = closeProject;
|
|
12
12
|
exports.reopenProject = reopenProject;
|
|
13
|
+
exports.archiveProject = archiveProject;
|
|
14
|
+
exports.restoreProject = restoreProject;
|
|
15
|
+
exports.listArchivedProjects = listArchivedProjects;
|
|
16
|
+
exports.projectDeleteBlocker = projectDeleteBlocker;
|
|
17
|
+
exports.deleteProject = deleteProject;
|
|
13
18
|
exports.listProjects = listProjects;
|
|
14
19
|
exports.listClients = listClients;
|
|
15
20
|
exports.getRates = getRates;
|
|
@@ -21,6 +26,7 @@ exports.forgetPublication = forgetPublication;
|
|
|
21
26
|
exports.listPublications = listPublications;
|
|
22
27
|
exports.authorsByRepo = authorsByRepo;
|
|
23
28
|
exports.projectForRepo = projectForRepo;
|
|
29
|
+
exports.ownerOfRepo = ownerOfRepo;
|
|
24
30
|
exports.allProjectRepoPaths = allProjectRepoPaths;
|
|
25
31
|
exports.saveTurns = saveTurns;
|
|
26
32
|
exports.quotaTurns = quotaTurns;
|
|
@@ -30,6 +36,7 @@ exports.saveCommits = saveCommits;
|
|
|
30
36
|
exports.logScan = logScan;
|
|
31
37
|
exports.adjustEntrySeconds = adjustEntrySeconds;
|
|
32
38
|
exports.clearEntryAdjustment = clearEntryAdjustment;
|
|
39
|
+
exports.deleteStaleEntries = deleteStaleEntries;
|
|
33
40
|
exports.saveTimeEntry = saveTimeEntry;
|
|
34
41
|
exports.setEntryWorkItems = setEntryWorkItems;
|
|
35
42
|
exports.getTimeEntries = getTimeEntries;
|
|
@@ -154,6 +161,7 @@ function getProject(db, id) {
|
|
|
154
161
|
aiCostPolicy: row["ai_cost_policy"],
|
|
155
162
|
kind: row["kind"] ?? "client",
|
|
156
163
|
closedAt: row["closed_at"] ? new Date(row["closed_at"]) : null,
|
|
164
|
+
archivedAt: row["archived_at"] ? new Date(row["archived_at"]) : null,
|
|
157
165
|
tracker: row["tracker"] ? JSON.parse(row["tracker"]) : null,
|
|
158
166
|
};
|
|
159
167
|
}
|
|
@@ -182,8 +190,45 @@ function closeProject(db, id, at = new Date()) {
|
|
|
182
190
|
function reopenProject(db, id) {
|
|
183
191
|
db.prepare("UPDATE projects SET closed_at = NULL WHERE id = ?").run(id);
|
|
184
192
|
}
|
|
193
|
+
/** Archiva un proyecto: sale del panel y deja de captar. No borra nada. */
|
|
194
|
+
function archiveProject(db, id, at = new Date()) {
|
|
195
|
+
db.prepare("UPDATE projects SET archived_at = ? WHERE id = ? AND archived_at IS NULL").run(iso(at), id);
|
|
196
|
+
}
|
|
197
|
+
function restoreProject(db, id) {
|
|
198
|
+
db.prepare("UPDATE projects SET archived_at = NULL WHERE id = ?").run(id);
|
|
199
|
+
}
|
|
200
|
+
function listArchivedProjects(db) {
|
|
201
|
+
const rows = db.prepare("SELECT id FROM projects WHERE archived_at IS NOT NULL ORDER BY name").all();
|
|
202
|
+
return rows.map((r) => getProject(db, r.id));
|
|
203
|
+
}
|
|
204
|
+
/** Por qué no se puede eliminar un proyecto, o null si se puede. */
|
|
205
|
+
function projectDeleteBlocker(db, id) {
|
|
206
|
+
const count = (sql) => db.prepare(sql).get(id).n;
|
|
207
|
+
if (count("SELECT COUNT(*) AS n FROM invoices WHERE project_id = ?") > 0)
|
|
208
|
+
return "invoiced";
|
|
209
|
+
if (count("SELECT COUNT(*) AS n FROM time_entries WHERE project_id = ? AND invoice_id IS NOT NULL") > 0)
|
|
210
|
+
return "invoiced";
|
|
211
|
+
// Lo que hay fuera de esta máquina se quedaría sin dueño aquí.
|
|
212
|
+
if (count("SELECT COUNT(*) AS n FROM publications WHERE project_id = ?") > 0)
|
|
213
|
+
return "shared";
|
|
214
|
+
if (count("SELECT COUNT(*) AS n FROM project_sync WHERE project_id = ?") > 0)
|
|
215
|
+
return "shared";
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Elimina un proyecto y sus imputaciones, tarifas y vínculos. Los turnos y los
|
|
220
|
+
* commits se quedan: son de la máquina, no del proyecto, y si el repo se vuelve
|
|
221
|
+
* a registrar sus horas reaparecen. Lo facturado o compartido no se elimina.
|
|
222
|
+
*/
|
|
223
|
+
function deleteProject(db, id) {
|
|
224
|
+
const blocker = projectDeleteBlocker(db, id);
|
|
225
|
+
if (blocker)
|
|
226
|
+
throw new Error(`project ${id} cannot be deleted: ${blocker}`);
|
|
227
|
+
db.prepare("DELETE FROM projects WHERE id = ?").run(id);
|
|
228
|
+
}
|
|
229
|
+
/** Los proyectos a la vista. Los archivados, con `listArchivedProjects`. */
|
|
185
230
|
function listProjects(db) {
|
|
186
|
-
const rows = db.prepare("SELECT id FROM projects ORDER BY name").all();
|
|
231
|
+
const rows = db.prepare("SELECT id FROM projects WHERE archived_at IS NULL ORDER BY name").all();
|
|
187
232
|
return rows.map((r) => getProject(db, r.id)).filter(Boolean);
|
|
188
233
|
}
|
|
189
234
|
function listClients(db) {
|
|
@@ -298,8 +343,18 @@ function authorsByRepo(db) {
|
|
|
298
343
|
}
|
|
299
344
|
/** Encuentra el proyecto al que pertenece una ruta de repositorio. */
|
|
300
345
|
function projectForRepo(db, repoPath) {
|
|
301
|
-
|
|
302
|
-
|
|
346
|
+
return matchRepo(db, repoPath, `SELECT r.project_id, r.repo_path FROM project_repos r
|
|
347
|
+
JOIN projects p ON p.id = r.project_id WHERE p.archived_at IS NULL`);
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Como `projectForRepo`, contando los archivados. Para lo que ofrece registrar
|
|
351
|
+
* un repo: uno archivado ya tiene dueño, y ofrecerlo otra vez lo resucitaría.
|
|
352
|
+
*/
|
|
353
|
+
function ownerOfRepo(db, repoPath) {
|
|
354
|
+
return matchRepo(db, repoPath, "SELECT project_id, repo_path FROM project_repos");
|
|
355
|
+
}
|
|
356
|
+
function matchRepo(db, repoPath, sql) {
|
|
357
|
+
const rows = db.prepare(sql).all();
|
|
303
358
|
// El prefijo más largo gana: permite anidar un subproyecto dentro de un monorepo.
|
|
304
359
|
// isSameOrInside y no startsWith: en Windows la misma carpeta llega con \ o
|
|
305
360
|
// con /, y con la unidad en mayúscula o minúscula.
|
|
@@ -509,6 +564,21 @@ function clearEntryAdjustment(db, id, at = new Date()) {
|
|
|
509
564
|
WHERE id = ?
|
|
510
565
|
`).run(iso(at), id);
|
|
511
566
|
}
|
|
567
|
+
/**
|
|
568
|
+
* Retira imputaciones repetidas (ver `findDuplicates` en billing/assign.ts).
|
|
569
|
+
* Repite aquí las condiciones de `saveTimeEntry`: lo escrito a mano, lo aprobado,
|
|
570
|
+
* lo facturado y lo ajustado no se borra desde ningún camino.
|
|
571
|
+
*/
|
|
572
|
+
function deleteStaleEntries(db, ids) {
|
|
573
|
+
const stmt = db.prepare(`
|
|
574
|
+
DELETE FROM time_entries WHERE id = ?
|
|
575
|
+
AND source IN ('agent', 'commit') AND invoice_id IS NULL
|
|
576
|
+
AND approved = 0 AND adjust_reason IS NULL`);
|
|
577
|
+
let deleted = 0;
|
|
578
|
+
for (const id of ids)
|
|
579
|
+
deleted += Number(stmt.run(id).changes);
|
|
580
|
+
return deleted;
|
|
581
|
+
}
|
|
512
582
|
function saveTimeEntry(db, entry, at = new Date()) {
|
|
513
583
|
const id = entry.id ?? `te_${(0, node_crypto_1.randomUUID)()}`;
|
|
514
584
|
db.prepare(`
|
|
@@ -935,7 +1005,8 @@ function listTeamSyncedProjects(db) {
|
|
|
935
1005
|
function listPersonalSyncCandidates(db) {
|
|
936
1006
|
const rows = db.prepare(`
|
|
937
1007
|
SELECT id FROM projects
|
|
938
|
-
WHERE
|
|
1008
|
+
WHERE archived_at IS NULL
|
|
1009
|
+
AND id NOT IN (SELECT project_id FROM project_sync WHERE scope = 'team')
|
|
939
1010
|
`).all();
|
|
940
1011
|
return rows.map((r) => r.id);
|
|
941
1012
|
}
|
package/dist/i18n/en.js
CHANGED
|
@@ -540,16 +540,17 @@ exports.EN = {
|
|
|
540
540
|
"{0} turnos de modelos sin precio de lista cuentan como 0 en ese %.": "{0} turns from models without a list price count as 0 in that %.",
|
|
541
541
|
"La ventana de 5 h es la que da Claude Code; el reparto solo incluye lo usado en esta máquina.": "The 5-hour window is the one Claude Code reports; the split only includes what was used on this machine.",
|
|
542
542
|
// ── import: horas de subcarpetas y worktrees ──
|
|
543
|
-
"\nEstela ahora reconoce las sesiones en subcarpetas y worktrees de un repositorio.": "\nEstela now recognizes sessions in a repository's subfolders and worktrees.",
|
|
544
|
-
"Estas horas no estaban imputadas y se sumarían a su proyecto:\n": "These hours weren't assigned and would be added to their project:\n",
|
|
545
|
-
"\n Nada de lo ya imputado cambia de proyecto, y los días aprobados no se tocan.": "\n Nothing already assigned changes project, and approved days aren't touched.",
|
|
546
543
|
"\n Sin tocar, porque ese día ya está aprobado o facturado:": "\n Left alone, because that day is already approved or invoiced:",
|
|
547
544
|
" {0} · {1}: {2} recuperables. Revísalo con: estela entries --project {3}": " {0} · {1}: {2} recoverable. Review it with: estela entries --project {3}",
|
|
548
|
-
"
|
|
549
|
-
"
|
|
545
|
+
" {0} imputaciones retiradas: sus horas ya estaban en otra rama del mismo día": " {0} entries removed: their hours were already in another branch that day",
|
|
546
|
+
"\n{0} imputaciones aprobadas o ajustadas repiten horas de otra rama. Revísalas antes de facturar:": "\n{0} approved or adjusted entries repeat hours from another branch. Review them before invoicing:",
|
|
547
|
+
"Archiva el proyecto antes de eliminarlo.": "Archive the project before deleting it.",
|
|
548
|
+
"El nombre no coincide: no se ha eliminado nada.": "The name doesn't match: nothing was deleted.",
|
|
549
|
+
"Tiene horas facturadas: se puede archivar, pero no eliminar.": "It has invoiced hours: it can be archived, but not deleted.",
|
|
550
|
+
"Está publicado o sincronizado: se puede archivar, pero no eliminar.": "It's published or synced: it can be archived, but not deleted.",
|
|
551
|
+
"Ya hay un proyecto archivado con ese nombre: restáuralo.": "There's already an archived project with that name: restore it.",
|
|
550
552
|
" {0} recuperadas de sesiones en subcarpetas y worktrees": " {0} recovered from sessions in subfolders and worktrees",
|
|
551
553
|
"{0} (sin proyecto)": "{0} (no project)",
|
|
552
|
-
"\n{0} de esos bloques son de subcarpetas o worktrees de un proyecto: estela import --yes los imputa.": "\n{0} of those blocks are from a project's subfolders or worktrees: estela import --yes assigns them.",
|
|
553
554
|
// ── publish: resumen antes de subir, y unpublish ──
|
|
554
555
|
"No se ha publicado nada. Sin terminal no se puede confirmar: añade --yes para publicar.": "Nothing was published. Without a terminal there's no way to confirm: add --yes to publish.",
|
|
555
556
|
"¿Publicar? [s/N] ": "Publish? [y/N] ",
|
package/dist/server.js
CHANGED
|
@@ -65,6 +65,8 @@ const assign_js_1 = require("./billing/assign.js");
|
|
|
65
65
|
const quota_js_1 = require("./metrics/quota.js");
|
|
66
66
|
const quota_js_2 = require("./quota.js");
|
|
67
67
|
const labels_js_1 = require("./i18n/labels.js");
|
|
68
|
+
/** Las imputaciones de un proyecto archivado no salen en ninguna vista del panel. */
|
|
69
|
+
const VISIBLE = "project_id NOT IN (SELECT id FROM projects WHERE archived_at IS NOT NULL)";
|
|
68
70
|
/**
|
|
69
71
|
* Servidor local del dashboard.
|
|
70
72
|
*
|
|
@@ -128,7 +130,11 @@ async function importOnce(dbPath) {
|
|
|
128
130
|
continue;
|
|
129
131
|
store.saveCommits(db, await (0, git_js_1.readCommits)(root, {}));
|
|
130
132
|
}
|
|
131
|
-
|
|
133
|
+
// Todo lo conocido, no solo lo leído ahora (ver knownTurns), y los correos
|
|
134
|
+
// de todos esos repos: sin ellos, un día antiguo perdería sus commits.
|
|
135
|
+
const known = (0, assign_js_1.knownTurns)(db, turns);
|
|
136
|
+
const mine = await (0, identity_js_1.myEmailsByRepo)(db, new Set([...repos, ...store.allProjectRepoPaths(db),
|
|
137
|
+
...known.flatMap((t) => t.repoPath ?? [])]));
|
|
132
138
|
const allRepoCommits = db.prepare("SELECT * FROM commits").all()
|
|
133
139
|
.map((r) => ({
|
|
134
140
|
repoPath: r["repo_path"], hash: r["hash"],
|
|
@@ -141,18 +147,15 @@ async function importOnce(dbPath) {
|
|
|
141
147
|
let imputed = 0;
|
|
142
148
|
// Solo tus commits producen horas tuyas.
|
|
143
149
|
const commits = (0, identity_js_1.onlyMine)(allRepoCommits, mine);
|
|
144
|
-
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(
|
|
150
|
+
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(known), commits);
|
|
145
151
|
const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commits, fromAgents);
|
|
146
|
-
// Las mismas reglas que `estela import` (billing/assign.ts).
|
|
147
|
-
|
|
148
|
-
// desde la terminal: el panel no cambia horas importadas en segundo plano
|
|
149
|
-
// sin que nadie haya visto antes qué iba a cambiar.
|
|
150
|
-
const recover = (0, node_fs_1.existsSync)((0, node_path_1.join)((0, node_path_1.dirname)(dbPath), ".recover-git-root"));
|
|
151
|
-
const plan = (0, assign_js_1.planEntries)(db, [...fromAgents, ...fromCommits], { recover });
|
|
152
|
+
// Las mismas reglas que `estela import` (billing/assign.ts).
|
|
153
|
+
const plan = (0, assign_js_1.planEntries)(db, [...fromAgents, ...fromCommits]);
|
|
152
154
|
for (const { entry } of plan.entries) {
|
|
153
155
|
store.saveTimeEntry(db, entry);
|
|
154
156
|
imputed++;
|
|
155
157
|
}
|
|
158
|
+
store.deleteStaleEntries(db, plan.stale);
|
|
156
159
|
return imputed;
|
|
157
160
|
}
|
|
158
161
|
finally {
|
|
@@ -322,7 +325,7 @@ async function api(req, res, url, dbPath) {
|
|
|
322
325
|
const projectId = url.searchParams.get("project");
|
|
323
326
|
const rows = db.prepare(`
|
|
324
327
|
SELECT local_date AS d, COUNT(*) AS n FROM time_entries
|
|
325
|
-
WHERE approved = 0 AND invoice_id IS NULL
|
|
328
|
+
WHERE approved = 0 AND invoice_id IS NULL AND ${VISIBLE}
|
|
326
329
|
${projectId ? "AND project_id = ?" : ""}
|
|
327
330
|
GROUP BY local_date ORDER BY local_date DESC
|
|
328
331
|
`).all(...(projectId ? [projectId] : []));
|
|
@@ -351,6 +354,49 @@ async function api(req, res, url, dbPath) {
|
|
|
351
354
|
const body = await readJson(req);
|
|
352
355
|
return json(res, 200, { id: createProject(db, body) });
|
|
353
356
|
}
|
|
357
|
+
if (path === "/api/project-archive" && req.method === "POST") {
|
|
358
|
+
const body = await readJson(req);
|
|
359
|
+
const projectId = String(body["projectId"] ?? "");
|
|
360
|
+
if (!store.getProject(db, projectId)) {
|
|
361
|
+
return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
|
|
362
|
+
}
|
|
363
|
+
store.archiveProject(db, projectId);
|
|
364
|
+
return json(res, 200, { ok: true });
|
|
365
|
+
}
|
|
366
|
+
if (path === "/api/project-restore" && req.method === "POST") {
|
|
367
|
+
const body = await readJson(req);
|
|
368
|
+
const projectId = String(body["projectId"] ?? "");
|
|
369
|
+
if (!store.getProject(db, projectId)) {
|
|
370
|
+
return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
|
|
371
|
+
}
|
|
372
|
+
store.restoreProject(db, projectId);
|
|
373
|
+
// Lo trabajado mientras estuvo archivado sigue en los turnos: se imputa ya.
|
|
374
|
+
const status = await runImport(dbPath);
|
|
375
|
+
return json(res, 200, { ok: true, importing: status.pending === true });
|
|
376
|
+
}
|
|
377
|
+
/* Borrar no tiene vuelta atrás: se exige haberlo archivado antes y escribir
|
|
378
|
+
su nombre, también aquí y no solo en la página. */
|
|
379
|
+
if (path === "/api/project-delete" && req.method === "POST") {
|
|
380
|
+
const body = await readJson(req);
|
|
381
|
+
const projectId = String(body["projectId"] ?? "");
|
|
382
|
+
const project = store.getProject(db, projectId);
|
|
383
|
+
if (!project)
|
|
384
|
+
return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
|
|
385
|
+
if (!project.archivedAt)
|
|
386
|
+
return json(res, 409, { error: (0, index_js_2.tr) `Archiva el proyecto antes de eliminarlo.` });
|
|
387
|
+
if (String(body["confirmName"] ?? "") !== project.name) {
|
|
388
|
+
return json(res, 400, { error: (0, index_js_2.tr) `El nombre no coincide: no se ha eliminado nada.` });
|
|
389
|
+
}
|
|
390
|
+
const blocker = store.projectDeleteBlocker(db, projectId);
|
|
391
|
+
if (blocker === "invoiced") {
|
|
392
|
+
return json(res, 409, { error: (0, index_js_2.tr) `Tiene horas facturadas: se puede archivar, pero no eliminar.` });
|
|
393
|
+
}
|
|
394
|
+
if (blocker === "shared") {
|
|
395
|
+
return json(res, 409, { error: (0, index_js_2.tr) `Está publicado o sincronizado: se puede archivar, pero no eliminar.` });
|
|
396
|
+
}
|
|
397
|
+
store.deleteProject(db, projectId);
|
|
398
|
+
return json(res, 200, { ok: true });
|
|
399
|
+
}
|
|
354
400
|
/* Con quién está compartido cada panel. El panel local no habla con la
|
|
355
401
|
base de la nube: pregunta a la API con el token de este dispositivo,
|
|
356
402
|
igual que hace para publicar. Sin cuenta vinculada no hay nada que
|
|
@@ -626,6 +672,7 @@ function buildOverview(db, from = "", to = "") {
|
|
|
626
672
|
SUM(ai_micro_usd) AS micro,
|
|
627
673
|
SUM(approved) AS approved
|
|
628
674
|
FROM time_entries
|
|
675
|
+
WHERE ${VISIBLE}
|
|
629
676
|
GROUP BY date
|
|
630
677
|
ORDER BY date DESC
|
|
631
678
|
LIMIT 30
|
|
@@ -648,7 +695,7 @@ function buildOverview(db, from = "", to = "") {
|
|
|
648
695
|
SELECT project_id, SUM(seconds) AS seconds, SUM(ai_micro_usd) AS micro, COUNT(*) AS blocks,
|
|
649
696
|
MIN(local_date) AS first_day, MAX(local_date) AS last_day
|
|
650
697
|
FROM time_entries
|
|
651
|
-
WHERE billable = 1 AND invoice_id IS NULL
|
|
698
|
+
WHERE billable = 1 AND invoice_id IS NULL AND ${VISIBLE}
|
|
652
699
|
${ranged ? "AND local_date >= ? AND local_date <= ?" : ""}
|
|
653
700
|
GROUP BY project_id
|
|
654
701
|
`).all(...(ranged ? [from, to] : []));
|
|
@@ -677,7 +724,7 @@ function buildOverview(db, from = "", to = "") {
|
|
|
677
724
|
? db.prepare(`
|
|
678
725
|
SELECT COALESCE(SUM(seconds),0) AS seconds, COUNT(*) AS blocks
|
|
679
726
|
FROM time_entries
|
|
680
|
-
WHERE billable = 1 AND invoice_id IS NULL
|
|
727
|
+
WHERE billable = 1 AND invoice_id IS NULL AND ${VISIBLE}
|
|
681
728
|
AND (local_date < ? OR local_date > ?)
|
|
682
729
|
`).get(from, to)
|
|
683
730
|
: { seconds: 0, blocks: 0 };
|
|
@@ -712,7 +759,7 @@ function buildSummary(db, from, to) {
|
|
|
712
759
|
MAX(local_date) AS last_day,
|
|
713
760
|
COUNT(DISTINCT local_date) AS days
|
|
714
761
|
FROM time_entries
|
|
715
|
-
WHERE local_date >= ? AND local_date <= ?
|
|
762
|
+
WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
|
|
716
763
|
GROUP BY project_id
|
|
717
764
|
`).all(from, to);
|
|
718
765
|
const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
|
|
@@ -752,7 +799,7 @@ function buildSummary(db, from, to) {
|
|
|
752
799
|
const byDay = db.prepare(`
|
|
753
800
|
SELECT local_date AS date, SUM(seconds) AS seconds
|
|
754
801
|
FROM time_entries
|
|
755
|
-
WHERE local_date >= ? AND local_date <= ?
|
|
802
|
+
WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
|
|
756
803
|
GROUP BY local_date ORDER BY local_date
|
|
757
804
|
`).all(from, to);
|
|
758
805
|
const totalSeconds = items.reduce((s, i) => s + i.seconds, 0);
|
|
@@ -819,7 +866,7 @@ async function buildTeam(db, from, to, projectId) {
|
|
|
819
866
|
// de qué fiarse, y ninguna otra cifra de la pantalla lo dice.
|
|
820
867
|
const composition = db.prepare(`
|
|
821
868
|
SELECT project_id, source, SUM(seconds) AS seconds
|
|
822
|
-
FROM time_entries WHERE local_date >= ? AND local_date <= ?
|
|
869
|
+
FROM time_entries WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
|
|
823
870
|
GROUP BY project_id, source
|
|
824
871
|
`).all(from, to);
|
|
825
872
|
const byProject = new Map();
|
|
@@ -933,7 +980,7 @@ function buildDay(db, date) {
|
|
|
933
980
|
SELECT t.*, i.number AS invoice_number
|
|
934
981
|
FROM time_entries t
|
|
935
982
|
LEFT JOIN invoices i ON i.id = t.invoice_id
|
|
936
|
-
WHERE t.local_date = ?
|
|
983
|
+
WHERE t.local_date = ? AND t.${VISIBLE}
|
|
937
984
|
ORDER BY t.started_at
|
|
938
985
|
`).all(date);
|
|
939
986
|
const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
|
|
@@ -1173,6 +1220,14 @@ function buildProjects(db) {
|
|
|
1173
1220
|
staleBlocks: pub.staleBlocks,
|
|
1174
1221
|
daysAgo: Math.floor((Date.now() - pub.publishedAt.getTime()) / 86_400_000),
|
|
1175
1222
|
})),
|
|
1223
|
+
archived: store.listArchivedProjects(db).map((p) => {
|
|
1224
|
+
const totals = db.prepare("SELECT COUNT(*) AS blocks, SUM(seconds) AS seconds FROM time_entries WHERE project_id = ?").get(p.id);
|
|
1225
|
+
return {
|
|
1226
|
+
id: p.id, name: p.name, clientName: clients.get(p.clientId)?.name ?? "—",
|
|
1227
|
+
blocks: totals.blocks, seconds: totals.seconds ?? 0,
|
|
1228
|
+
deleteBlocker: store.projectDeleteBlocker(db, p.id),
|
|
1229
|
+
};
|
|
1230
|
+
}),
|
|
1176
1231
|
projects: store.listProjects(db).map((p) => {
|
|
1177
1232
|
const client = clients.get(p.clientId);
|
|
1178
1233
|
const rates = store.getRates(db, p.id);
|
|
@@ -1195,6 +1250,7 @@ function buildProjects(db) {
|
|
|
1195
1250
|
hourlyMinor: current?.amount ?? null,
|
|
1196
1251
|
blocks: totals.blocks,
|
|
1197
1252
|
seconds: totals.seconds ?? 0,
|
|
1253
|
+
unbilledSeconds: db.prepare("SELECT COALESCE(SUM(seconds), 0) AS s FROM time_entries WHERE project_id = ? AND billable = 1 AND invoice_id IS NULL").get(p.id).s,
|
|
1198
1254
|
// El historial completo: subir la tarifa no reescribe lo ya trabajado,
|
|
1199
1255
|
// así que conviene poder verlo.
|
|
1200
1256
|
rateHistory: rates.map((r) => ({
|
|
@@ -1215,7 +1271,7 @@ function unassignedRepos(db) {
|
|
|
1215
1271
|
GROUP BY repo_path ORDER BY turns DESC
|
|
1216
1272
|
`).all();
|
|
1217
1273
|
return rows
|
|
1218
|
-
.filter((r) => store.
|
|
1274
|
+
.filter((r) => store.ownerOfRepo(db, r.repo_path) === null)
|
|
1219
1275
|
.slice(0, 40)
|
|
1220
1276
|
.map((r) => ({
|
|
1221
1277
|
path: r.repo_path,
|
|
@@ -1246,6 +1302,10 @@ function createProject(db, body) {
|
|
|
1246
1302
|
}
|
|
1247
1303
|
}
|
|
1248
1304
|
const id = String(body["id"] ?? "").trim() || slug(name);
|
|
1305
|
+
// Volver a crearlo no lo sacaría del archivo: quedaría oculto y sin explicación.
|
|
1306
|
+
if (store.getProject(db, id)?.archivedAt) {
|
|
1307
|
+
throw new Error((0, index_js_2.tr) `Ya hay un proyecto archivado con ese nombre: restáuralo.`);
|
|
1308
|
+
}
|
|
1249
1309
|
const repo = String(body["repoPath"] ?? "").trim();
|
|
1250
1310
|
const kind = (String(body["kind"] ?? "client"));
|
|
1251
1311
|
store.upsertProject(db, {
|
package/dist/setup.js
CHANGED
|
@@ -96,7 +96,7 @@ async function planSetup(db, repoPaths, myEmailGuess) {
|
|
|
96
96
|
}
|
|
97
97
|
const plans = [];
|
|
98
98
|
for (const root of roots) {
|
|
99
|
-
const already = store.
|
|
99
|
+
const already = store.ownerOfRepo(db, root);
|
|
100
100
|
const { id, name } = projectFromRepo(root);
|
|
101
101
|
if (already) {
|
|
102
102
|
plans.push({ repoPath: root, projectId: already, name, emails: [], existing: true });
|
|
@@ -211,6 +211,11 @@ export interface Project {
|
|
|
211
211
|
* — `estela doctor` avisa en vez de tragárselo en silencio.
|
|
212
212
|
*/
|
|
213
213
|
readonly closedAt: Date | null;
|
|
214
|
+
/**
|
|
215
|
+
* Cuándo se archivó, o `null`/ausente si está activo. A diferencia de cerrar,
|
|
216
|
+
* archivar lo quita del panel y deja de captar horas. Se puede restaurar.
|
|
217
|
+
*/
|
|
218
|
+
readonly archivedAt?: Date | null;
|
|
214
219
|
/**
|
|
215
220
|
* El gestor de tareas del proyecto (Jira, Azure Boards, GitHub...), para
|
|
216
221
|
* reconocer sus tickets en ramas y commits. Sin él solo se reconoce lo
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;AA+Ca,QAAA,WAAW,GAAe;IACrC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC;CACpE,CAAC;AAuKW,QAAA,mBAAmB,GAAgC;IAC9D,MAAM,EAAE,SAAS;IACjB,UAAU,EAAE,QAAQ;IACpB,QAAQ,EAAE,SAAS;CACpB,CAAC;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;AA+Ca,QAAA,WAAW,GAAe;IACrC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC;CACpE,CAAC;AAuKW,QAAA,mBAAmB,GAAgC;IAC9D,MAAM,EAAE,SAAS;IACjB,UAAU,EAAE,QAAQ;IACpB,QAAQ,EAAE,SAAS;CACpB,CAAC;AAmEW,QAAA,mBAAmB,GAAgC;IAC9D,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,EAAE,gBAAgB;CAC7E,CAAC;AAmBW,QAAA,gBAAgB,GAA6B;IACxD,WAAW,EAAE,YAAY;IACzB,OAAO,EAAE,SAAS;IAClB,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,UAAU;IAClB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,SAAS;IAClB,KAAK,EAAE,MAAM;CACd,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "estela",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Time tracking you never fill in: rebuilds your billable hours from Claude Code, Codex and GitHub Copilot sessions and your Git history, and splits what the AI cost per project.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"time-tracking",
|
package/web/app.css
CHANGED
|
@@ -639,6 +639,10 @@ main.wide { max-width: 1180px; }
|
|
|
639
639
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
|
640
640
|
}
|
|
641
641
|
.pcard-warn { font-size: .85rem; color: var(--amber); margin-top: 10px; }
|
|
642
|
+
.pcard-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
|
|
643
|
+
.archived { margin: 16px 0; }
|
|
644
|
+
.archived summary { cursor: pointer; color: var(--muted, inherit); }
|
|
645
|
+
.archived-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; padding: 10px 0; }
|
|
642
646
|
.repos { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
|
643
647
|
.repos code {
|
|
644
648
|
font-family: ui-monospace, Menlo, monospace; font-size: .76rem; color: var(--ink-3);
|
package/web/app.js
CHANGED
|
@@ -1010,6 +1010,10 @@ function renderProjects(data, orphans) {
|
|
|
1010
1010
|
<input type="date" class="rate-from" value="${localToday()}">
|
|
1011
1011
|
<button type="submit" class="chip">${tr("common.save")}</button>
|
|
1012
1012
|
</form>
|
|
1013
|
+
<div class="pcard-actions">
|
|
1014
|
+
<button type="button" class="chip project-archive" data-project="${esc(p.id)}"
|
|
1015
|
+
data-name="${esc(p.name)}" data-unbilled="${p.unbilledSeconds}">${tr("projects.archive")}</button>
|
|
1016
|
+
</div>
|
|
1013
1017
|
</div>`);
|
|
1014
1018
|
}
|
|
1015
1019
|
|
|
@@ -1018,6 +1022,27 @@ function renderProjects(data, orphans) {
|
|
|
1018
1022
|
<p>${tr("projects.empty.body")}</p></div>`);
|
|
1019
1023
|
}
|
|
1020
1024
|
|
|
1025
|
+
// Lo archivado queda a mano y plegado: no estorba, pero se puede recuperar.
|
|
1026
|
+
if ((data.archived || []).length) {
|
|
1027
|
+
parts.push(`<details class="archived">
|
|
1028
|
+
<summary>${tr("projects.archived.title", { n: data.archived.length })}</summary>
|
|
1029
|
+
${data.archived.map((p) => `<div class="archived-row">
|
|
1030
|
+
<div>
|
|
1031
|
+
<div class="pcard-name">${esc(p.name)}</div>
|
|
1032
|
+
<div class="pcard-meta">${tr("projects.meta", {
|
|
1033
|
+
client: esc(p.clientName), duration: fmtDuration(p.seconds), blocks: tr("blocks", { n: p.blocks }),
|
|
1034
|
+
})}</div>
|
|
1035
|
+
${p.deleteBlocker ? `<div class="addform-hint">${tr(`projects.delete.blocked.${p.deleteBlocker}`)}</div>` : ""}
|
|
1036
|
+
</div>
|
|
1037
|
+
<div class="row-inline">
|
|
1038
|
+
<button type="button" class="chip project-restore" data-project="${esc(p.id)}">${tr("projects.restore")}</button>
|
|
1039
|
+
${p.deleteBlocker ? "" : `<button type="button" class="chip project-delete" data-project="${esc(p.id)}"
|
|
1040
|
+
data-name="${esc(p.name)}" data-blocks="${p.blocks}" data-seconds="${p.seconds}">${tr("projects.delete")}</button>`}
|
|
1041
|
+
</div>
|
|
1042
|
+
</div>`).join("")}
|
|
1043
|
+
</details>`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1021
1046
|
// Repos con trabajo capturado que no pertenecen a nadie. Es la lista más
|
|
1022
1047
|
// útil de la pantalla: son horas que ya tienes pero no puedes facturar.
|
|
1023
1048
|
// Se presentan como tarjetas y no como un desplegable porque rellenar un
|
|
@@ -1153,6 +1178,48 @@ function wireProjects() {
|
|
|
1153
1178
|
});
|
|
1154
1179
|
|
|
1155
1180
|
// Copiar el enlace, sin salir de aquí.
|
|
1181
|
+
document.querySelectorAll(".project-archive").forEach((btn) => {
|
|
1182
|
+
btn.addEventListener("click", async () => {
|
|
1183
|
+
const unbilled = Number(btn.dataset.unbilled);
|
|
1184
|
+
const aviso = unbilled > 0 ? tr("projects.archive.unbilled", { duration: fmtDuration(unbilled) }) : "";
|
|
1185
|
+
if (!window.confirm(tr("projects.archive.confirm", { name: btn.dataset.name }) + aviso)) return;
|
|
1186
|
+
try {
|
|
1187
|
+
await api("/api/project-archive", { method: "POST", body: JSON.stringify({ projectId: btn.dataset.project }) });
|
|
1188
|
+
await loadProjects();
|
|
1189
|
+
toast(tr("toast.projectArchived"));
|
|
1190
|
+
} catch (error) { toast(error.message); }
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
document.querySelectorAll(".project-restore").forEach((btn) => {
|
|
1195
|
+
btn.addEventListener("click", async () => {
|
|
1196
|
+
btn.disabled = true;
|
|
1197
|
+
try {
|
|
1198
|
+
await api("/api/project-restore", { method: "POST", body: JSON.stringify({ projectId: btn.dataset.project }) });
|
|
1199
|
+
await loadProjects();
|
|
1200
|
+
toast(tr("toast.projectRestored"));
|
|
1201
|
+
} catch (error) { toast(error.message); btn.disabled = false; }
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
// Eliminar no tiene vuelta atrás: se escribe el nombre, como en cualquier
|
|
1206
|
+
// herramienta donde un clic de más cuesta datos.
|
|
1207
|
+
document.querySelectorAll(".project-delete").forEach((btn) => {
|
|
1208
|
+
btn.addEventListener("click", async () => {
|
|
1209
|
+
const escrito = window.prompt(tr("projects.delete.confirm", {
|
|
1210
|
+
name: btn.dataset.name, duration: fmtDuration(Number(btn.dataset.seconds)),
|
|
1211
|
+
blocks: tr("blocks", { n: Number(btn.dataset.blocks) }),
|
|
1212
|
+
}));
|
|
1213
|
+
if (escrito === null) return;
|
|
1214
|
+
try {
|
|
1215
|
+
await api("/api/project-delete", { method: "POST",
|
|
1216
|
+
body: JSON.stringify({ projectId: btn.dataset.project, confirmName: escrito }) });
|
|
1217
|
+
await loadProjects();
|
|
1218
|
+
toast(tr("toast.projectDeleted"));
|
|
1219
|
+
} catch (error) { toast(error.message); }
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1156
1223
|
// Vincular un repositorio ya detectado a un proyecto que existe. Es lo que
|
|
1157
1224
|
// le faltaba a quien acepta una invitación: el proyecto queda creado pero
|
|
1158
1225
|
// vacío, y la única salida era la terminal.
|
package/web/i18n.js
CHANGED
|
@@ -226,6 +226,18 @@
|
|
|
226
226
|
"projects.noRepo": "Sin repositorio: no se le imputa nada todavía.",
|
|
227
227
|
"projects.orphanOption": "{name} — {turns} turnos, hasta {last}",
|
|
228
228
|
"projects.link": "Vincular",
|
|
229
|
+
"projects.archive": "Archivar",
|
|
230
|
+
"projects.archive.confirm": "¿Archivar \"{name}\"? Saldrá del panel y dejará de captar horas. No se borra nada y puedes restaurarlo.",
|
|
231
|
+
"projects.archive.unbilled": "\n\nTiene {duration} sin facturar: se conservan, pero no podrás facturarlas hasta restaurarlo.",
|
|
232
|
+
"projects.archived.title": "Archivados ({n})",
|
|
233
|
+
"projects.restore": "Restaurar",
|
|
234
|
+
"projects.delete": "Eliminar",
|
|
235
|
+
"projects.delete.confirm": "Se eliminarán \"{name}\" y sus {blocks} ({duration}), y no se puede deshacer. Las sesiones y los commits se conservan.\n\nEscribe el nombre del proyecto para confirmar:",
|
|
236
|
+
"projects.delete.blocked.invoiced": "Tiene horas facturadas: no se puede eliminar.",
|
|
237
|
+
"projects.delete.blocked.shared": "Está publicado o sincronizado: no se puede eliminar.",
|
|
238
|
+
"toast.projectArchived": "Proyecto archivado",
|
|
239
|
+
"toast.projectRestored": "Proyecto restaurado",
|
|
240
|
+
"toast.projectDeleted": "Proyecto eliminado",
|
|
229
241
|
"projects.noOrphans": "No hemos detectado ningún repositorio sin asignar. Trabaja un rato con tu agente y vuelve, o vincúlalo con <code>estela team repo --project {id} --add <ruta></code>.",
|
|
230
242
|
"projects.empty.title": "Todavía no hay proyectos",
|
|
231
243
|
"projects.empty.body": "Crea el primero abajo y asígnale el repositorio donde trabajas.",
|
|
@@ -532,6 +544,18 @@
|
|
|
532
544
|
"projects.noRepo": "No repository: nothing is assigned to it yet.",
|
|
533
545
|
"projects.orphanOption": "{name} — {turns} turns, until {last}",
|
|
534
546
|
"projects.link": "Link",
|
|
547
|
+
"projects.archive": "Archive",
|
|
548
|
+
"projects.archive.confirm": "Archive \"{name}\"? It will leave the panel and stop capturing hours. Nothing is deleted and you can restore it.",
|
|
549
|
+
"projects.archive.unbilled": "\n\nIt has {duration} not invoiced: they're kept, but you can't invoice them until you restore it.",
|
|
550
|
+
"projects.archived.title": "Archived ({n})",
|
|
551
|
+
"projects.restore": "Restore",
|
|
552
|
+
"projects.delete": "Delete",
|
|
553
|
+
"projects.delete.confirm": "This deletes \"{name}\" and its {blocks} ({duration}), and can't be undone. Sessions and commits are kept.\n\nType the project name to confirm:",
|
|
554
|
+
"projects.delete.blocked.invoiced": "It has invoiced hours: it can't be deleted.",
|
|
555
|
+
"projects.delete.blocked.shared": "It's published or synced: it can't be deleted.",
|
|
556
|
+
"toast.projectArchived": "Project archived",
|
|
557
|
+
"toast.projectRestored": "Project restored",
|
|
558
|
+
"toast.projectDeleted": "Project deleted",
|
|
535
559
|
"projects.noOrphans": "We haven't detected any unassigned repository. Work with your agent for a while and come back, or link it with <code>estela team repo --project {id} --add <path></code>.",
|
|
536
560
|
"projects.empty.title": "No projects yet",
|
|
537
561
|
"projects.empty.body": "Create the first one below and assign it the repository you work in.",
|