estela 0.3.0 → 0.3.3
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 +37 -5
- package/dist/billing/assign.js +219 -27
- package/dist/billing/sessionize.d.ts +9 -1
- package/dist/billing/sessionize.js +21 -6
- package/dist/cli.js +53 -62
- package/dist/db/store.d.ts +6 -0
- package/dist/db/store.js +22 -0
- package/dist/export/invoice-pdf.d.ts +2 -0
- package/dist/export/invoice-pdf.js +26 -16
- package/dist/i18n/en.js +2 -6
- package/dist/server.js +22 -15
- package/package.json +1 -1
- package/web/app.css +1 -0
- package/web/app.js +10 -5
- package/web/i18n.js +6 -4
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 { 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,9 +41,37 @@ 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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Los bloques deducidos solo de commits: los que no se solapan con ninguna
|
|
52
|
+
* sesión de agente, tampoco con las que ya no están en el disco.
|
|
53
|
+
*
|
|
54
|
+
* Claude Code limpia sus transcripts antiguos, así que lo escaneado no es todo
|
|
55
|
+
* lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
|
|
56
|
+
* trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
export declare function commitOnlyBlocks(db: DatabaseSync, commits: readonly CommitRecord[], agentBlocks: readonly WorkBlock[]): WorkBlock[];
|
|
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;
|
|
45
77
|
//# sourceMappingURL=assign.d.ts.map
|
package/dist/billing/assign.js
CHANGED
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.entryId = entryId;
|
|
37
|
+
exports.commitOnlyBlocks = commitOnlyBlocks;
|
|
38
|
+
exports.knownTurns = knownTurns;
|
|
37
39
|
exports.planEntries = planEntries;
|
|
38
40
|
const shared_1 = require("@estela/shared");
|
|
39
41
|
const store = __importStar(require("../db/store.js"));
|
|
@@ -42,10 +44,118 @@ const sessionize_js_1 = require("./sessionize.js");
|
|
|
42
44
|
function entryId(projectId, day, branch) {
|
|
43
45
|
// Determinista (proyecto + día + rama) para que reimportar actualice en vez
|
|
44
46
|
// de duplicar. La fecha, la LOCAL: la misma que usa el servidor.
|
|
45
|
-
|
|
46
|
-
return `te_${projectId}_${day}_${slug}`;
|
|
47
|
+
return `te_${projectId}_${day}_${branchSlug(branch)}`;
|
|
47
48
|
}
|
|
48
|
-
function
|
|
49
|
+
function branchSlug(branch) {
|
|
50
|
+
return (branch ?? "sin-rama").replace(/[^a-zA-Z0-9]+/g, "-");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Los bloques deducidos solo de commits: los que no se solapan con ninguna
|
|
54
|
+
* sesión de agente, tampoco con las que ya no están en el disco.
|
|
55
|
+
*
|
|
56
|
+
* Claude Code limpia sus transcripts antiguos, así que lo escaneado no es todo
|
|
57
|
+
* lo que hubo. Comparar solo con eso convertía los commits de esas sesiones en
|
|
58
|
+
* trabajo sin agente, y sumaba horas estimadas a un trabajo ya medido. La base
|
|
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.
|
|
65
|
+
*/
|
|
66
|
+
function commitOnlyBlocks(db, commits, agentBlocks) {
|
|
67
|
+
const saved = (0, sessionize_js_1.sessionize)(store.turnsBetween(db, new Date(0), new Date("9999-12-31")));
|
|
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()];
|
|
94
|
+
}
|
|
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) {
|
|
49
159
|
const ownerOf = (0, project_of_js_1.ownerResolver)(db);
|
|
50
160
|
const direct = (repo) => (repo ? store.projectForRepo(db, repo) : null);
|
|
51
161
|
const viaRoot = (repo) => {
|
|
@@ -54,20 +164,11 @@ function planEntries(db, raw, options) {
|
|
|
54
164
|
const o = ownerOf(repo);
|
|
55
165
|
return o.kind === "project" ? o.projectId : null;
|
|
56
166
|
};
|
|
57
|
-
//
|
|
58
|
-
// `repo
|
|
59
|
-
//
|
|
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.
|
|
60
170
|
const keyDirect = (b) => direct(b.repoPath) ?? b.repoPath ?? "";
|
|
61
|
-
const
|
|
62
|
-
const base = byId(groupedBase, direct);
|
|
63
|
-
if (!options.recover) {
|
|
64
|
-
return {
|
|
65
|
-
entries: [...base.values()].map((g) => ({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: 0 })),
|
|
66
|
-
grouped: groupedBase.length,
|
|
67
|
-
unassigned: groupedBase.filter((b) => !direct(b.repoPath)).length,
|
|
68
|
-
recovered: new Map(), blocked: [],
|
|
69
|
-
};
|
|
70
|
-
}
|
|
171
|
+
const base = byId((0, sessionize_js_1.groupByBranchAndDay)(raw, keyDirect), direct);
|
|
71
172
|
// Con la raíz git, pero solo para lo que no tenía proyecto (regla 1).
|
|
72
173
|
const assigned = (repo) => direct(repo) ?? viaRoot(repo);
|
|
73
174
|
const keyFull = (b) => assigned(b.repoPath) ?? b.repoPath ?? "";
|
|
@@ -91,35 +192,126 @@ function planEntries(db, raw, options) {
|
|
|
91
192
|
}
|
|
92
193
|
return lockedDays.get(k);
|
|
93
194
|
};
|
|
94
|
-
|
|
95
|
-
|
|
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 = [];
|
|
96
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);
|
|
97
216
|
for (const [id, g] of full) {
|
|
98
217
|
const before = base.get(id);
|
|
99
|
-
if (!touched.has(id)) {
|
|
100
|
-
entries.push({ entry: toEntry(db, g.projectId, g.block), recoveredSeconds: 0 });
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
const gain = Math.max(0, g.block.seconds - (before?.block.seconds ?? 0));
|
|
104
218
|
const day = (0, shared_1.localDate)(g.block.startedAt);
|
|
105
|
-
if (locked(g.projectId, day)) {
|
|
219
|
+
if (touched.has(id) && locked(g.projectId, day)) {
|
|
106
220
|
// Regla 2: el día se queda como estaba; si antes no había nada, nada.
|
|
107
221
|
if (before)
|
|
108
|
-
|
|
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));
|
|
109
224
|
if (gain > 0)
|
|
110
225
|
blocked.push({ projectId: g.projectId, day, seconds: gain });
|
|
111
226
|
continue;
|
|
112
227
|
}
|
|
113
|
-
|
|
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 });
|
|
114
243
|
if (gain > 0)
|
|
115
|
-
recovered.set(
|
|
244
|
+
recovered.set(row.projectId, (recovered.get(row.projectId) ?? 0) + gain);
|
|
116
245
|
}
|
|
246
|
+
const duplicates = findDuplicates(db, raw, new Set(entries.map((e) => e.entry.id)), assigned);
|
|
117
247
|
return {
|
|
118
248
|
entries, grouped: groupedFull.length,
|
|
119
249
|
unassigned: groupedFull.filter((b) => !assigned(b.repoPath)).length,
|
|
120
250
|
recovered, blocked,
|
|
251
|
+
stale: [...duplicates.stale, ...empty],
|
|
252
|
+
protectedDuplicates: duplicates.protectedDuplicates,
|
|
121
253
|
};
|
|
122
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
|
+
}
|
|
123
315
|
/** Los bloques agrupados con proyecto, por el id con el que se guardarían. */
|
|
124
316
|
function byId(blocks, projectOf) {
|
|
125
317
|
const out = new Map();
|
|
@@ -113,6 +113,14 @@ export declare function sessionizeCommits(commits: readonly CommitRecord[], opti
|
|
|
113
113
|
*
|
|
114
114
|
* Sin esto se contaría dos veces el mismo rato: el agente ya cubrió esas horas
|
|
115
115
|
* y el commit que las cerró generaría otro bloque encima.
|
|
116
|
+
*
|
|
117
|
+
* Mismo repositorio quiere decir lo mismo que en `attachCommits`: la carpeta
|
|
118
|
+
* del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
|
|
119
|
+
* y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
|
|
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.
|
|
116
124
|
*/
|
|
117
|
-
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[];
|
|
118
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,
|
|
@@ -290,10 +291,24 @@ function sessionizeCommits(commits, options = {}) {
|
|
|
290
291
|
*
|
|
291
292
|
* Sin esto se contaría dos veces el mismo rato: el agente ya cubrió esas horas
|
|
292
293
|
* y el commit que las cerró generaría otro bloque encima.
|
|
294
|
+
*
|
|
295
|
+
* Mismo repositorio quiere decir lo mismo que en `attachCommits`: la carpeta
|
|
296
|
+
* del commit (la raíz) o una subcarpeta suya. Antes se exigía la ruta exacta,
|
|
297
|
+
* y una sesión abierta en `repo/src/UI` no tapaba el commit hecho en `repo`:
|
|
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.
|
|
293
302
|
*/
|
|
294
|
-
function withoutOverlap(commitBlocks, agentBlocks) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
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))));
|
|
308
|
+
}
|
|
309
|
+
function sameRepo(a, b) {
|
|
310
|
+
if (a === b)
|
|
311
|
+
return true;
|
|
312
|
+
return !!a && !!b && ((0, paths_js_1.isSameOrInside)(a, b) || (0, paths_js_1.isSameOrInside)(b, a));
|
|
298
313
|
}
|
|
299
314
|
//# sourceMappingURL=sessionize.js.map
|
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>]
|
|
@@ -278,9 +278,11 @@ estela — registro de horas para desarrollo asistido por IA
|
|
|
278
278
|
[--pdf <fichero.pdf>] [--csv <fichero.csv>] [--fx <tipo>]
|
|
279
279
|
[--number <nº>]
|
|
280
280
|
[--from-name <tu nombre>] [--from-email <email>]
|
|
281
|
-
[--from-tax-id <tu NIF/CIF>]
|
|
281
|
+
[--from-tax-id <tu NIF/CIF>] [--no-amounts]
|
|
282
282
|
Informe de horas y commits para respaldar tu trabajo. No es una
|
|
283
283
|
factura: Estela no emite documentos fiscales.
|
|
284
|
+
--no-amounts saca el PDF sin tarifa ni importes. En el panel, lo ya
|
|
285
|
+
emitido se vuelve a descargar con o sin ellos.
|
|
284
286
|
--number fija el número del informe; sin él se numera solo y
|
|
285
287
|
correlativo por año (INF-${new Date().getFullYear()}-001, -002...).
|
|
286
288
|
Los --from-* son tus datos como emisor, en la cabecera del PDF.
|
|
@@ -305,12 +307,12 @@ estela — time tracking for AI-assisted development
|
|
|
305
307
|
First run: detects your agents and repositories, and rebuilds your
|
|
306
308
|
history. Two minutes, no questions, no account.
|
|
307
309
|
|
|
308
|
-
estela import [--since YYYY-MM-DD] [--repo <path>]
|
|
310
|
+
estela import [--since YYYY-MM-DD] [--repo <path>]
|
|
309
311
|
Rebuilds your history from agent transcripts and git. A session in a
|
|
310
|
-
subfolder or a worktree counts for its repo's project.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
314
316
|
|
|
315
317
|
estela client add --id <id> --name <name> --currency <EUR|USD|...>
|
|
316
318
|
[--tax-id <tax ID>] [--email <a@b.com>]
|
|
@@ -489,9 +491,11 @@ estela — time tracking for AI-assisted development
|
|
|
489
491
|
[--pdf <file.pdf>] [--csv <file.csv>] [--fx <rate>]
|
|
490
492
|
[--number <no.>]
|
|
491
493
|
[--from-name <your name>] [--from-email <email>]
|
|
492
|
-
[--from-tax-id <your tax ID>]
|
|
494
|
+
[--from-tax-id <your tax ID>] [--no-amounts]
|
|
493
495
|
Report of hours and commits to back up your work. It isn't an
|
|
494
496
|
invoice: Estela doesn't issue tax documents.
|
|
497
|
+
--no-amounts leaves the rate and amounts out of the PDF. In the panel,
|
|
498
|
+
issued reports can be downloaded again with or without them.
|
|
495
499
|
--number sets the report number; without it, reports are numbered
|
|
496
500
|
automatically and sequentially per year (INF-${new Date().getFullYear()}-001, -002...).
|
|
497
501
|
The --from-* flags are your details as the issuer, in the PDF header.
|
|
@@ -776,18 +780,6 @@ async function cmdSetup(args, dbPath) {
|
|
|
776
780
|
db.close();
|
|
777
781
|
}
|
|
778
782
|
}
|
|
779
|
-
/** La vista previa de la primera recuperación: horas por proyecto. */
|
|
780
|
-
function printRecoveryPreview(db, plan) {
|
|
781
|
-
console.log((0, index_js_1.tr) `\nEstela ahora reconoce las sesiones en subcarpetas y worktrees de un repositorio.`);
|
|
782
|
-
console.log((0, index_js_1.tr) `Estas horas no estaban imputadas y se sumarían a su proyecto:\n`);
|
|
783
|
-
for (const [projectId, seconds] of [...plan.recovered].sort((a, b) => b[1] - a[1])) {
|
|
784
|
-
const name = store.getProject(db, projectId)?.name ?? projectId;
|
|
785
|
-
console.log(` ${name.padEnd(30)} +${(0, shared_1.formatDuration)(seconds)}`);
|
|
786
|
-
}
|
|
787
|
-
console.log((0, index_js_1.tr) `\n Nada de lo ya imputado cambia de proyecto, y los días aprobados no se tocan.`);
|
|
788
|
-
printRecoveryBlocked(db, plan.blocked);
|
|
789
|
-
console.log("");
|
|
790
|
-
}
|
|
791
783
|
/** Lo que se recuperaría en días aprobados o facturados: se avisa, no se aplica. */
|
|
792
784
|
function printRecoveryBlocked(db, blocked) {
|
|
793
785
|
if (blocked.length === 0)
|
|
@@ -886,7 +878,12 @@ async function cmdImport(args, dbPath) {
|
|
|
886
878
|
continue;
|
|
887
879
|
savedCommits += store.saveCommits(db, await (0, git_js_1.readCommits)(root, since ? { since } : {}));
|
|
888
880
|
}
|
|
889
|
-
|
|
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 ?? [])]));
|
|
890
887
|
if (rescued > 0) {
|
|
891
888
|
console.log((0, index_js_1.tr) ` ${rescued} turnos de scratchpad devueltos a su repositorio`);
|
|
892
889
|
}
|
|
@@ -901,57 +898,42 @@ async function cmdImport(args, dbPath) {
|
|
|
901
898
|
// Solo TUS commits derivan horas tuyas. Sin este filtro, los commits de un
|
|
902
899
|
// compañero se convertirían en tiempo facturable a tu nombre.
|
|
903
900
|
const commitsAll = (0, identity_js_1.onlyMine)(await allCommits(db), mine);
|
|
904
|
-
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(
|
|
905
|
-
const fromCommits = (0,
|
|
901
|
+
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(known), commitsAll);
|
|
902
|
+
const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commitsAll, fromAgents);
|
|
906
903
|
const raw = [...fromAgents, ...fromCommits];
|
|
907
904
|
if (fromCommits.length) {
|
|
908
905
|
console.log((0, index_js_1.tr) ` ${fromCommits.length} bloques deducidos solo de commits (sin agente)`);
|
|
909
906
|
}
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
// importadas se enseñan antes y se pide confirmación; en una base nueva no
|
|
913
|
-
// hay nada que cambiar, así que se aplica sin preguntar.
|
|
914
|
-
const marca = (0, node_path_1.join)((0, node_path_1.dirname)(dbPath), ".recover-git-root");
|
|
915
|
-
const habiaHoras = db.prepare("SELECT COUNT(*) AS n FROM time_entries").get().n > 0;
|
|
916
|
-
let plan = (0, assign_js_1.planEntries)(db, raw, { recover: true });
|
|
917
|
-
const conRaiz = plan;
|
|
918
|
-
const recuperable = [...plan.recovered.values()].reduce((a, b) => a + b, 0);
|
|
919
|
-
if (!(0, node_fs_1.existsSync)(marca) && habiaHoras && recuperable > 0) {
|
|
920
|
-
printRecoveryPreview(db, plan);
|
|
921
|
-
const ok = args.flags["yes"] === true || await confirm((0, index_js_1.tr) `¿Imputar estas horas? [s/N] `);
|
|
922
|
-
if (!ok) {
|
|
923
|
-
plan = (0, assign_js_1.planEntries)(db, raw, { recover: false });
|
|
924
|
-
console.log((0, index_js_1.tr) ` No se ha tocado nada. Cuando quieras: estela import --yes\n`);
|
|
925
|
-
}
|
|
926
|
-
else {
|
|
927
|
-
(0, node_fs_1.writeFileSync)(marca, new Date().toISOString());
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
else {
|
|
931
|
-
try {
|
|
932
|
-
(0, node_fs_1.writeFileSync)(marca, new Date().toISOString());
|
|
933
|
-
}
|
|
934
|
-
catch { /* sin marca, se volverá a preguntar */ }
|
|
935
|
-
}
|
|
907
|
+
// Sesiones en subcarpetas y worktrees incluidas: ver billing/assign.ts.
|
|
908
|
+
const plan = (0, assign_js_1.planEntries)(db, raw);
|
|
936
909
|
for (const { entry } of plan.entries)
|
|
937
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);
|
|
938
913
|
const entries = plan.entries.length;
|
|
939
914
|
const unassigned = plan.unassigned;
|
|
940
915
|
console.log((0, index_js_1.tr) `Bloques: ${raw.length} detectados → ${plan.grouped} tras agrupar por rama y día · ` +
|
|
941
916
|
(0, index_js_1.tr) `${entries} imputados · ${unassigned} sin proyecto`);
|
|
942
917
|
const aplicadas = [...plan.recovered.values()].reduce((a, b) => a + b, 0);
|
|
943
|
-
if (aplicadas > 0
|
|
918
|
+
if (aplicadas > 0) {
|
|
944
919
|
console.log((0, index_js_1.tr) ` ${(0, shared_1.formatDuration)(aplicadas)} recuperadas de sesiones en subcarpetas y worktrees`);
|
|
945
920
|
}
|
|
946
921
|
printRecoveryBlocked(db, plan.blocked);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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:`);
|
|
955
937
|
console.log((0, index_js_1.tr) ` estela project add --id <id> --client <id> --name <nombre> --repo <ruta>`);
|
|
956
938
|
}
|
|
957
939
|
}
|
|
@@ -2472,7 +2454,9 @@ function cmdInvoice(args, dbPath) {
|
|
|
2472
2454
|
const cutoffAt = new Date(`${cutoffRaw}T23:59:59Z`);
|
|
2473
2455
|
if (Number.isNaN(cutoffAt.getTime()))
|
|
2474
2456
|
throw new UserError((0, index_js_1.tr) `Fecha inválida: ${cutoffRaw}`);
|
|
2475
|
-
|
|
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;
|
|
2476
2460
|
const number = str(args, "number") ?? store.nextInvoiceNumber(db, "INF");
|
|
2477
2461
|
const fx = str(args, "fx");
|
|
2478
2462
|
const entries = store.getTimeEntries(db, projectId);
|
|
@@ -2527,6 +2511,7 @@ function cmdInvoice(args, dbPath) {
|
|
|
2527
2511
|
},
|
|
2528
2512
|
} : {}),
|
|
2529
2513
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
2514
|
+
withAmounts: !("no-amounts" in args.flags),
|
|
2530
2515
|
}));
|
|
2531
2516
|
(0, node_fs_1.writeFileSync)(pdfPath, pdf);
|
|
2532
2517
|
console.log((0, index_js_1.tr) `\nPDF: ${pdfPath} (${(pdf.length / 1024).toFixed(1)} KB, ${invoice.lines.length} conceptos)`);
|
|
@@ -2572,6 +2557,12 @@ async function main() {
|
|
|
2572
2557
|
console.log(VERSION);
|
|
2573
2558
|
return;
|
|
2574
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
|
+
}
|
|
2575
2566
|
(0, client_js_1.setClientVersion)(VERSION);
|
|
2576
2567
|
const dbPath = str(args, "db") ?? schema_js_1.DEFAULT_DB_PATH;
|
|
2577
2568
|
// `estela unpublish <panel>` lleva el panel suelto, sin flag: no es un subcomando.
|
package/dist/db/store.d.ts
CHANGED
|
@@ -135,6 +135,12 @@ export declare class AdjustError extends Error {
|
|
|
135
135
|
export declare function adjustEntrySeconds(db: DatabaseSync, id: string, seconds: number, reason: string, at?: Date): void;
|
|
136
136
|
/** Quita el ajuste y devuelve el bloque a lo que mide el agente. */
|
|
137
137
|
export declare function clearEntryAdjustment(db: DatabaseSync, id: string, at?: Date): void;
|
|
138
|
+
/**
|
|
139
|
+
* Retira imputaciones repetidas (ver `findDuplicates` en billing/assign.ts).
|
|
140
|
+
* Repite aquí las condiciones de `saveTimeEntry`: lo escrito a mano, lo aprobado,
|
|
141
|
+
* lo facturado y lo ajustado no se borra desde ningún camino.
|
|
142
|
+
*/
|
|
143
|
+
export declare function deleteStaleEntries(db: DatabaseSync, ids: readonly string[]): number;
|
|
138
144
|
export declare function saveTimeEntry(db: DatabaseSync, entry: Omit<TimeEntry, "id"> & {
|
|
139
145
|
id?: string;
|
|
140
146
|
}, at?: Date): string;
|
package/dist/db/store.js
CHANGED
|
@@ -30,6 +30,7 @@ exports.saveCommits = saveCommits;
|
|
|
30
30
|
exports.logScan = logScan;
|
|
31
31
|
exports.adjustEntrySeconds = adjustEntrySeconds;
|
|
32
32
|
exports.clearEntryAdjustment = clearEntryAdjustment;
|
|
33
|
+
exports.deleteStaleEntries = deleteStaleEntries;
|
|
33
34
|
exports.saveTimeEntry = saveTimeEntry;
|
|
34
35
|
exports.setEntryWorkItems = setEntryWorkItems;
|
|
35
36
|
exports.getTimeEntries = getTimeEntries;
|
|
@@ -509,6 +510,21 @@ function clearEntryAdjustment(db, id, at = new Date()) {
|
|
|
509
510
|
WHERE id = ?
|
|
510
511
|
`).run(iso(at), id);
|
|
511
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Retira imputaciones repetidas (ver `findDuplicates` en billing/assign.ts).
|
|
515
|
+
* Repite aquí las condiciones de `saveTimeEntry`: lo escrito a mano, lo aprobado,
|
|
516
|
+
* lo facturado y lo ajustado no se borra desde ningún camino.
|
|
517
|
+
*/
|
|
518
|
+
function deleteStaleEntries(db, ids) {
|
|
519
|
+
const stmt = db.prepare(`
|
|
520
|
+
DELETE FROM time_entries WHERE id = ?
|
|
521
|
+
AND source IN ('agent', 'commit') AND invoice_id IS NULL
|
|
522
|
+
AND approved = 0 AND adjust_reason IS NULL`);
|
|
523
|
+
let deleted = 0;
|
|
524
|
+
for (const id of ids)
|
|
525
|
+
deleted += Number(stmt.run(id).changes);
|
|
526
|
+
return deleted;
|
|
527
|
+
}
|
|
512
528
|
function saveTimeEntry(db, entry, at = new Date()) {
|
|
513
529
|
const id = entry.id ?? `te_${(0, node_crypto_1.randomUUID)()}`;
|
|
514
530
|
db.prepare(`
|
|
@@ -560,8 +576,14 @@ function saveTimeEntry(db, entry, at = new Date()) {
|
|
|
560
576
|
-- Y lo ya facturado es historia. Sin esta condición, un reimport
|
|
561
577
|
-- recalculaba filas que respaldan una factura emitida y dejaba el panel
|
|
562
578
|
-- diciendo una cosa y el PDF del cliente otra.
|
|
579
|
+
--
|
|
580
|
+
-- Lo aprobado, igual: aprobar un día es dar por buena esa cifra, y a
|
|
581
|
+
-- menudo ya se ha compartido (un panel publicado, un avance al cliente).
|
|
582
|
+
-- Un cambio en cómo se calculan las horas se aplica a lo que viene, no
|
|
583
|
+
-- reescribe lo que ya se dio por bueno. Para recalcularlo, se desaprueba.
|
|
563
584
|
WHERE time_entries.source IN ('agent', 'commit')
|
|
564
585
|
AND time_entries.invoice_id IS NULL
|
|
586
|
+
AND time_entries.approved = 0
|
|
565
587
|
`).run(id, entry.projectId, iso(entry.startedAt), (0, shared_1.localDate)(entry.startedAt), iso(entry.endedAt), entry.seconds, entry.description, entry.billable ? 1 : 0, entry.invoiceId, entry.aiCost.microUsd, entry.agentSeconds, entry.commitHashes.join(","), entry.agents.join(","), entry.source, entry.kind, entry.branch, (entry.workItems ?? []).join(","), iso(at));
|
|
566
588
|
return id;
|
|
567
589
|
}
|
|
@@ -15,6 +15,8 @@ export interface InvoicePdfOptions {
|
|
|
15
15
|
};
|
|
16
16
|
/** Coste real de IA imputado al periodo (cuota amortizada). Solo informativo. */
|
|
17
17
|
readonly amortizedAiCost?: Money;
|
|
18
|
+
/** Sin tarifa ni importes: solo las horas. Por defecto, con importes. */
|
|
19
|
+
readonly withAmounts?: boolean;
|
|
18
20
|
readonly footer?: string;
|
|
19
21
|
}
|
|
20
22
|
export declare function invoiceToPdf(invoice: Invoice, client: Client, project: Project, options?: InvoicePdfOptions): Buffer;
|
|
@@ -15,6 +15,9 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
15
15
|
// Los importes van con el locale del documento, no con el de la terminal: el
|
|
16
16
|
// cliente lee "€1.254,79" o "€1,254.79" según SU idioma.
|
|
17
17
|
const money = (m) => (0, shared_1.formatMoney)(m, (0, index_js_1.moneyLocale)((0, index_js_1.getLang)()));
|
|
18
|
+
const withAmounts = options.withAmounts !== false;
|
|
19
|
+
// Sin tarifa ni importe, el tiempo pasa a ser la última columna.
|
|
20
|
+
const colTime = withAmounts ? COL_TIME : COL_AMOUNT;
|
|
18
21
|
let y = M;
|
|
19
22
|
// --- Cabecera -------------------------------------------------------------
|
|
20
23
|
doc.text(M, y + 4, (0, index_js_1.tr) `INFORME DE HORAS`, "Helvetica-Bold", 20);
|
|
@@ -57,20 +60,22 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
57
60
|
// --- Cabecera de tabla ----------------------------------------------------
|
|
58
61
|
const header = () => {
|
|
59
62
|
doc.text(M, y, (0, index_js_1.tr) `CONCEPTO`, "Helvetica-Bold", 8, 0.45);
|
|
60
|
-
doc.textRight(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
doc.textRight(colTime, y, (0, index_js_1.tr) `TIEMPO`, "Helvetica-Bold", 8, 0.45);
|
|
64
|
+
if (withAmounts) {
|
|
65
|
+
doc.textRight(COL_RATE, y, (0, index_js_1.tr) `TARIFA`, "Helvetica-Bold", 8, 0.45);
|
|
66
|
+
doc.textRight(COL_AMOUNT, y, (0, index_js_1.tr) `IMPORTE`, "Helvetica-Bold", 8, 0.45);
|
|
67
|
+
}
|
|
63
68
|
y += 7;
|
|
64
69
|
doc.line(M, y, COL_AMOUNT, y, 0.8, 0.3);
|
|
65
70
|
y += 15;
|
|
66
71
|
};
|
|
67
72
|
header();
|
|
68
73
|
// --- Conceptos ------------------------------------------------------------
|
|
69
|
-
// La columna TIEMPO se alinea a la derecha en
|
|
74
|
+
// La columna TIEMPO se alinea a la derecha en colTime, así que su texto crece
|
|
70
75
|
// hacia la izquierda. Hay que restar el más ancho de esos textos, no un hueco
|
|
71
76
|
// fijo, o la descripción se le mete encima.
|
|
72
77
|
const widestTime = invoice.lines.reduce((max, l) => Math.max(max, pdf_js_1.PdfDocument.measure((0, shared_1.formatDuration)(l.seconds), "Helvetica", 10)), 0);
|
|
73
|
-
const maxDescWidth =
|
|
78
|
+
const maxDescWidth = colTime - M - widestTime - 14;
|
|
74
79
|
for (const line of invoice.lines) {
|
|
75
80
|
if (y > BOTTOM - 60) {
|
|
76
81
|
doc.newPage();
|
|
@@ -79,9 +84,11 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
79
84
|
}
|
|
80
85
|
const text = pdf_js_1.PdfDocument.truncate(line.description, "Helvetica", 10, maxDescWidth);
|
|
81
86
|
doc.text(M, y, text, "Helvetica", 10);
|
|
82
|
-
doc.textRight(
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
doc.textRight(colTime, y, (0, shared_1.formatDuration)(line.seconds), "Helvetica", 10, 0.2);
|
|
88
|
+
if (withAmounts) {
|
|
89
|
+
doc.textRight(COL_RATE, y, `${money(line.hourlyRate)}/h`, "Helvetica", 10, 0.4);
|
|
90
|
+
doc.textRight(COL_AMOUNT, y, money(line.amount), "Helvetica", 10);
|
|
91
|
+
}
|
|
85
92
|
y += 9;
|
|
86
93
|
doc.line(M, y, COL_AMOUNT, y, 0.4, 0.9);
|
|
87
94
|
y += 12;
|
|
@@ -95,19 +102,22 @@ function invoiceToPdf(invoice, client, project, options = {}) {
|
|
|
95
102
|
doc.line(M, y, COL_AMOUNT, y, 0.8, 0.3);
|
|
96
103
|
y += 18;
|
|
97
104
|
doc.text(M, y, invoice.lines.length === 1 ? (0, index_js_1.tr) `1 concepto` : (0, index_js_1.tr) `${invoice.lines.length} conceptos`, "Helvetica", 9, 0.4);
|
|
98
|
-
doc.textRight(
|
|
99
|
-
|
|
105
|
+
doc.textRight(colTime, y, (0, shared_1.formatDuration)(invoice.totalSeconds), "Helvetica-Bold", 10);
|
|
106
|
+
if (withAmounts)
|
|
107
|
+
doc.textRight(COL_AMOUNT, y, money(invoice.subtotal), "Helvetica", 10);
|
|
100
108
|
y += 18;
|
|
101
|
-
if (invoice.aiCostBilled) {
|
|
109
|
+
if (withAmounts && invoice.aiCostBilled) {
|
|
102
110
|
doc.text(M, y, (0, index_js_1.tr) `Coste de IA repercutido (1 USD = ${invoice.usdFxRate} ${invoice.currency})`, "Helvetica", 9, 0.4);
|
|
103
111
|
doc.textRight(COL_AMOUNT, y, money(invoice.aiCostBilled), "Helvetica", 10);
|
|
104
112
|
y += 18;
|
|
105
113
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
if (withAmounts) {
|
|
115
|
+
y += 4;
|
|
116
|
+
doc.rect(rightCol - 20, y - 14, COL_AMOUNT - rightCol + 20, 32, 0.94);
|
|
117
|
+
doc.text(rightCol - 8, y + 2, (0, index_js_1.tr) `VALOR`, "Helvetica-Bold", 11);
|
|
118
|
+
doc.textRight(COL_AMOUNT - 8, y + 3, money(invoice.total), "Helvetica-Bold", 14);
|
|
119
|
+
y += 46;
|
|
120
|
+
}
|
|
111
121
|
// Aquí había una "NOTA INTERNA (no se comparte)" con el consumo de IA del
|
|
112
122
|
// periodo. Se quitó: esa frase era un deseo, no un mecanismo. Este PDF es
|
|
113
123
|
// justamente el que se adjunta a la factura del cliente, así que en cuanto
|
package/dist/i18n/en.js
CHANGED
|
@@ -540,16 +540,12 @@ 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:",
|
|
550
547
|
" {0} recuperadas de sesiones en subcarpetas y worktrees": " {0} recovered from sessions in subfolders and worktrees",
|
|
551
548
|
"{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
549
|
// ── publish: resumen antes de subir, y unpublish ──
|
|
554
550
|
"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
551
|
"¿Publicar? [s/N] ": "Publish? [y/N] ",
|
package/dist/server.js
CHANGED
|
@@ -128,7 +128,11 @@ async function importOnce(dbPath) {
|
|
|
128
128
|
continue;
|
|
129
129
|
store.saveCommits(db, await (0, git_js_1.readCommits)(root, {}));
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
// Todo lo conocido, no solo lo leído ahora (ver knownTurns), y los correos
|
|
132
|
+
// de todos esos repos: sin ellos, un día antiguo perdería sus commits.
|
|
133
|
+
const known = (0, assign_js_1.knownTurns)(db, turns);
|
|
134
|
+
const mine = await (0, identity_js_1.myEmailsByRepo)(db, new Set([...repos, ...store.allProjectRepoPaths(db),
|
|
135
|
+
...known.flatMap((t) => t.repoPath ?? [])]));
|
|
132
136
|
const allRepoCommits = db.prepare("SELECT * FROM commits").all()
|
|
133
137
|
.map((r) => ({
|
|
134
138
|
repoPath: r["repo_path"], hash: r["hash"],
|
|
@@ -141,18 +145,15 @@ async function importOnce(dbPath) {
|
|
|
141
145
|
let imputed = 0;
|
|
142
146
|
// Solo tus commits producen horas tuyas.
|
|
143
147
|
const commits = (0, identity_js_1.onlyMine)(allRepoCommits, mine);
|
|
144
|
-
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(
|
|
145
|
-
const fromCommits = (0,
|
|
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 });
|
|
148
|
+
const fromAgents = (0, sessionize_js_1.attachCommits)((0, sessionize_js_1.sessionize)(known), commits);
|
|
149
|
+
const fromCommits = (0, assign_js_1.commitOnlyBlocks)(db, commits, fromAgents);
|
|
150
|
+
// Las mismas reglas que `estela import` (billing/assign.ts).
|
|
151
|
+
const plan = (0, assign_js_1.planEntries)(db, [...fromAgents, ...fromCommits]);
|
|
152
152
|
for (const { entry } of plan.entries) {
|
|
153
153
|
store.saveTimeEntry(db, entry);
|
|
154
154
|
imputed++;
|
|
155
155
|
}
|
|
156
|
+
store.deleteStaleEntries(db, plan.stale);
|
|
156
157
|
return imputed;
|
|
157
158
|
}
|
|
158
159
|
finally {
|
|
@@ -581,11 +582,11 @@ async function api(req, res, url, dbPath) {
|
|
|
581
582
|
// recalcular nada: el mismo papel que salió el día que se emitió.
|
|
582
583
|
const reimpresion = /^\/api\/invoices\/([A-Za-z0-9-]+)\/pdf$/.exec(path);
|
|
583
584
|
if (reimpresion && req.method === "GET") {
|
|
584
|
-
return sendStoredInvoicePdf(db, res, reimpresion[1], url.searchParams.get("author") ?? "");
|
|
585
|
+
return sendStoredInvoicePdf(db, res, reimpresion[1], url.searchParams.get("author") ?? "", url.searchParams.get("amounts") !== "0");
|
|
585
586
|
}
|
|
586
587
|
if (path === "/api/invoice" && req.method === "POST") {
|
|
587
588
|
const body = await readJson(req);
|
|
588
|
-
return sendInvoice(db, res, String(body["projectId"] ?? ""), String(body["cutoff"] ?? ""), typeof body["author"] === "string" ? body["author"] : "");
|
|
589
|
+
return sendInvoice(db, res, String(body["projectId"] ?? ""), String(body["cutoff"] ?? ""), typeof body["author"] === "string" ? body["author"] : "", body["withAmounts"] !== false);
|
|
589
590
|
}
|
|
590
591
|
if (path === "/api/entry" && req.method === "POST") {
|
|
591
592
|
const body = await readJson(req);
|
|
@@ -1368,7 +1369,7 @@ function sendReport(db, res, projectId, from, to, withAmounts, author) {
|
|
|
1368
1369
|
* congeló al emitirla. Es lo que hace que el papel de agosto siga siendo el
|
|
1369
1370
|
* papel de agosto cuando se pide en diciembre.
|
|
1370
1371
|
*/
|
|
1371
|
-
function sendStoredInvoicePdf(db, res, number, author) {
|
|
1372
|
+
function sendStoredInvoicePdf(db, res, number, author, withAmounts) {
|
|
1372
1373
|
const invoice = store.getInvoice(db, number);
|
|
1373
1374
|
if (!invoice)
|
|
1374
1375
|
return json(res, 404, { error: "Esa factura no existe." });
|
|
@@ -1382,15 +1383,20 @@ function sendStoredInvoicePdf(db, res, number, author) {
|
|
|
1382
1383
|
const pdf = (0, index_js_2.withLang)((0, index_js_2.documentLang)({ clientLanguage: client.language }), () => (0, invoice_pdf_js_1.invoiceToPdf)(invoice, client, project, {
|
|
1383
1384
|
...(author ? { issuer: { name: author } } : {}),
|
|
1384
1385
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
1386
|
+
withAmounts,
|
|
1385
1387
|
}));
|
|
1386
1388
|
res.writeHead(200, {
|
|
1387
1389
|
"Content-Type": "application/pdf",
|
|
1388
|
-
"Content-Disposition": `attachment; filename="${invoice.number}
|
|
1390
|
+
"Content-Disposition": `attachment; filename="${invoicePdfName(invoice.number, withAmounts)}"`,
|
|
1389
1391
|
"Content-Length": pdf.length,
|
|
1390
1392
|
});
|
|
1391
1393
|
res.end(pdf);
|
|
1392
1394
|
}
|
|
1393
|
-
|
|
1395
|
+
/** Con otro nombre, para no confundir al guardarlos juntos cuál lleva importes. */
|
|
1396
|
+
function invoicePdfName(number, withAmounts) {
|
|
1397
|
+
return withAmounts ? `${number}.pdf` : `${number}-sin-importes.pdf`;
|
|
1398
|
+
}
|
|
1399
|
+
function sendInvoice(db, res, projectId, cutoffRaw, author, withAmounts) {
|
|
1394
1400
|
const project = store.getProject(db, projectId);
|
|
1395
1401
|
if (!project)
|
|
1396
1402
|
return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
|
|
@@ -1429,8 +1435,9 @@ function sendInvoice(db, res, projectId, cutoffRaw, author) {
|
|
|
1429
1435
|
const pdf = (0, index_js_2.withLang)((0, index_js_2.documentLang)({ clientLanguage: client.language }), () => (0, invoice_pdf_js_1.invoiceToPdf)(invoice, client, project, {
|
|
1430
1436
|
...(author ? { issuer: { name: author } } : {}),
|
|
1431
1437
|
...(invoice.aiAmortized ? { amortizedAiCost: invoice.aiAmortized } : {}),
|
|
1438
|
+
withAmounts,
|
|
1432
1439
|
}));
|
|
1433
|
-
const filename =
|
|
1440
|
+
const filename = invoicePdfName(invoice.number, withAmounts);
|
|
1434
1441
|
res.writeHead(200, {
|
|
1435
1442
|
"Content-Type": "application/pdf",
|
|
1436
1443
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "estela",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
@@ -900,6 +900,7 @@ main.wide { max-width: 1180px; }
|
|
|
900
900
|
subraye y que se centre como el resto. */
|
|
901
901
|
.invoice-dl{flex:0 0 auto;text-decoration:none;display:inline-flex;
|
|
902
902
|
align-items:center;padding:9px 18px;font-size:.9rem}
|
|
903
|
+
.invoice-dls{flex:0 0 auto;display:flex;gap:8px;flex-wrap:wrap}
|
|
903
904
|
@media(max-width:620px){
|
|
904
905
|
.invoice-row{flex-wrap:wrap}
|
|
905
906
|
.invoice-main{flex:1 1 100%}
|
package/web/app.js
CHANGED
|
@@ -1469,8 +1469,12 @@ function renderReports() {
|
|
|
1469
1469
|
<div class="report-hours">${fmtDuration(f.totalSeconds)}</div>
|
|
1470
1470
|
<div class="report-amount">${importe}</div>
|
|
1471
1471
|
</div>
|
|
1472
|
-
<
|
|
1473
|
-
|
|
1472
|
+
<div class="invoice-dls">
|
|
1473
|
+
<a class="btn-main invoice-dl" download
|
|
1474
|
+
href="/api/invoices/${encodeURIComponent(f.number)}/pdf">${tr("invoices.withAmounts")}</a>
|
|
1475
|
+
<a class="chip invoice-dl" download
|
|
1476
|
+
href="/api/invoices/${encodeURIComponent(f.number)}/pdf?amounts=0">${tr("invoices.withoutAmounts")}</a>
|
|
1477
|
+
</div>
|
|
1474
1478
|
</div>`;
|
|
1475
1479
|
}).join("") + "</div>");
|
|
1476
1480
|
}
|
|
@@ -1535,6 +1539,7 @@ function renderReports() {
|
|
|
1535
1539
|
const projectId = form.dataset.project;
|
|
1536
1540
|
const cutoff = form.querySelector(".rep-to").value;
|
|
1537
1541
|
const author = form.querySelector(".rep-author").value;
|
|
1542
|
+
const withAmounts = form.querySelector(".rep-amounts").checked;
|
|
1538
1543
|
const row = (state.reportsOverview?.unbilled || []).find((r) => r.projectId === projectId);
|
|
1539
1544
|
const proyecto = row ? row.projectName : projectId;
|
|
1540
1545
|
|
|
@@ -1542,7 +1547,7 @@ function renderReports() {
|
|
|
1542
1547
|
|
|
1543
1548
|
btn.disabled = true;
|
|
1544
1549
|
try {
|
|
1545
|
-
await cutoffAndInvoice(projectId, cutoff, author);
|
|
1550
|
+
await cutoffAndInvoice(projectId, cutoff, author, withAmounts);
|
|
1546
1551
|
toast(tr("toast.invoiced"));
|
|
1547
1552
|
await loadReports();
|
|
1548
1553
|
} catch (error) {
|
|
@@ -1561,11 +1566,11 @@ function renderReports() {
|
|
|
1561
1566
|
* con un enlace sintético — el mismo patrón que usa cualquier app sin
|
|
1562
1567
|
* framework para esto.
|
|
1563
1568
|
*/
|
|
1564
|
-
async function cutoffAndInvoice(projectId, cutoff, author) {
|
|
1569
|
+
async function cutoffAndInvoice(projectId, cutoff, author, withAmounts) {
|
|
1565
1570
|
const res = await fetch("/api/invoice", {
|
|
1566
1571
|
method: "POST",
|
|
1567
1572
|
headers: { "Content-Type": "application/json" },
|
|
1568
|
-
body: JSON.stringify({ projectId, cutoff, author }),
|
|
1573
|
+
body: JSON.stringify({ projectId, cutoff, author, withAmounts }),
|
|
1569
1574
|
});
|
|
1570
1575
|
if (!res.ok) {
|
|
1571
1576
|
let message = `Error ${res.status}`;
|
package/web/i18n.js
CHANGED
|
@@ -308,9 +308,10 @@
|
|
|
308
308
|
"reports.allbilled.title": "Todo facturado",
|
|
309
309
|
"reports.allbilled.body": "No queda nada pendiente en este periodo. Lo ya emitido está abajo, y se puede volver a descargar.",
|
|
310
310
|
"invoices.title": "Ya facturado",
|
|
311
|
-
"invoices.note": "Se reimprime tal y como se emiti
|
|
311
|
+
"invoices.note": "Se reimprime tal y como se emitió, con o sin importes: las cifras quedaron congeladas ese día.",
|
|
312
312
|
"invoices.upto": "Hasta el {date}",
|
|
313
|
-
"invoices.
|
|
313
|
+
"invoices.withAmounts": "Con importes",
|
|
314
|
+
"invoices.withoutAmounts": "Sin importes",
|
|
314
315
|
"reports.noMatch": "Ningún proyecto coincide con «{q}».",
|
|
315
316
|
"reports.client": "{client} · {blocks} · todo lo pendiente desde {date}",
|
|
316
317
|
"reports.from": "Desde",
|
|
@@ -613,9 +614,10 @@
|
|
|
613
614
|
"reports.allbilled.title": "All invoiced",
|
|
614
615
|
"reports.allbilled.body": "Nothing pending in this period. What you already issued is below, and can be downloaded again.",
|
|
615
616
|
"invoices.title": "Already invoiced",
|
|
616
|
-
"invoices.note": "Reprinted exactly as issued
|
|
617
|
+
"invoices.note": "Reprinted exactly as issued, with or without amounts: the figures were frozen that day.",
|
|
617
618
|
"invoices.upto": "Up to {date}",
|
|
618
|
-
"invoices.
|
|
619
|
+
"invoices.withAmounts": "With amounts",
|
|
620
|
+
"invoices.withoutAmounts": "Without amounts",
|
|
619
621
|
"reports.noMatch": "No project matches “{q}”.",
|
|
620
622
|
"reports.client": "{client} · {blocks} · everything pending since {date}",
|
|
621
623
|
"reports.from": "From",
|