estela 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +97 -0
  2. package/dist/billing/assign.d.ts +11 -1
  3. package/dist/billing/assign.js +59 -1
  4. package/dist/billing/sessionize.d.ts +6 -0
  5. package/dist/billing/sessionize.js +24 -5
  6. package/dist/cli.js +1084 -8
  7. package/dist/db/schema.js +100 -0
  8. package/dist/db/store.d.ts +46 -0
  9. package/dist/db/store.js +179 -5
  10. package/dist/delivery/clients.d.ts +52 -0
  11. package/dist/delivery/clients.js +94 -0
  12. package/dist/delivery/github.d.ts +21 -0
  13. package/dist/delivery/github.js +123 -0
  14. package/dist/delivery/report.d.ts +55 -0
  15. package/dist/delivery/report.js +124 -0
  16. package/dist/delivery/source.d.ts +68 -0
  17. package/dist/delivery/source.js +3 -0
  18. package/dist/export/csv.d.ts +14 -0
  19. package/dist/export/csv.js +78 -0
  20. package/dist/export/finance.d.ts +79 -0
  21. package/dist/export/finance.js +208 -0
  22. package/dist/i18n/en.js +171 -0
  23. package/dist/metrics/spend.d.ts +83 -0
  24. package/dist/metrics/spend.js +167 -0
  25. package/dist/metrics/undelivered.d.ts +104 -0
  26. package/dist/metrics/undelivered.js +199 -0
  27. package/dist/server.js +68 -8
  28. package/dist/setup.js +1 -1
  29. package/dist/watchers/git.d.ts +40 -0
  30. package/dist/watchers/git.js +87 -0
  31. package/node_modules/@estela/shared/dist/money.d.ts +2 -0
  32. package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
  33. package/node_modules/@estela/shared/dist/money.js +7 -7
  34. package/node_modules/@estela/shared/dist/money.js.map +1 -1
  35. package/node_modules/@estela/shared/dist/types.d.ts +7 -0
  36. package/node_modules/@estela/shared/dist/types.d.ts.map +1 -1
  37. package/node_modules/@estela/shared/dist/types.js.map +1 -1
  38. package/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
  39. package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
  40. package/node_modules/@estela/shared/dist/workitems.js.map +1 -1
  41. package/package.json +1 -1
  42. package/web/app.css +4 -0
  43. package/web/app.js +67 -0
  44. package/web/i18n.js +24 -0
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.spendByUnit = spendByUnit;
4
+ exports.realFromMonths = realFromMonths;
5
+ const shared_1 = require("@estela/shared");
6
+ const amortize_js_1 = require("../billing/amortize.js");
7
+ const index_js_1 = require("../i18n/index.js");
8
+ const paths_js_1 = require("../paths.js");
9
+ /** Agentes cuyo transcript no trae tokens: su trabajo cuenta horas pero no coste. */
10
+ const UNPRICED_AGENTS = new Set(["copilot"]);
11
+ function spendByUnit(input, by) {
12
+ const reports = [];
13
+ // Como en `amortize`: el denominador nunca baja de lo imputado ese mes, o un
14
+ // mes sin turnos guardados le daría la cuota entera a cada unidad.
15
+ const assignedByMonth = new Map();
16
+ for (const e of input.entries) {
17
+ const month = (0, amortize_js_1.monthOf)(e.startedAt);
18
+ assignedByMonth.set(month, (assignedByMonth.get(month) ?? 0) + e.aiCost.microUsd);
19
+ }
20
+ for (const project of input.projects) {
21
+ const entries = input.entries.filter((e) => e.projectId === project.id);
22
+ if (entries.length === 0)
23
+ continue;
24
+ const prsByBranch = mergedPrs(input.merges, project.repoPaths);
25
+ const groups = new Map();
26
+ for (const entry of entries) {
27
+ const a = attribute(entry, project.tracker ?? null, prsByBranch);
28
+ const key = by === "branch" ? entry.branch : a.items.length ? a.items.join(",") : null;
29
+ const id = key ?? "\u0000";
30
+ let acc = groups.get(id);
31
+ if (!acc) {
32
+ acc = newAcc(key);
33
+ groups.set(id, acc);
34
+ }
35
+ add(acc, entry, a, by);
36
+ }
37
+ const units = [...groups.values()].map((acc) => finish(acc, input, assignedByMonth));
38
+ units.sort((x, y) => (x.key === null ? 1 : 0) - (y.key === null ? 1 : 0)
39
+ || y.consumption.microUsd - x.consumption.microUsd || y.seconds - x.seconds);
40
+ reports.push({ project, units, total: sumUnits(units) });
41
+ }
42
+ return reports;
43
+ }
44
+ /** PRs fusionados por rama, de los merges de los repos del proyecto. */
45
+ function mergedPrs(merges, repos) {
46
+ const out = new Map();
47
+ for (const m of merges) {
48
+ if (!m.sourceBranch || !m.ref)
49
+ continue;
50
+ if (!repos.some((r) => (0, paths_js_1.isSameOrInside)(m.repoPath, r) || (0, paths_js_1.isSameOrInside)(r, m.repoPath)))
51
+ continue;
52
+ const list = out.get(m.sourceBranch) ?? [];
53
+ if (!list.includes(m.ref))
54
+ list.push(m.ref);
55
+ out.set(m.sourceBranch, list);
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Los tickets de una imputación y en qué se basan. Primero los que ya tiene
61
+ * (rama, commits o a mano); si no tiene ninguno, el PR con que se fusionó su
62
+ * rama. Un PR no sustituye a un ticket que ya se conoce: son cosas distintas.
63
+ */
64
+ function attribute(entry, tracker, prsByBranch) {
65
+ const prs = entry.branch ? prsByBranch.get(entry.branch) ?? [] : [];
66
+ const known = entry.workItems ?? [];
67
+ if (known.length) {
68
+ const fromBranch = new Set((0, shared_1.extractWorkItems)(entry.branch, [], tracker));
69
+ const itemBasis = entry.workItemsManual
70
+ ? ["manual"]
71
+ : known.map((k) => (fromBranch.has(k) ? "branch-name" : "commit-message"));
72
+ return { items: [...known], prs, itemBasis };
73
+ }
74
+ if (prs.length)
75
+ return { items: [...prs], prs, itemBasis: ["merge"] };
76
+ return { items: [], prs, itemBasis: [] };
77
+ }
78
+ function newAcc(key) {
79
+ return { key, branches: new Set(), items: new Set(), prs: new Set(), basis: new Set(),
80
+ seconds: 0, microUsd: 0, byMonth: new Map(), unpricedAgents: false };
81
+ }
82
+ function add(acc, entry, a, by) {
83
+ acc.seconds += entry.seconds;
84
+ acc.microUsd += entry.aiCost.microUsd;
85
+ // El mes de started_at, el mismo que usa `estela ai-cost` para repartir.
86
+ const month = (0, amortize_js_1.monthOf)(entry.startedAt);
87
+ acc.byMonth.set(month, (acc.byMonth.get(month) ?? 0) + entry.aiCost.microUsd);
88
+ if (entry.agents.some((x) => UNPRICED_AGENTS.has(x)))
89
+ acc.unpricedAgents = true;
90
+ if (entry.branch)
91
+ acc.branches.add(entry.branch);
92
+ // Por rama, los PRs van en su sitio: el del merge no se repite como ticket.
93
+ const fromMerge = a.itemBasis.length === 1 && a.itemBasis[0] === "merge";
94
+ if (by === "item" || !fromMerge)
95
+ for (const i of a.items)
96
+ acc.items.add(i);
97
+ for (const p of a.prs)
98
+ acc.prs.add(p);
99
+ if (by === "branch") {
100
+ if (entry.branch)
101
+ acc.basis.add(entry.agents.length ? "agent-branch" : "commit-branch");
102
+ }
103
+ else if (a.items.length) {
104
+ for (const b of a.itemBasis)
105
+ acc.basis.add(b);
106
+ }
107
+ }
108
+ /**
109
+ * Dinero real: la cuota del mes por la fracción de todo el consumo de ese mes
110
+ * que es de esta parte. La fórmula de `amortize`, solo que con una parte
111
+ * cualquiera (una rama, unas sesiones) en vez del proyecto; redondea por parte,
112
+ * así que la suma puede diferir en un céntimo de la de `ai-cost`.
113
+ *
114
+ * `partial`: algún mes con consumo no tenía cuota, y `real` cuenta solo el resto.
115
+ */
116
+ function realFromMonths(byMonth, subscriptions, monthTotal) {
117
+ let real = null;
118
+ let partial = false;
119
+ if (!subscriptions.length)
120
+ return { real, partial };
121
+ for (const [month, micro] of byMonth) {
122
+ const fee = (0, amortize_js_1.feeForMonth)(subscriptions, month);
123
+ if (!fee) {
124
+ if (micro > 0)
125
+ partial = true;
126
+ continue;
127
+ }
128
+ const total = Math.max(monthTotal(month), micro);
129
+ const part = total === 0 ? 0 : Math.round(fee.amount * (micro / total));
130
+ if (real && real.currency !== fee.currency) {
131
+ throw new TypeError((0, index_js_1.tr) `Suscripciones en monedas distintas en el periodo (${real.currency} y ${fee.currency}). ` +
132
+ (0, index_js_1.tr) `Unifica la moneda de las suscripciones: no se convierten automáticamente.`);
133
+ }
134
+ real = (0, shared_1.money)((real?.amount ?? 0) + part, fee.currency);
135
+ }
136
+ return { real, partial };
137
+ }
138
+ function finish(acc, input, assignedByMonth) {
139
+ const { real, partial } = realFromMonths(acc.byMonth, input.subscriptions, (month) => Math.max(input.monthTotals.get(month)?.microUsd ?? 0, assignedByMonth.get(month) ?? 0));
140
+ return {
141
+ key: acc.key,
142
+ branches: [...acc.branches].sort(),
143
+ items: [...acc.items],
144
+ prs: [...acc.prs],
145
+ basis: [...acc.basis],
146
+ seconds: acc.seconds,
147
+ consumption: { microUsd: acc.microUsd },
148
+ real,
149
+ realPartial: partial,
150
+ unpricedAgents: acc.unpricedAgents,
151
+ };
152
+ }
153
+ function sumUnits(units) {
154
+ let real = null;
155
+ for (const u of units) {
156
+ if (u.real)
157
+ real = (0, shared_1.money)((real?.amount ?? 0) + u.real.amount, u.real.currency);
158
+ }
159
+ return {
160
+ seconds: units.reduce((s, u) => s + u.seconds, 0),
161
+ consumption: { microUsd: units.reduce((s, u) => s + u.consumption.microUsd, 0) },
162
+ real,
163
+ realPartial: units.some((u) => u.realPartial),
164
+ unpricedAgents: units.some((u) => u.unpricedAgents),
165
+ };
166
+ }
167
+ //# sourceMappingURL=spend.js.map
@@ -0,0 +1,104 @@
1
+ import type { AiCost, AgentKind, Money, Subscription } from "@estela/shared";
2
+ /**
3
+ * Gasto de IA sin entrega: lo que no ha llegado (todavía) a la rama principal.
4
+ *
5
+ * Es una señal para revisar, no un juicio. Explorar, probar y descartar es
6
+ * trabajo, y una sesión en la que Claude cambia algo cinco veces antes de que
7
+ * lo des por bueno vale lo mismo aunque aún no tenga commit. Por eso nada de
8
+ * esto resta horas ni toca imputaciones: solo pone una cifra al lado.
9
+ *
10
+ * Se mide sobre los turnos, no sobre las imputaciones: una sesión es un rato
11
+ * de un chat, y una imputación es un día de una rama que puede juntar varias.
12
+ * Dos bloques que no se pisan:
13
+ *
14
+ * 1. Sesiones sin commit: un rato de trabajo de un chat (sin pausas de más de
15
+ * 30 min) en el que no hiciste ningún commit en ese repo, ni en los 30 min
16
+ * siguientes. Se separan las que luego sí tuvieron commit en su rama. Las
17
+ * anteriores a tu primer commit conocido en el repo no se juzgan: quizá
18
+ * commiteabas con otro correo.
19
+ * 2. Ramas sin fusionar: el resto del coste de una rama que sigue existiendo,
20
+ * no está integrada y lleva N días sin actividad. Una rama borrada sin
21
+ * merge registrado puede ser un squash: su estado es "desconocido" y no
22
+ * cuenta, porque contarla inflaría la cifra.
23
+ */
24
+ export interface UndeliveredTurn {
25
+ readonly projectId: string;
26
+ readonly sessionId: string;
27
+ readonly at: Date;
28
+ /** Raíz git (el repo principal, también para un worktree). */
29
+ readonly root: string;
30
+ readonly branch: string | null;
31
+ readonly microUsd: number;
32
+ readonly agent: AgentKind;
33
+ }
34
+ /** Un commit tuyo, con su raíz git ya resuelta igual que la de los turnos. */
35
+ export interface CommitFact {
36
+ readonly root: string;
37
+ readonly at: Date;
38
+ readonly branch: string | null;
39
+ }
40
+ export interface BranchFacts {
41
+ /** Integrada: la rama base, `git branch --merged` o un commit de merge. */
42
+ readonly merged: boolean;
43
+ /** `null` si el repo ya no se puede leer: no se sabe. */
44
+ readonly exists: boolean | null;
45
+ }
46
+ export interface UndeliveredInput {
47
+ readonly turns: readonly UndeliveredTurn[];
48
+ readonly commits: readonly CommitFact[];
49
+ readonly branchFacts: (root: string, branch: string) => BranchFacts;
50
+ readonly staleDays: number;
51
+ readonly now: Date;
52
+ /** Fechas locales; sin ellas, todo. Se mira la fecha de inicio de la sesión. */
53
+ readonly from?: string;
54
+ readonly to?: string;
55
+ readonly subscriptions: readonly Subscription[];
56
+ readonly monthTotals: ReadonlyMap<string, AiCost>;
57
+ }
58
+ export interface Spend {
59
+ readonly consumption: AiCost;
60
+ readonly real: Money | null;
61
+ readonly realPartial: boolean;
62
+ }
63
+ export type BranchState = "unmerged" | "open" | "unknown";
64
+ export interface BranchLine extends Spend {
65
+ readonly branch: string;
66
+ readonly state: BranchState;
67
+ /** Días desde la última actividad (turno o commit) en la rama. */
68
+ readonly idleDays: number;
69
+ }
70
+ export interface UndeliveredReport {
71
+ readonly projectId: string;
72
+ /** Todo el gasto del periodo en el proyecto: la base del porcentaje. */
73
+ readonly total: Spend;
74
+ /**
75
+ * Sesiones sin commit que no se pueden juzgar: no se conoce ningún commit tuyo
76
+ * en ese repo, o la sesión es anterior al primero. Puede ser que commitearas
77
+ * con otro correo (ver `estela author`), así que no cuentan.
78
+ */
79
+ readonly unverifiable: Spend & {
80
+ readonly count: number;
81
+ };
82
+ readonly sessionsWithoutCommit: Spend & {
83
+ readonly count: number;
84
+ /** De ellas, las que luego tuvieron commit en su rama (dentro de N días). */
85
+ readonly laterCommitted: Spend & {
86
+ readonly count: number;
87
+ };
88
+ };
89
+ /** Ramas que cuentan: sin fusionar tras N días. */
90
+ readonly unmerged: Spend & {
91
+ readonly branches: readonly BranchLine[];
92
+ };
93
+ /** No cuentan: abiertas hace menos de N días, o de estado desconocido. */
94
+ readonly notCounted: readonly BranchLine[];
95
+ /** Suma de los dos bloques que cuentan. */
96
+ readonly undelivered: Spend;
97
+ readonly unpricedAgents: boolean;
98
+ }
99
+ /** Ramas donde se integra: trabajar directamente en ellas ya es entregar. */
100
+ export declare const BASE_BRANCHES: Set<string>;
101
+ export declare function undeliveredSpend(input: UndeliveredInput): UndeliveredReport[];
102
+ /** El porcentaje de una parte sobre el total, en tanto por ciento. `null` sin total. */
103
+ export declare function shareOf(part: Spend, total: Spend): number | null;
104
+ //# sourceMappingURL=undelivered.d.ts.map
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BASE_BRANCHES = void 0;
4
+ exports.undeliveredSpend = undeliveredSpend;
5
+ exports.shareOf = shareOf;
6
+ const shared_1 = require("@estela/shared");
7
+ const amortize_js_1 = require("../billing/amortize.js");
8
+ const spend_js_1 = require("./spend.js");
9
+ /** El mismo hueco que separa bloques de trabajo (ver sessionize). */
10
+ const GAP_MS = 30 * 60_000;
11
+ const DAY_MS = 86_400_000;
12
+ const UNPRICED_AGENTS = new Set(["copilot"]);
13
+ /** Ramas donde se integra: trabajar directamente en ellas ya es entregar. */
14
+ exports.BASE_BRANCHES = new Set(["main", "master", "develop", "trunk"]);
15
+ function undeliveredSpend(input) {
16
+ const byProject = new Map();
17
+ for (const t of input.turns) {
18
+ const list = byProject.get(t.projectId) ?? [];
19
+ list.push(t);
20
+ byProject.set(t.projectId, list);
21
+ }
22
+ const reports = [];
23
+ for (const [projectId, turns] of byProject) {
24
+ const report = projectReport(projectId, turns, input);
25
+ if (report.total.consumption.microUsd > 0 || report.unpricedAgents)
26
+ reports.push(report);
27
+ }
28
+ return reports.sort((a, b) => a.projectId.localeCompare(b.projectId));
29
+ }
30
+ function projectReport(projectId, all, input) {
31
+ const inPeriod = (d) => {
32
+ const day = (0, shared_1.localDate)(d);
33
+ return (!input.from || day >= input.from) && (!input.to || day <= input.to);
34
+ };
35
+ const spend = new SpendAcc(input);
36
+ // Ratos de cada chat: mismo session_id y repo, cortados por pausas largas.
37
+ const sittings = [];
38
+ const bySession = new Map();
39
+ for (const t of all) {
40
+ const key = `${t.sessionId}\0${t.root}`;
41
+ const list = bySession.get(key) ?? [];
42
+ list.push(t);
43
+ bySession.set(key, list);
44
+ }
45
+ for (const list of bySession.values()) {
46
+ list.sort((a, b) => a.at.getTime() - b.at.getTime());
47
+ let current = [];
48
+ for (const t of list) {
49
+ const last = current[current.length - 1];
50
+ if (last && t.at.getTime() - last.at.getTime() > GAP_MS) {
51
+ sittings.push({ turns: current, root: last.root });
52
+ current = [];
53
+ }
54
+ current.push(t);
55
+ }
56
+ if (current.length)
57
+ sittings.push({ turns: current, root: current[0].root });
58
+ }
59
+ // El primer commit tuyo de cada repo: antes de él, no hay con qué comparar.
60
+ const firstCommit = new Map();
61
+ for (const c of input.commits) {
62
+ firstCommit.set(c.root, Math.min(firstCommit.get(c.root) ?? Infinity, c.at.getTime()));
63
+ }
64
+ const total = spend.empty();
65
+ const unverifiable = spend.empty();
66
+ let unverifiableCount = 0;
67
+ const noCommit = spend.empty();
68
+ const later = spend.empty();
69
+ let noCommitCount = 0;
70
+ let laterCount = 0;
71
+ const counted = new Set();
72
+ for (const s of sittings) {
73
+ const first = s.turns[0];
74
+ if (!inPeriod(first.at))
75
+ continue;
76
+ for (const t of s.turns)
77
+ spend.add(total, t);
78
+ const start = first.at.getTime();
79
+ const end = s.turns[s.turns.length - 1].at.getTime();
80
+ const committed = input.commits.some((c) => c.root === s.root && c.at.getTime() >= start && c.at.getTime() <= end + GAP_MS);
81
+ if (committed)
82
+ continue;
83
+ if (start < (firstCommit.get(s.root) ?? Infinity)) {
84
+ unverifiableCount++;
85
+ for (const t of s.turns) {
86
+ spend.add(unverifiable, t);
87
+ counted.add(t);
88
+ }
89
+ continue;
90
+ }
91
+ noCommitCount++;
92
+ for (const t of s.turns) {
93
+ spend.add(noCommit, t);
94
+ counted.add(t);
95
+ }
96
+ const branches = new Set(s.turns.map((t) => t.branch).filter((b) => !!b));
97
+ const afterwards = input.commits.some((c) => c.root === s.root && c.branch !== null
98
+ && branches.has(c.branch) && c.at.getTime() > end && c.at.getTime() <= end + input.staleDays * DAY_MS);
99
+ if (afterwards) {
100
+ laterCount++;
101
+ for (const t of s.turns)
102
+ spend.add(later, t);
103
+ }
104
+ }
105
+ // Ramas: el coste que no está ya en una sesión sin commit.
106
+ const lastActivity = new Map();
107
+ const touch = (root, branch, at) => {
108
+ const k = `${root}\0${branch}`;
109
+ lastActivity.set(k, Math.max(lastActivity.get(k) ?? 0, at.getTime()));
110
+ };
111
+ for (const t of all)
112
+ if (t.branch)
113
+ touch(t.root, t.branch, t.at);
114
+ const roots = new Set(all.map((t) => t.root));
115
+ for (const c of input.commits)
116
+ if (c.branch && roots.has(c.root))
117
+ touch(c.root, c.branch, c.at);
118
+ const lines = new Map();
119
+ for (const s of sittings) {
120
+ if (!inPeriod(s.turns[0].at))
121
+ continue;
122
+ for (const t of s.turns) {
123
+ if (counted.has(t) || !t.branch || t.branch === "HEAD" || exports.BASE_BRANCHES.has(t.branch))
124
+ continue;
125
+ const k = `${t.root}\0${t.branch}`;
126
+ let line = lines.get(k);
127
+ if (!line) {
128
+ line = { branch: t.branch, root: t.root, acc: spend.empty() };
129
+ lines.set(k, line);
130
+ }
131
+ spend.add(line.acc, t);
132
+ }
133
+ }
134
+ const unmergedAcc = spend.empty();
135
+ const unmergedLines = [];
136
+ const notCounted = [];
137
+ for (const [k, { branch, root, acc }] of lines) {
138
+ const facts = input.branchFacts(root, branch);
139
+ if (facts.merged)
140
+ continue;
141
+ const idleDays = Math.floor((input.now.getTime() - (lastActivity.get(k) ?? 0)) / DAY_MS);
142
+ const state = facts.exists !== true ? "unknown" : idleDays >= input.staleDays ? "unmerged" : "open";
143
+ const line = { branch, state, idleDays, ...spend.finish(acc) };
144
+ if (state === "unmerged") {
145
+ unmergedLines.push(line);
146
+ spend.merge(unmergedAcc, acc);
147
+ }
148
+ else {
149
+ notCounted.push(line);
150
+ }
151
+ }
152
+ const byCost = (a, b) => b.consumption.microUsd - a.consumption.microUsd;
153
+ const undelivered = spend.empty();
154
+ spend.merge(undelivered, noCommit);
155
+ spend.merge(undelivered, unmergedAcc);
156
+ return {
157
+ projectId,
158
+ total: spend.finish(total),
159
+ unverifiable: { ...spend.finish(unverifiable), count: unverifiableCount },
160
+ sessionsWithoutCommit: {
161
+ ...spend.finish(noCommit), count: noCommitCount,
162
+ laterCommitted: { ...spend.finish(later), count: laterCount },
163
+ },
164
+ unmerged: { ...spend.finish(unmergedAcc), branches: unmergedLines.sort(byCost) },
165
+ notCounted: notCounted.sort(byCost),
166
+ undelivered: spend.finish(undelivered),
167
+ unpricedAgents: total.unpriced,
168
+ };
169
+ }
170
+ /** Suma coste por mes, para repartir la cuota igual que `ai-cost`. */
171
+ class SpendAcc {
172
+ input;
173
+ constructor(input) {
174
+ this.input = input;
175
+ }
176
+ empty() { return { microUsd: 0, byMonth: new Map(), unpriced: false }; }
177
+ add(acc, t) {
178
+ acc.microUsd += t.microUsd;
179
+ const month = (0, amortize_js_1.monthOf)(t.at);
180
+ acc.byMonth.set(month, (acc.byMonth.get(month) ?? 0) + t.microUsd);
181
+ if (UNPRICED_AGENTS.has(t.agent))
182
+ acc.unpriced = true;
183
+ }
184
+ merge(into, from) {
185
+ into.microUsd += from.microUsd;
186
+ for (const [m, v] of from.byMonth)
187
+ into.byMonth.set(m, (into.byMonth.get(m) ?? 0) + v);
188
+ into.unpriced ||= from.unpriced;
189
+ }
190
+ finish(acc) {
191
+ const { real, partial } = (0, spend_js_1.realFromMonths)(acc.byMonth, this.input.subscriptions, (month) => this.input.monthTotals.get(month)?.microUsd ?? 0);
192
+ return { consumption: { microUsd: acc.microUsd }, real, realPartial: partial };
193
+ }
194
+ }
195
+ /** El porcentaje de una parte sobre el total, en tanto por ciento. `null` sin total. */
196
+ function shareOf(part, total) {
197
+ return total.consumption.microUsd === 0 ? null : (part.consumption.microUsd / total.consumption.microUsd) * 100;
198
+ }
199
+ //# sourceMappingURL=undelivered.js.map
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
  *
@@ -127,6 +129,7 @@ async function importOnce(dbPath) {
127
129
  if (!root)
128
130
  continue;
129
131
  store.saveCommits(db, await (0, git_js_1.readCommits)(root, {}));
132
+ store.saveMerges(db, await (0, git_js_1.readMerges)(root, {}));
130
133
  }
131
134
  // Todo lo conocido, no solo lo leído ahora (ver knownTurns), y los correos
132
135
  // de todos esos repos: sin ellos, un día antiguo perdería sus commits.
@@ -323,7 +326,7 @@ async function api(req, res, url, dbPath) {
323
326
  const projectId = url.searchParams.get("project");
324
327
  const rows = db.prepare(`
325
328
  SELECT local_date AS d, COUNT(*) AS n FROM time_entries
326
- WHERE approved = 0 AND invoice_id IS NULL
329
+ WHERE approved = 0 AND invoice_id IS NULL AND ${VISIBLE}
327
330
  ${projectId ? "AND project_id = ?" : ""}
328
331
  GROUP BY local_date ORDER BY local_date DESC
329
332
  `).all(...(projectId ? [projectId] : []));
@@ -352,6 +355,49 @@ async function api(req, res, url, dbPath) {
352
355
  const body = await readJson(req);
353
356
  return json(res, 200, { id: createProject(db, body) });
354
357
  }
358
+ if (path === "/api/project-archive" && req.method === "POST") {
359
+ const body = await readJson(req);
360
+ const projectId = String(body["projectId"] ?? "");
361
+ if (!store.getProject(db, projectId)) {
362
+ return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
363
+ }
364
+ store.archiveProject(db, projectId);
365
+ return json(res, 200, { ok: true });
366
+ }
367
+ if (path === "/api/project-restore" && req.method === "POST") {
368
+ const body = await readJson(req);
369
+ const projectId = String(body["projectId"] ?? "");
370
+ if (!store.getProject(db, projectId)) {
371
+ return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
372
+ }
373
+ store.restoreProject(db, projectId);
374
+ // Lo trabajado mientras estuvo archivado sigue en los turnos: se imputa ya.
375
+ const status = await runImport(dbPath);
376
+ return json(res, 200, { ok: true, importing: status.pending === true });
377
+ }
378
+ /* Borrar no tiene vuelta atrás: se exige haberlo archivado antes y escribir
379
+ su nombre, también aquí y no solo en la página. */
380
+ if (path === "/api/project-delete" && req.method === "POST") {
381
+ const body = await readJson(req);
382
+ const projectId = String(body["projectId"] ?? "");
383
+ const project = store.getProject(db, projectId);
384
+ if (!project)
385
+ return json(res, 404, { error: (0, index_js_2.tr) `No existe el proyecto "${projectId}".` });
386
+ if (!project.archivedAt)
387
+ return json(res, 409, { error: (0, index_js_2.tr) `Archiva el proyecto antes de eliminarlo.` });
388
+ if (String(body["confirmName"] ?? "") !== project.name) {
389
+ return json(res, 400, { error: (0, index_js_2.tr) `El nombre no coincide: no se ha eliminado nada.` });
390
+ }
391
+ const blocker = store.projectDeleteBlocker(db, projectId);
392
+ if (blocker === "invoiced") {
393
+ return json(res, 409, { error: (0, index_js_2.tr) `Tiene horas facturadas: se puede archivar, pero no eliminar.` });
394
+ }
395
+ if (blocker === "shared") {
396
+ return json(res, 409, { error: (0, index_js_2.tr) `Está publicado o sincronizado: se puede archivar, pero no eliminar.` });
397
+ }
398
+ store.deleteProject(db, projectId);
399
+ return json(res, 200, { ok: true });
400
+ }
355
401
  /* Con quién está compartido cada panel. El panel local no habla con la
356
402
  base de la nube: pregunta a la API con el token de este dispositivo,
357
403
  igual que hace para publicar. Sin cuenta vinculada no hay nada que
@@ -627,6 +673,7 @@ function buildOverview(db, from = "", to = "") {
627
673
  SUM(ai_micro_usd) AS micro,
628
674
  SUM(approved) AS approved
629
675
  FROM time_entries
676
+ WHERE ${VISIBLE}
630
677
  GROUP BY date
631
678
  ORDER BY date DESC
632
679
  LIMIT 30
@@ -649,7 +696,7 @@ function buildOverview(db, from = "", to = "") {
649
696
  SELECT project_id, SUM(seconds) AS seconds, SUM(ai_micro_usd) AS micro, COUNT(*) AS blocks,
650
697
  MIN(local_date) AS first_day, MAX(local_date) AS last_day
651
698
  FROM time_entries
652
- WHERE billable = 1 AND invoice_id IS NULL
699
+ WHERE billable = 1 AND invoice_id IS NULL AND ${VISIBLE}
653
700
  ${ranged ? "AND local_date >= ? AND local_date <= ?" : ""}
654
701
  GROUP BY project_id
655
702
  `).all(...(ranged ? [from, to] : []));
@@ -678,7 +725,7 @@ function buildOverview(db, from = "", to = "") {
678
725
  ? db.prepare(`
679
726
  SELECT COALESCE(SUM(seconds),0) AS seconds, COUNT(*) AS blocks
680
727
  FROM time_entries
681
- WHERE billable = 1 AND invoice_id IS NULL
728
+ WHERE billable = 1 AND invoice_id IS NULL AND ${VISIBLE}
682
729
  AND (local_date < ? OR local_date > ?)
683
730
  `).get(from, to)
684
731
  : { seconds: 0, blocks: 0 };
@@ -713,7 +760,7 @@ function buildSummary(db, from, to) {
713
760
  MAX(local_date) AS last_day,
714
761
  COUNT(DISTINCT local_date) AS days
715
762
  FROM time_entries
716
- WHERE local_date >= ? AND local_date <= ?
763
+ WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
717
764
  GROUP BY project_id
718
765
  `).all(from, to);
719
766
  const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
@@ -753,7 +800,7 @@ function buildSummary(db, from, to) {
753
800
  const byDay = db.prepare(`
754
801
  SELECT local_date AS date, SUM(seconds) AS seconds
755
802
  FROM time_entries
756
- WHERE local_date >= ? AND local_date <= ?
803
+ WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
757
804
  GROUP BY local_date ORDER BY local_date
758
805
  `).all(from, to);
759
806
  const totalSeconds = items.reduce((s, i) => s + i.seconds, 0);
@@ -820,7 +867,7 @@ async function buildTeam(db, from, to, projectId) {
820
867
  // de qué fiarse, y ninguna otra cifra de la pantalla lo dice.
821
868
  const composition = db.prepare(`
822
869
  SELECT project_id, source, SUM(seconds) AS seconds
823
- FROM time_entries WHERE local_date >= ? AND local_date <= ?
870
+ FROM time_entries WHERE local_date >= ? AND local_date <= ? AND ${VISIBLE}
824
871
  GROUP BY project_id, source
825
872
  `).all(from, to);
826
873
  const byProject = new Map();
@@ -934,7 +981,7 @@ function buildDay(db, date) {
934
981
  SELECT t.*, i.number AS invoice_number
935
982
  FROM time_entries t
936
983
  LEFT JOIN invoices i ON i.id = t.invoice_id
937
- WHERE t.local_date = ?
984
+ WHERE t.local_date = ? AND t.${VISIBLE}
938
985
  ORDER BY t.started_at
939
986
  `).all(date);
940
987
  const projects = new Map(store.listProjects(db).map((p) => [p.id, p]));
@@ -1174,6 +1221,14 @@ function buildProjects(db) {
1174
1221
  staleBlocks: pub.staleBlocks,
1175
1222
  daysAgo: Math.floor((Date.now() - pub.publishedAt.getTime()) / 86_400_000),
1176
1223
  })),
1224
+ archived: store.listArchivedProjects(db).map((p) => {
1225
+ const totals = db.prepare("SELECT COUNT(*) AS blocks, SUM(seconds) AS seconds FROM time_entries WHERE project_id = ?").get(p.id);
1226
+ return {
1227
+ id: p.id, name: p.name, clientName: clients.get(p.clientId)?.name ?? "—",
1228
+ blocks: totals.blocks, seconds: totals.seconds ?? 0,
1229
+ deleteBlocker: store.projectDeleteBlocker(db, p.id),
1230
+ };
1231
+ }),
1177
1232
  projects: store.listProjects(db).map((p) => {
1178
1233
  const client = clients.get(p.clientId);
1179
1234
  const rates = store.getRates(db, p.id);
@@ -1196,6 +1251,7 @@ function buildProjects(db) {
1196
1251
  hourlyMinor: current?.amount ?? null,
1197
1252
  blocks: totals.blocks,
1198
1253
  seconds: totals.seconds ?? 0,
1254
+ 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,
1199
1255
  // El historial completo: subir la tarifa no reescribe lo ya trabajado,
1200
1256
  // así que conviene poder verlo.
1201
1257
  rateHistory: rates.map((r) => ({
@@ -1216,7 +1272,7 @@ function unassignedRepos(db) {
1216
1272
  GROUP BY repo_path ORDER BY turns DESC
1217
1273
  `).all();
1218
1274
  return rows
1219
- .filter((r) => store.projectForRepo(db, r.repo_path) === null)
1275
+ .filter((r) => store.ownerOfRepo(db, r.repo_path) === null)
1220
1276
  .slice(0, 40)
1221
1277
  .map((r) => ({
1222
1278
  path: r.repo_path,
@@ -1247,6 +1303,10 @@ function createProject(db, body) {
1247
1303
  }
1248
1304
  }
1249
1305
  const id = String(body["id"] ?? "").trim() || slug(name);
1306
+ // Volver a crearlo no lo sacaría del archivo: quedaría oculto y sin explicación.
1307
+ if (store.getProject(db, id)?.archivedAt) {
1308
+ throw new Error((0, index_js_2.tr) `Ya hay un proyecto archivado con ese nombre: restáuralo.`);
1309
+ }
1250
1310
  const repo = String(body["repoPath"] ?? "").trim();
1251
1311
  const kind = (String(body["kind"] ?? "client"));
1252
1312
  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.projectForRepo(db, root);
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 });