ray-finance 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/context.js +17 -10
- package/dist/cli/chat.js +183 -8
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +136 -1
- package/dist/cli/completions.js +2 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +21 -9
- package/dist/daily-sync.js +1 -1
- package/dist/queries/index.js +10 -1
- package/package.json +1 -1
package/dist/ai/context.js
CHANGED
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
|
|
2
|
-
import { resolve } from "path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { resolve, dirname, basename } from "path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
function getContextFilePath() {
|
|
5
|
+
const dbFile = basename(config.dbPath, ".db");
|
|
6
|
+
const contextFile = dbFile === "finance" ? "context.md" : `context-${dbFile}.md`;
|
|
7
|
+
return resolve(dirname(config.dbPath), "..", contextFile);
|
|
8
|
+
}
|
|
5
9
|
export function getContextPath() {
|
|
6
|
-
return
|
|
10
|
+
return getContextFilePath();
|
|
7
11
|
}
|
|
8
12
|
export function readContext() {
|
|
9
|
-
|
|
13
|
+
const contextPath = getContextFilePath();
|
|
14
|
+
if (!existsSync(contextPath))
|
|
10
15
|
return "";
|
|
11
16
|
try {
|
|
12
|
-
return readFileSync(
|
|
17
|
+
return readFileSync(contextPath, "utf-8");
|
|
13
18
|
}
|
|
14
19
|
catch {
|
|
15
20
|
return "";
|
|
16
21
|
}
|
|
17
22
|
}
|
|
18
23
|
export function writeContext(content) {
|
|
19
|
-
const
|
|
24
|
+
const contextPath = getContextFilePath();
|
|
25
|
+
const dir = dirname(contextPath);
|
|
20
26
|
if (!existsSync(dir))
|
|
21
27
|
mkdirSync(dir, { recursive: true });
|
|
22
|
-
writeFileSync(
|
|
28
|
+
writeFileSync(contextPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
23
29
|
try {
|
|
24
|
-
chmodSync(
|
|
30
|
+
chmodSync(contextPath, 0o600);
|
|
25
31
|
}
|
|
26
32
|
catch { }
|
|
27
33
|
}
|
|
@@ -64,7 +70,8 @@ export function replaceContextSection(section, content) {
|
|
|
64
70
|
writeContext(current);
|
|
65
71
|
}
|
|
66
72
|
export function createContextTemplate(userName) {
|
|
67
|
-
|
|
73
|
+
const contextPath = getContextFilePath();
|
|
74
|
+
if (existsSync(contextPath))
|
|
68
75
|
return; // don't overwrite existing
|
|
69
76
|
const template = `# Financial Context for ${userName}
|
|
70
77
|
|
package/dist/cli/chat.js
CHANGED
|
@@ -5,7 +5,35 @@ import { banner, formatResponse, formatDuration, formatError } from "./format.js
|
|
|
5
5
|
function rawReadLine(prompt, belowLines) {
|
|
6
6
|
return new Promise((resolve) => {
|
|
7
7
|
let buf = "";
|
|
8
|
+
let cursor = 0; // cursor position within buf
|
|
8
9
|
const out = process.stdout;
|
|
10
|
+
const promptLen = stripAnsi(prompt).length;
|
|
11
|
+
// Redraws the buffer from the prompt onward and repositions the cursor
|
|
12
|
+
const redraw = () => {
|
|
13
|
+
out.write("\r" + prompt + buf + "\x1b[K"); // clear to end of line
|
|
14
|
+
// Move cursor back to the correct position
|
|
15
|
+
const back = buf.length - cursor;
|
|
16
|
+
if (back > 0)
|
|
17
|
+
out.write(`\x1b[${back}D`);
|
|
18
|
+
};
|
|
19
|
+
// Find the start of the previous word boundary
|
|
20
|
+
const wordLeft = () => {
|
|
21
|
+
let p = cursor;
|
|
22
|
+
while (p > 0 && buf[p - 1] === " ")
|
|
23
|
+
p--; // skip trailing spaces
|
|
24
|
+
while (p > 0 && buf[p - 1] !== " ")
|
|
25
|
+
p--; // skip word chars
|
|
26
|
+
return p;
|
|
27
|
+
};
|
|
28
|
+
// Find the end of the next word boundary
|
|
29
|
+
const wordRight = () => {
|
|
30
|
+
let p = cursor;
|
|
31
|
+
while (p < buf.length && buf[p] !== " ")
|
|
32
|
+
p++; // skip word chars
|
|
33
|
+
while (p < buf.length && buf[p] === " ")
|
|
34
|
+
p++; // skip trailing spaces
|
|
35
|
+
return p;
|
|
36
|
+
};
|
|
9
37
|
// Render: prompt on current line, then content below, then move cursor back
|
|
10
38
|
out.write(prompt);
|
|
11
39
|
if (belowLines.length > 0) {
|
|
@@ -32,9 +60,51 @@ function rawReadLine(prompt, belowLines) {
|
|
|
32
60
|
resolve("\x03");
|
|
33
61
|
return;
|
|
34
62
|
}
|
|
63
|
+
// Ctrl+A — beginning of line
|
|
64
|
+
if (code === 1) {
|
|
65
|
+
if (cursor > 0) {
|
|
66
|
+
out.write(`\x1b[${cursor}D`);
|
|
67
|
+
cursor = 0;
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Ctrl+E — end of line
|
|
72
|
+
if (code === 5) {
|
|
73
|
+
if (cursor < buf.length) {
|
|
74
|
+
out.write(`\x1b[${buf.length - cursor}C`);
|
|
75
|
+
cursor = buf.length;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Ctrl+K — delete from cursor to end of line
|
|
80
|
+
if (code === 11) {
|
|
81
|
+
buf = buf.slice(0, cursor);
|
|
82
|
+
out.write("\x1b[K");
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Ctrl+U — delete from cursor to beginning of line
|
|
86
|
+
if (code === 21) {
|
|
87
|
+
buf = buf.slice(cursor);
|
|
88
|
+
cursor = 0;
|
|
89
|
+
redraw();
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Ctrl+W — delete word backward
|
|
93
|
+
if (code === 23) {
|
|
94
|
+
if (cursor > 0) {
|
|
95
|
+
const target = wordLeft();
|
|
96
|
+
buf = buf.slice(0, target) + buf.slice(cursor);
|
|
97
|
+
cursor = target;
|
|
98
|
+
redraw();
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
35
102
|
// Enter
|
|
36
103
|
if (code === 13) {
|
|
37
104
|
cleanup();
|
|
105
|
+
// Move cursor to end of buf first
|
|
106
|
+
if (cursor < buf.length)
|
|
107
|
+
out.write(`\x1b[${buf.length - cursor}C`);
|
|
38
108
|
// Move past the below-content lines, then newline
|
|
39
109
|
for (let j = 0; j < belowLines.length; j++)
|
|
40
110
|
out.write("\x1b[1B");
|
|
@@ -44,31 +114,136 @@ function rawReadLine(prompt, belowLines) {
|
|
|
44
114
|
}
|
|
45
115
|
// Backspace
|
|
46
116
|
if (code === 127 || code === 8) {
|
|
47
|
-
if (
|
|
48
|
-
buf = buf.slice(0, -1);
|
|
49
|
-
|
|
117
|
+
if (cursor > 0) {
|
|
118
|
+
buf = buf.slice(0, cursor - 1) + buf.slice(cursor);
|
|
119
|
+
cursor--;
|
|
120
|
+
redraw();
|
|
50
121
|
}
|
|
51
122
|
continue;
|
|
52
123
|
}
|
|
53
|
-
//
|
|
124
|
+
// Escape sequences (arrow keys, Option+key, etc.)
|
|
54
125
|
if (code === 27) {
|
|
126
|
+
// Option+Backspace — ESC followed by DEL (0x7f)
|
|
127
|
+
if (i + 1 < chunk.length && chunk.charCodeAt(i + 1) === 127) {
|
|
128
|
+
i++; // consume the DEL
|
|
129
|
+
if (cursor > 0) {
|
|
130
|
+
const target = wordLeft();
|
|
131
|
+
buf = buf.slice(0, target) + buf.slice(cursor);
|
|
132
|
+
cursor = target;
|
|
133
|
+
redraw();
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Option+b / Option+f — ESC followed by 'b' or 'f'
|
|
138
|
+
if (i + 1 < chunk.length && chunk[i + 1] === "b") {
|
|
139
|
+
i++;
|
|
140
|
+
const target = wordLeft();
|
|
141
|
+
if (target < cursor) {
|
|
142
|
+
out.write(`\x1b[${cursor - target}D`);
|
|
143
|
+
cursor = target;
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (i + 1 < chunk.length && chunk[i + 1] === "f") {
|
|
148
|
+
i++;
|
|
149
|
+
const target = wordRight();
|
|
150
|
+
if (target > cursor) {
|
|
151
|
+
out.write(`\x1b[${target - cursor}C`);
|
|
152
|
+
cursor = target;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
55
156
|
if (i + 1 < chunk.length && chunk[i + 1] === "[") {
|
|
56
|
-
i += 2;
|
|
57
|
-
|
|
157
|
+
i += 2; // skip past ESC [
|
|
158
|
+
// Collect any intermediate bytes (modifiers like "1;3")
|
|
159
|
+
let seq = "";
|
|
160
|
+
while (i < chunk.length && chunk.charCodeAt(i) < 64) {
|
|
161
|
+
seq += chunk[i];
|
|
58
162
|
i++;
|
|
163
|
+
}
|
|
164
|
+
if (i < chunk.length) {
|
|
165
|
+
const final = chunk[i];
|
|
166
|
+
// Modifier keys: ;3 = Option, ;5 = Ctrl, ;9 = Cmd (Kitty protocol)
|
|
167
|
+
const isWordMod = seq === "1;3" || seq === "1;5" || seq === "1;9";
|
|
168
|
+
const isCmd = seq === "1;9";
|
|
169
|
+
if (final === "D") {
|
|
170
|
+
if (isWordMod) {
|
|
171
|
+
// Option/Ctrl/Cmd+Left — word backward
|
|
172
|
+
const target = wordLeft();
|
|
173
|
+
if (target < cursor) {
|
|
174
|
+
out.write(`\x1b[${cursor - target}D`);
|
|
175
|
+
cursor = target;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (cursor > 0) {
|
|
179
|
+
cursor--;
|
|
180
|
+
out.write("\x1b[D");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (final === "C") {
|
|
184
|
+
if (isWordMod) {
|
|
185
|
+
// Option/Ctrl/Cmd+Right — word forward
|
|
186
|
+
const target = wordRight();
|
|
187
|
+
if (target > cursor) {
|
|
188
|
+
out.write(`\x1b[${target - cursor}C`);
|
|
189
|
+
cursor = target;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (cursor < buf.length) {
|
|
193
|
+
cursor++;
|
|
194
|
+
out.write("\x1b[C");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (final === "H") {
|
|
198
|
+
// Home
|
|
199
|
+
if (cursor > 0) {
|
|
200
|
+
out.write(`\x1b[${cursor}D`);
|
|
201
|
+
cursor = 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (final === "F") {
|
|
205
|
+
// End
|
|
206
|
+
if (cursor < buf.length) {
|
|
207
|
+
out.write(`\x1b[${buf.length - cursor}C`);
|
|
208
|
+
cursor = buf.length;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (final === "u") {
|
|
212
|
+
// Kitty keyboard protocol: ESC [ codepoint ; modifier u
|
|
213
|
+
const parts = seq.split(";");
|
|
214
|
+
const codepoint = parseInt(parts[0], 10);
|
|
215
|
+
const mod = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
|
216
|
+
const hasCmd = (mod - 1) & 8; // super/cmd bit
|
|
217
|
+
const hasCtrl = (mod - 1) & 4; // ctrl bit
|
|
218
|
+
if (codepoint === 127 && (hasCmd || hasCtrl)) {
|
|
219
|
+
// Cmd+Backspace / Ctrl+Backspace — delete to line start
|
|
220
|
+
if (cursor > 0) {
|
|
221
|
+
buf = buf.slice(cursor);
|
|
222
|
+
cursor = 0;
|
|
223
|
+
redraw();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Ignore other sequences (up/down, etc.)
|
|
228
|
+
}
|
|
59
229
|
}
|
|
60
230
|
continue;
|
|
61
231
|
}
|
|
62
232
|
// Printable characters
|
|
63
233
|
if (code >= 32) {
|
|
64
|
-
buf
|
|
65
|
-
|
|
234
|
+
buf = buf.slice(0, cursor) + chunk[i] + buf.slice(cursor);
|
|
235
|
+
cursor++;
|
|
236
|
+
redraw();
|
|
66
237
|
}
|
|
67
238
|
}
|
|
68
239
|
};
|
|
69
240
|
process.stdin.on("data", onData);
|
|
70
241
|
});
|
|
71
242
|
}
|
|
243
|
+
/** Strip ANSI escape codes to get visible length */
|
|
244
|
+
function stripAnsi(str) {
|
|
245
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
246
|
+
}
|
|
72
247
|
const THINKING_PHRASES = [
|
|
73
248
|
"Thinking...",
|
|
74
249
|
"Crunching numbers...",
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -14,3 +14,5 @@ export declare function showScore(): void;
|
|
|
14
14
|
export declare function runAdd(): Promise<void>;
|
|
15
15
|
export declare function runRemove(): Promise<void>;
|
|
16
16
|
export declare function showAlerts(): void;
|
|
17
|
+
export declare function showBills(days?: number): void;
|
|
18
|
+
export declare function showRecap(period?: string): void;
|
package/dist/cli/commands.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../db/connection.js";
|
|
3
|
-
import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
|
|
3
|
+
import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, compareSpending, getNetWorthTrend, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
|
|
4
4
|
import { getLatestScore, getAchievements, getMonthlySavings } from "../scoring/index.js";
|
|
5
5
|
import { generateAlerts } from "../alerts/index.js";
|
|
6
6
|
import { runDailySync } from "../daily-sync.js";
|
|
@@ -388,3 +388,138 @@ export function showAlerts() {
|
|
|
388
388
|
}
|
|
389
389
|
console.log();
|
|
390
390
|
}
|
|
391
|
+
export function showBills(days = 7) {
|
|
392
|
+
const db = getDb();
|
|
393
|
+
const now = new Date();
|
|
394
|
+
const todayDay = now.getDate();
|
|
395
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
396
|
+
const endDay = todayDay + days;
|
|
397
|
+
let bills = [];
|
|
398
|
+
if (endDay <= daysInMonth) {
|
|
399
|
+
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, endDay);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// Wraparound into next month
|
|
403
|
+
const thisMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, daysInMonth);
|
|
404
|
+
const nextMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ? ORDER BY day_of_month`).all(endDay - daysInMonth);
|
|
405
|
+
bills = [...thisMonth, ...nextMonth];
|
|
406
|
+
}
|
|
407
|
+
if (bills.length === 0) {
|
|
408
|
+
console.log(`\nNo upcoming bills in the next ${days} days.`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
console.log(`\n${heading("Upcoming Bills")} ${dim(`next ${days} days`)}\n`);
|
|
412
|
+
const maxName = Math.max(...bills.map(b => b.name.length));
|
|
413
|
+
let total = 0;
|
|
414
|
+
for (const b of bills) {
|
|
415
|
+
// Calculate the actual date for this bill
|
|
416
|
+
let billDate;
|
|
417
|
+
if (b.day_of_month > todayDay) {
|
|
418
|
+
billDate = new Date(now.getFullYear(), now.getMonth(), b.day_of_month);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
billDate = new Date(now.getFullYear(), now.getMonth() + 1, b.day_of_month);
|
|
422
|
+
}
|
|
423
|
+
const dateStr = billDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
424
|
+
console.log(` ${dim(dateStr.padEnd(8))}${b.name.padEnd(maxName + 2)}${rawFormatMoney(b.amount).padStart(10)}`);
|
|
425
|
+
total += b.amount;
|
|
426
|
+
}
|
|
427
|
+
console.log(`\n ${dim("Total due:".padEnd(maxName + 10))}${chalk.bold(rawFormatMoney(total))}`);
|
|
428
|
+
console.log();
|
|
429
|
+
}
|
|
430
|
+
export function showRecap(period = "last_month") {
|
|
431
|
+
const db = getDb();
|
|
432
|
+
const now = new Date();
|
|
433
|
+
const y = now.getFullYear();
|
|
434
|
+
const m = now.getMonth();
|
|
435
|
+
let start, end, label;
|
|
436
|
+
let prevStart, prevEnd;
|
|
437
|
+
if (period === "this_month") {
|
|
438
|
+
start = new Date(y, m, 1).toISOString().slice(0, 10);
|
|
439
|
+
end = now.toISOString().slice(0, 10);
|
|
440
|
+
label = now.toLocaleDateString("en-US", { month: "long", year: "numeric" }) + " (so far)";
|
|
441
|
+
prevStart = new Date(y, m - 1, 1).toISOString().slice(0, 10);
|
|
442
|
+
prevEnd = new Date(y, m, 0).toISOString().slice(0, 10);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
// last_month
|
|
446
|
+
start = new Date(y, m - 1, 1).toISOString().slice(0, 10);
|
|
447
|
+
end = new Date(y, m, 0).toISOString().slice(0, 10);
|
|
448
|
+
const lastMonth = new Date(y, m - 1, 1);
|
|
449
|
+
label = lastMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
450
|
+
prevStart = new Date(y, m - 2, 1).toISOString().slice(0, 10);
|
|
451
|
+
prevEnd = new Date(y, m - 1, 0).toISOString().slice(0, 10);
|
|
452
|
+
}
|
|
453
|
+
// Spending this period
|
|
454
|
+
const spending = db.prepare(`SELECT SUM(amount) as total, COUNT(*) as count FROM transactions
|
|
455
|
+
WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
456
|
+
AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(start, end);
|
|
457
|
+
// Income this period
|
|
458
|
+
const income = db.prepare(`SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM transactions
|
|
459
|
+
WHERE amount < 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
460
|
+
AND category NOT IN ('TRANSFER_IN')`).get(start, end);
|
|
461
|
+
const totalSpent = spending.total || 0;
|
|
462
|
+
const txnCount = spending.count || 0;
|
|
463
|
+
if (txnCount === 0) {
|
|
464
|
+
console.log(`\nNo transaction data for ${label}.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
console.log(`\n${heading("Recap")} ${dim(label)}\n`);
|
|
468
|
+
// ── Spending summary with comparison ──
|
|
469
|
+
const cmp = compareSpending(db, prevStart, prevEnd, start, end);
|
|
470
|
+
let spendLine = ` Spent ${chalk.bold(rawFormatMoney(totalSpent))} across ${txnCount} transactions`;
|
|
471
|
+
if (cmp.period1Total > 0) {
|
|
472
|
+
const pct = Math.abs(cmp.pctChange);
|
|
473
|
+
const dir = cmp.pctChange <= 0 ? chalk.green(`${pct}% less`) : chalk.red(`${pct}% more`);
|
|
474
|
+
spendLine += ` — ${dir} than prior month`;
|
|
475
|
+
}
|
|
476
|
+
console.log(spendLine);
|
|
477
|
+
// ── Income ──
|
|
478
|
+
if (income.total > 0) {
|
|
479
|
+
const net = income.total - totalSpent;
|
|
480
|
+
const savingsRate = Math.round((net / income.total) * 100);
|
|
481
|
+
console.log(` Earned ${chalk.bold(rawFormatMoney(income.total))} Net: ${formatMoneyColored(net)} ${dim(`(${savingsRate}% savings rate)`)}`);
|
|
482
|
+
}
|
|
483
|
+
// ── Biggest movers ──
|
|
484
|
+
const movers = cmp.categories.filter(c => Math.abs(c.diff) >= 10).slice(0, 3);
|
|
485
|
+
if (movers.length > 0) {
|
|
486
|
+
console.log(`\n ${heading("Biggest Movers")}`);
|
|
487
|
+
for (const mv of movers) {
|
|
488
|
+
const arrow = mv.diff > 0 ? chalk.red("↑") : chalk.green("↓");
|
|
489
|
+
const diffStr = mv.diff > 0 ? chalk.red("+" + rawFormatMoney(mv.diff)) : chalk.green("-" + rawFormatMoney(Math.abs(mv.diff)));
|
|
490
|
+
console.log(` ${arrow} ${categoryLabel(mv.category).padEnd(18)} ${rawFormatMoney(mv.period2).padStart(10)} ${diffStr}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// ── Top categories ──
|
|
494
|
+
const topCats = db.prepare(`SELECT category, SUM(amount) as total FROM transactions
|
|
495
|
+
WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
496
|
+
AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')
|
|
497
|
+
GROUP BY category ORDER BY total DESC LIMIT 5`).all(start, end);
|
|
498
|
+
if (topCats.length > 0) {
|
|
499
|
+
console.log(`\n ${heading("Top Categories")}`);
|
|
500
|
+
for (const c of topCats) {
|
|
501
|
+
const pct = Math.round((c.total / totalSpent) * 100);
|
|
502
|
+
console.log(` ${categoryLabel(c.category).padEnd(18)} ${rawFormatMoney(c.total).padStart(10)} ${dim(`${pct}%`)}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// ── Net worth change over the period ──
|
|
506
|
+
const nwTrend = getNetWorthTrend(db, 60);
|
|
507
|
+
const nwAtStart = nwTrend.find(d => d.date >= start);
|
|
508
|
+
const nwAtEnd = [...nwTrend].reverse().find(d => d.date <= end);
|
|
509
|
+
if (nwAtStart && nwAtEnd) {
|
|
510
|
+
const nwChange = nwAtEnd.net_worth - nwAtStart.net_worth;
|
|
511
|
+
const arrow = nwChange >= 0 ? chalk.green("↑") : chalk.red("↓");
|
|
512
|
+
console.log(`\n ${heading("Net Worth")}`);
|
|
513
|
+
console.log(` ${rawFormatMoney(nwAtStart.net_worth)} → ${chalk.bold(rawFormatMoney(nwAtEnd.net_worth))} ${arrow} ${formatMoneyColored(nwChange)}`);
|
|
514
|
+
}
|
|
515
|
+
// ── Goals progress ──
|
|
516
|
+
const goals = getGoals(db);
|
|
517
|
+
const activeGoals = goals.filter(g => g.progress_pct < 100);
|
|
518
|
+
if (activeGoals.length > 0) {
|
|
519
|
+
console.log(`\n ${heading("Goals")}`);
|
|
520
|
+
for (const g of activeGoals) {
|
|
521
|
+
console.log(` ${g.name.padEnd(20)} ${progressBar(g.progress_pct, 12)} ${dim(rawFormatMoney(g.current) + " / " + rawFormatMoney(g.target))}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
console.log();
|
|
525
|
+
}
|
package/dist/cli/completions.js
CHANGED
|
@@ -45,12 +45,13 @@ const COMMANDS = [
|
|
|
45
45
|
{ name: "goals", desc: "Show financial goals" },
|
|
46
46
|
{ name: "score", desc: "Show daily financial score and streaks" },
|
|
47
47
|
{ name: "alerts", desc: "Show financial alerts" },
|
|
48
|
+
{ name: "bills", desc: "Show upcoming bills" },
|
|
49
|
+
{ name: "recap", desc: "Monthly spending recap" },
|
|
48
50
|
{ name: "export", desc: "Export data to a backup file" },
|
|
49
51
|
{ name: "import", desc: "Restore data from a backup file" },
|
|
50
52
|
{ name: "billing", desc: "Manage your Ray subscription" },
|
|
51
53
|
{ name: "update", desc: "Update Ray to the latest version" },
|
|
52
54
|
{ name: "doctor", desc: "Check system health" },
|
|
53
|
-
{ name: "completions", desc: "Install shell completions" },
|
|
54
55
|
];
|
|
55
56
|
const SPENDING_PERIODS = ["this_month", "last_month", "last_30", "last_90"];
|
|
56
57
|
function generateZsh() {
|
package/dist/cli/doctor.js
CHANGED
|
@@ -137,7 +137,7 @@ export async function runDoctor() {
|
|
|
137
137
|
checks.push({ label: "Shell completions", status: "ok", detail: completionPath });
|
|
138
138
|
}
|
|
139
139
|
else {
|
|
140
|
-
checks.push({ label: "Shell completions", status: "warn", detail:
|
|
140
|
+
checks.push({ label: "Shell completions", status: "warn", detail: "Not installed" });
|
|
141
141
|
}
|
|
142
142
|
// ── Node version ──
|
|
143
143
|
const nodeVersion = process.version;
|
package/dist/cli/index.js
CHANGED
|
@@ -144,6 +144,24 @@ program
|
|
|
144
144
|
const { showAlerts } = await import("./commands.js");
|
|
145
145
|
showAlerts();
|
|
146
146
|
});
|
|
147
|
+
program
|
|
148
|
+
.command("bills")
|
|
149
|
+
.description("Show upcoming bills")
|
|
150
|
+
.option("-d, --days <number>", "Number of days ahead", "7")
|
|
151
|
+
.action(async (opts) => {
|
|
152
|
+
ensureConfigured();
|
|
153
|
+
const { showBills } = await import("./commands.js");
|
|
154
|
+
showBills(Number(opts.days));
|
|
155
|
+
});
|
|
156
|
+
program
|
|
157
|
+
.command("recap")
|
|
158
|
+
.description("Monthly spending recap")
|
|
159
|
+
.argument("[period]", "Period: this_month, last_month", "last_month")
|
|
160
|
+
.action(async (period) => {
|
|
161
|
+
ensureConfigured();
|
|
162
|
+
const { showRecap } = await import("./commands.js");
|
|
163
|
+
showRecap(period);
|
|
164
|
+
});
|
|
147
165
|
program
|
|
148
166
|
.command("export")
|
|
149
167
|
.description("Export user data (goals, budgets, memories, context) to a backup file")
|
|
@@ -168,7 +186,7 @@ program
|
|
|
168
186
|
.action(async () => {
|
|
169
187
|
ensureConfigured();
|
|
170
188
|
if (!useManaged()) {
|
|
171
|
-
console.log("You're using
|
|
189
|
+
console.log("You're using your own keys. No subscription to manage.");
|
|
172
190
|
return;
|
|
173
191
|
}
|
|
174
192
|
const open = (await import("open")).default;
|
|
@@ -217,13 +235,6 @@ program
|
|
|
217
235
|
const { seedDemoDb } = await import("../demo/seed.js");
|
|
218
236
|
seedDemoDb(demoPath);
|
|
219
237
|
});
|
|
220
|
-
program
|
|
221
|
-
.command("completions")
|
|
222
|
-
.description("Install shell completions")
|
|
223
|
-
.action(async () => {
|
|
224
|
-
const { installCompletions } = await import("./completions.js");
|
|
225
|
-
installCompletions();
|
|
226
|
-
});
|
|
227
238
|
function ensureConfigured() {
|
|
228
239
|
if (isDemoMode)
|
|
229
240
|
return;
|
|
@@ -248,13 +259,14 @@ program.configureHelp({
|
|
|
248
259
|
{ name: "goals", desc: "Show financial goals" },
|
|
249
260
|
{ name: "score", desc: "Show daily financial score and streaks" },
|
|
250
261
|
{ name: "alerts", desc: "Show financial alerts" },
|
|
262
|
+
{ name: "bills", desc: "Show upcoming bills" },
|
|
263
|
+
{ name: "recap", desc: "Monthly spending recap" },
|
|
251
264
|
{ name: "export", desc: "Export data to a backup file" },
|
|
252
265
|
{ name: "import", desc: "Restore data from a backup file" },
|
|
253
266
|
{ name: "billing", desc: "Manage your Ray subscription" },
|
|
254
267
|
{ name: "update", desc: "Update Ray to the latest version" },
|
|
255
268
|
{ name: "doctor", desc: "Check system health" },
|
|
256
269
|
{ name: "demo", desc: "Seed a demo database with fake data" },
|
|
257
|
-
{ name: "completions", desc: "Install shell completions" },
|
|
258
270
|
]),
|
|
259
271
|
});
|
|
260
272
|
import("./updater.js").then(m => m.checkForUpdate(version)).catch(() => { });
|
package/dist/daily-sync.js
CHANGED
|
@@ -145,7 +145,7 @@ export async function runDailySync(db) {
|
|
|
145
145
|
? db.prepare(`UPDATE transactions SET category = ?, subcategory = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.target_subcategory, rule.match_pattern, rule.target_category)
|
|
146
146
|
: db.prepare(`UPDATE transactions SET category = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.match_pattern, rule.target_category);
|
|
147
147
|
if (result.changes > 0) {
|
|
148
|
-
console.log(` Recategorized ${result.changes} txn(s): ${rule.label}`);
|
|
148
|
+
console.log(` Recategorized ${result.changes} txn(s): ${rule.label || rule.match_pattern}`);
|
|
149
149
|
totalRecat += result.changes;
|
|
150
150
|
}
|
|
151
151
|
}
|
package/dist/queries/index.js
CHANGED
|
@@ -406,6 +406,15 @@ export function categoryLabel(cat) {
|
|
|
406
406
|
GOVERNMENT_AND_NON_PROFIT: "Gov/Nonprofit",
|
|
407
407
|
MEDICAL: "Medical",
|
|
408
408
|
BANK_FEES: "Bank Fees",
|
|
409
|
+
EDUCATION: "Education",
|
|
410
|
+
INSURANCE: "Insurance",
|
|
411
|
+
BUSINESS: "Business",
|
|
412
|
+
INCOME: "Income",
|
|
413
|
+
TRANSFER_IN: "Transfer In",
|
|
414
|
+
TRANSFER_OUT: "Transfer Out",
|
|
415
|
+
HOME_IMPROVEMENT: "Home Improvement",
|
|
416
|
+
TRAVEL: "Travel",
|
|
417
|
+
OTHER: "Other",
|
|
409
418
|
};
|
|
410
|
-
return labels[cat] || cat;
|
|
419
|
+
return labels[cat] || cat.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(" ");
|
|
411
420
|
}
|