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.
- package/README.md +97 -0
- package/dist/billing/assign.d.ts +11 -1
- package/dist/billing/assign.js +59 -1
- package/dist/billing/sessionize.d.ts +6 -0
- package/dist/billing/sessionize.js +24 -5
- package/dist/cli.js +1084 -8
- package/dist/db/schema.js +100 -0
- package/dist/db/store.d.ts +46 -0
- package/dist/db/store.js +179 -5
- package/dist/delivery/clients.d.ts +52 -0
- package/dist/delivery/clients.js +94 -0
- package/dist/delivery/github.d.ts +21 -0
- package/dist/delivery/github.js +123 -0
- package/dist/delivery/report.d.ts +55 -0
- package/dist/delivery/report.js +124 -0
- package/dist/delivery/source.d.ts +68 -0
- package/dist/delivery/source.js +3 -0
- package/dist/export/csv.d.ts +14 -0
- package/dist/export/csv.js +78 -0
- package/dist/export/finance.d.ts +79 -0
- package/dist/export/finance.js +208 -0
- package/dist/i18n/en.js +171 -0
- package/dist/metrics/spend.d.ts +83 -0
- package/dist/metrics/spend.js +167 -0
- package/dist/metrics/undelivered.d.ts +104 -0
- package/dist/metrics/undelivered.js +199 -0
- package/dist/server.js +68 -8
- package/dist/setup.js +1 -1
- package/dist/watchers/git.d.ts +40 -0
- package/dist/watchers/git.js +87 -0
- package/node_modules/@estela/shared/dist/money.d.ts +2 -0
- package/node_modules/@estela/shared/dist/money.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/money.js +7 -7
- package/node_modules/@estela/shared/dist/money.js.map +1 -1
- package/node_modules/@estela/shared/dist/types.d.ts +7 -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/node_modules/@estela/shared/dist/workitems.d.ts +10 -0
- package/node_modules/@estela/shared/dist/workitems.d.ts.map +1 -1
- package/node_modules/@estela/shared/dist/workitems.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
|
@@ -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.
|
|
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.
|
|
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 });
|