spendwise 1.0.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/LICENSE +21 -0
- package/README.md +119 -0
- package/package.json +62 -0
- package/src/main/api-core.js +231 -0
- package/src/main/auth.js +29 -0
- package/src/main/backup.js +48 -0
- package/src/main/db-open.js +77 -0
- package/src/main/ipc.js +187 -0
- package/src/main/main.js +125 -0
- package/src/main/migrate.js +181 -0
- package/src/main/storage/local.js +55 -0
- package/src/main/update-check.js +99 -0
- package/src/main/webserver.js +294 -0
- package/src/preload.cjs +27 -0
- package/src/renderer/assets/icon.ico +0 -0
- package/src/renderer/assets/icon.png +0 -0
- package/src/renderer/css/app.css +2 -0
- package/src/renderer/fonts/roca.otf +0 -0
- package/src/renderer/fonts/roca.woff +0 -0
- package/src/renderer/fonts/roca.woff2 +0 -0
- package/src/renderer/index.html +1136 -0
- package/src/renderer/js/app.js +185 -0
- package/src/renderer/js/boot.js +4 -0
- package/src/renderer/js/charts.js +116 -0
- package/src/renderer/js/components.js +39 -0
- package/src/renderer/js/helpers.js +76 -0
- package/src/renderer/js/views/insights.js +259 -0
- package/src/renderer/js/views/month.js +687 -0
- package/src/renderer/js/views/settings.js +353 -0
- package/src/renderer/js/web-api.js +50 -0
- package/src/renderer/manifest.json +18 -0
- package/src/renderer/vendor/alpine-collapse.min.js +1 -0
- package/src/renderer/vendor/alpine.min.js +5 -0
- package/src/renderer/vendor/apexcharts.min.js +38 -0
- package/src/server/cli.mjs +164 -0
- package/src/shared/engine.js +728 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
/* global Alpine, FinEngine, Charts, fmt, showToast, confirmDialog, unwrap */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
function monthView () {
|
|
5
|
+
return {
|
|
6
|
+
tagEditorFor: null, // field id with the tag editor open
|
|
7
|
+
autoEditorFor: null, // field id with the auto-fund editor open
|
|
8
|
+
schedEditorFor: null, // field id with the goal-schedule editor open
|
|
9
|
+
newTag: '',
|
|
10
|
+
// groupId → bool, persisted per machine so collapse choices survive
|
|
11
|
+
// reloads and view switches (monthView is re-created by x-if)
|
|
12
|
+
collapsed: (() => {
|
|
13
|
+
try { return JSON.parse(localStorage.getItem('finances.collapsedGroups')) || {}; }
|
|
14
|
+
catch { return {}; }
|
|
15
|
+
})(),
|
|
16
|
+
budgetOptions: [], // shared dropdown options, refreshed by one watcher
|
|
17
|
+
knownTags: [], // tag suggestions, computed when a tag editor opens
|
|
18
|
+
tagSuggestOpen: false, // suggestion dropdown under the tag input
|
|
19
|
+
tagSuggestIndex: -1, // highlighted suggestion (-1 = none)
|
|
20
|
+
tagSuggestPos: { x: 0, y: 0 }, // fixed-position coords (group cards clip overflow)
|
|
21
|
+
rowMenuFor: null, // field id with the mobile ⋮ actions menu open
|
|
22
|
+
rowMenuPos: { x: 0, y: 0 },
|
|
23
|
+
linkedMap: {}, // envelopeFieldId → linked expense total, one watcher
|
|
24
|
+
_optCache: {},
|
|
25
|
+
history: [], // undo/redo snapshots of the current month
|
|
26
|
+
historyIndex: -1,
|
|
27
|
+
|
|
28
|
+
// ------------------------------------------------------- accessors
|
|
29
|
+
|
|
30
|
+
get db () { return Alpine.store('data').db; },
|
|
31
|
+
get ui () { return Alpine.store('ui'); },
|
|
32
|
+
get key () { return this.ui.currentKey; },
|
|
33
|
+
get month () { return (this.db && this.db.months[this.key]) || null; },
|
|
34
|
+
get keys () { return FinEngine.monthKeys(this.db); },
|
|
35
|
+
get isLatest () { return this.keys[this.keys.length - 1] === this.key; },
|
|
36
|
+
get isClosed () { return this.month && this.month.status === 'closed'; },
|
|
37
|
+
get unlocked () { return this.ui.unlockedKeys.includes(this.key); },
|
|
38
|
+
get readonly () { return this.isClosed && !this.unlocked; },
|
|
39
|
+
get offline () { return !!this.ui.offline; },
|
|
40
|
+
// readonly = "this month is history"; frozen = "you can't edit right
|
|
41
|
+
// now" (history OR no server to save to). The closed-month notice keys
|
|
42
|
+
// off readonly; every input disables on frozen.
|
|
43
|
+
get frozen () { return this.readonly || this.offline; },
|
|
44
|
+
get label () { return this.key ? FinEngine.keyLabel(this.key) : ''; },
|
|
45
|
+
get isDirty () { return this.month && JSON.stringify(this.month) !== this.ui.savedSnapshot; },
|
|
46
|
+
|
|
47
|
+
get net () { return this.month ? FinEngine.monthNet(this.month) : 0; },
|
|
48
|
+
get currentSavings () { return this.month ? FinEngine.savings(this.month) : 0; },
|
|
49
|
+
get withBudgets () { return this.month ? FinEngine.savingsWithBudgets(this.month) : 0; },
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The envelope dropdown options are computed by ONE watcher and stored,
|
|
53
|
+
* instead of a getter - a getter would be re-evaluated independently by
|
|
54
|
+
* every expense row's <select> (each scan is envelopes × expenses),
|
|
55
|
+
* which made budget assignment take ~1s on a real-sized month.
|
|
56
|
+
*/
|
|
57
|
+
computeBudgetOptions () {
|
|
58
|
+
const opts = [{ id: '', label: '-' }];
|
|
59
|
+
if (!this.month) return opts;
|
|
60
|
+
for (const g of this.month.groups) {
|
|
61
|
+
if (!FinEngine.isEnvelopeKind(g.kind)) continue;
|
|
62
|
+
for (const f of g.fields) {
|
|
63
|
+
const left = FinEngine.num(f.avail) - FinEngine.effectiveSpent(this.month, f);
|
|
64
|
+
opts.push({ id: f.id, label: `${f.label || '(unnamed)'} (${fmt(left)} left)` });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return opts;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
get optionsSignature () { return JSON.stringify(this.computeBudgetOptions()); },
|
|
71
|
+
|
|
72
|
+
refreshBudgetOptions () {
|
|
73
|
+
const fresh = JSON.parse(this.optionsSignature);
|
|
74
|
+
// reuse unchanged option objects so keyed x-for skips their bindings
|
|
75
|
+
const next = fresh.map((o) => {
|
|
76
|
+
const hit = this._optCache[o.id];
|
|
77
|
+
return (hit && hit.label === o.label) ? hit : o;
|
|
78
|
+
});
|
|
79
|
+
this._optCache = Object.fromEntries(next.map((o) => [o.id, o]));
|
|
80
|
+
this.budgetOptions = next;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/** Income/expense groups an auto-fund rule may draw from. */
|
|
84
|
+
get sourceGroups () {
|
|
85
|
+
if (!this.month) return [];
|
|
86
|
+
return this.month.groups
|
|
87
|
+
.filter((g) => g.kind === 'income' || g.kind === 'expense')
|
|
88
|
+
.map((g) => ({ groupId: g.groupId, title: g.title }));
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Per-row helpers read the shared linkedMap instead of calling the
|
|
93
|
+
* engine's linkedSpent - otherwise every envelope row binding rescans
|
|
94
|
+
* every expense row on each change (the other half of the ~1s lag).
|
|
95
|
+
*/
|
|
96
|
+
get linkedSignature () {
|
|
97
|
+
const map = {};
|
|
98
|
+
if (this.month) {
|
|
99
|
+
for (const g of this.month.groups) {
|
|
100
|
+
if (g.kind !== 'expense') continue;
|
|
101
|
+
for (const f of g.fields) {
|
|
102
|
+
if (f.budgetId) map[f.budgetId] = (map[f.budgetId] || 0) + FinEngine.num(f.value);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return JSON.stringify(map);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Available includes the current month's own allotment (legacy behavior),
|
|
111
|
+
* so editing an envelope's monthly amount - or an auto-fund rate moving -
|
|
112
|
+
* must adjust "available" live by the difference. Capped goals are
|
|
113
|
+
* excluded (their top-up is bounded by the target and lands at close-out).
|
|
114
|
+
*/
|
|
115
|
+
get rateSignature () {
|
|
116
|
+
const map = {};
|
|
117
|
+
if (this.month) {
|
|
118
|
+
for (const g of this.month.groups) {
|
|
119
|
+
if (!FinEngine.isEnvelopeKind(g.kind)) continue;
|
|
120
|
+
for (const f of g.fields) {
|
|
121
|
+
if (g.kind === 'goal' && FinEngine.num(f.target) > 0) continue;
|
|
122
|
+
map[f.id] = FinEngine.fieldValue(this.month, f);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return JSON.stringify(map);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
refreshRateCache () {
|
|
130
|
+
this._rateCache = JSON.parse(this.rateSignature);
|
|
131
|
+
this._schedCache = JSON.parse(this.scheduleSignature);
|
|
132
|
+
this._rateCtx = this.key + '|' + (this.db ? this.db.meta.rev : 0);
|
|
133
|
+
this._schedCtx = this._rateCtx;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scheduled goals materialize their monthly deposit whenever the
|
|
138
|
+
* schedule inputs (target, due date, frequency) change: the deposit is
|
|
139
|
+
* fixed for the month, so spending mid-cycle doesn't accelerate it -
|
|
140
|
+
* the NEXT month recalculates from the new headroom.
|
|
141
|
+
*/
|
|
142
|
+
get scheduleSignature () {
|
|
143
|
+
const map = {};
|
|
144
|
+
if (this.month) {
|
|
145
|
+
for (const g of this.month.groups) {
|
|
146
|
+
if (g.kind !== 'goal') continue;
|
|
147
|
+
for (const f of g.fields) {
|
|
148
|
+
map[f.id] = { t: FinEngine.num(f.target), d: f.dueKey || '', q: FinEngine.num(f.freqMonths) };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return JSON.stringify(map);
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
applyScheduleChanges () {
|
|
156
|
+
const ctx = this.key + '|' + (this.db ? this.db.meta.rev : 0);
|
|
157
|
+
const map = JSON.parse(this.scheduleSignature);
|
|
158
|
+
if (ctx !== this._schedCtx || !this._schedCache) {
|
|
159
|
+
this._schedCache = map;
|
|
160
|
+
this._schedCtx = ctx;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const [id, s] of Object.entries(map)) {
|
|
164
|
+
const old = this._schedCache[id];
|
|
165
|
+
// unseen id in the same ctx = field born this flush - include it
|
|
166
|
+
const changed = old ? (old.t !== s.t || old.d !== s.d || old.q !== s.q) : true;
|
|
167
|
+
if (!changed || !s.d || !(s.t > 0)) continue;
|
|
168
|
+
const field = FinEngine.findField(this.month, id);
|
|
169
|
+
if (!field) continue;
|
|
170
|
+
const base = Math.max(0, FinEngine.num(field.avail) - FinEngine.num(field.value));
|
|
171
|
+
const deposit = FinEngine.scheduledAmount(this.key, field, base);
|
|
172
|
+
field.value = deposit;
|
|
173
|
+
field.avail = base + deposit;
|
|
174
|
+
}
|
|
175
|
+
this._schedCache = map;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
applyRateDeltas () {
|
|
179
|
+
const ctx = this.key + '|' + (this.db ? this.db.meta.rev : 0);
|
|
180
|
+
const map = JSON.parse(this.rateSignature);
|
|
181
|
+
if (ctx !== this._rateCtx || !this._rateCache) {
|
|
182
|
+
// month switched or data replaced - balances are already correct
|
|
183
|
+
this._rateCache = map;
|
|
184
|
+
this._rateCtx = ctx;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (const [id, rate] of Object.entries(map)) {
|
|
188
|
+
const old = this._rateCache[id];
|
|
189
|
+
if (old === undefined || old === rate) continue;
|
|
190
|
+
const field = FinEngine.findField(this.month, id);
|
|
191
|
+
if (field) field.avail = FinEngine.num(field.avail) + (rate - old);
|
|
192
|
+
}
|
|
193
|
+
this._rateCache = map;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/** Mobile row-actions menu (⋮): fixed-positioned - group cards clip
|
|
197
|
+
overflow - and kept on-screen for rows near the right edge. */
|
|
198
|
+
toggleRowMenu (field, e) {
|
|
199
|
+
if (this.rowMenuFor === field.id) { this.rowMenuFor = null; return; }
|
|
200
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
201
|
+
this.rowMenuPos = {
|
|
202
|
+
x: Math.max(8, Math.min(r.left, window.innerWidth - 232)),
|
|
203
|
+
y: r.bottom + 4,
|
|
204
|
+
};
|
|
205
|
+
this.rowMenuFor = field.id;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
toggleCollapsed (groupId) {
|
|
209
|
+
this.collapsed[groupId] = !this.collapsed[groupId];
|
|
210
|
+
localStorage.setItem('finances.collapsedGroups', JSON.stringify(this.collapsed));
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
groupTotal (group) { return FinEngine.groupTotal(this.month, group); },
|
|
214
|
+
linked (field) { return this.linkedMap[field.id] || 0; },
|
|
215
|
+
effSpent (field) { return FinEngine.num(field.spent) + this.linked(field); },
|
|
216
|
+
overBudget (field) { return this.effSpent(field) > FinEngine.num(field.avail); },
|
|
217
|
+
progress (field) { return FinEngine.goalProgress(this.month, field); },
|
|
218
|
+
envelopeLeft (field) { return FinEngine.num(field.avail) - this.effSpent(field); },
|
|
219
|
+
// fuel gauge: % of the envelope still unspent this month.
|
|
220
|
+
// untouched envelopes show no gauge at all.
|
|
221
|
+
health (field) {
|
|
222
|
+
const avail = FinEngine.num(field.avail);
|
|
223
|
+
const spent = this.effSpent(field);
|
|
224
|
+
const left = avail - spent;
|
|
225
|
+
const pct = avail > 0 ? Math.max(0, Math.min(100, (left / avail) * 100)) : 0;
|
|
226
|
+
return { pct, empty: left <= 0, show: spent > 0 };
|
|
227
|
+
},
|
|
228
|
+
// NOTE: do not name helpers after Object.prototype members (valueOf,
|
|
229
|
+
// toString…) - inside Alpine's `with`-based expression scope they
|
|
230
|
+
// resolve to the prototype method of the nearest scope object.
|
|
231
|
+
rateOf (field) { return FinEngine.fieldValue(this.month, field); },
|
|
232
|
+
contrib (group, field) { return FinEngine.contribution(this.month, field, group.kind); },
|
|
233
|
+
isAuto (field) { return FinEngine.hasAuto(field); },
|
|
234
|
+
isSched (group, field) { return group.kind === 'goal' && !!field.dueKey && FinEngine.num(field.target) > 0; },
|
|
235
|
+
|
|
236
|
+
groupHint (kind) {
|
|
237
|
+
return {
|
|
238
|
+
income: 'All income for the month. 📌 Pin recurring sources so they carry forward.',
|
|
239
|
+
envelope: 'Little savings accounts: “monthly” is added at each close-out. Spending flows in automatically from expense rows assigned to the envelope. Use % to auto-fund from a percentage of other groups.',
|
|
240
|
+
goal: 'Envelope budgets with a cap - ideal for recurring bills. Use 🕓 to set a due date and the monthly deposit is calculated for you and re-spread each cycle.',
|
|
241
|
+
expense: 'Monthly or one-time expenses. 📌 Pin recurring ones. Assign one to an envelope and it spends from that envelope’s balance instead.',
|
|
242
|
+
}[kind] || '';
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// ------------------------------------------------------ life cycle
|
|
246
|
+
|
|
247
|
+
init () {
|
|
248
|
+
// the header's month picker lives outside this component
|
|
249
|
+
this.ui.monthGoto = (k) => this.goto(k);
|
|
250
|
+
this.$watch('key', () => {
|
|
251
|
+
this.tagEditorFor = null;
|
|
252
|
+
this.autoEditorFor = null;
|
|
253
|
+
this.schedEditorFor = null;
|
|
254
|
+
this.resetHistory();
|
|
255
|
+
this.renderChart();
|
|
256
|
+
});
|
|
257
|
+
this.$watch('currentSavings', () => this.scheduleChart());
|
|
258
|
+
this.$watch('optionsSignature', () => this.refreshBudgetOptions());
|
|
259
|
+
this.$watch('linkedSignature', (sig) => { this.linkedMap = JSON.parse(sig); });
|
|
260
|
+
this.$watch('rateSignature', () => this.applyRateDeltas());
|
|
261
|
+
this.$watch('scheduleSignature', () => this.applyScheduleChanges());
|
|
262
|
+
this.$watch('monthSignature', (sig) => this.recordHistory(sig));
|
|
263
|
+
this.linkedMap = JSON.parse(this.linkedSignature);
|
|
264
|
+
this.refreshBudgetOptions();
|
|
265
|
+
this.refreshRateCache();
|
|
266
|
+
this.resetHistory();
|
|
267
|
+
this.$nextTick(() => this.renderChart());
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
// -------------------------------------------------- undo / redo
|
|
271
|
+
|
|
272
|
+
get monthSignature () { return this.month ? JSON.stringify(this.month) : ''; },
|
|
273
|
+
|
|
274
|
+
resetHistory () {
|
|
275
|
+
clearTimeout(this._histTimer);
|
|
276
|
+
this.history = this.month ? [JSON.stringify(this.month)] : [];
|
|
277
|
+
this.historyIndex = this.history.length - 1;
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
recordHistory (sig) {
|
|
281
|
+
if (this._histSuppress || !sig) return;
|
|
282
|
+
clearTimeout(this._histTimer);
|
|
283
|
+
// group rapid keystrokes into one undo step
|
|
284
|
+
this._histTimer = setTimeout(() => {
|
|
285
|
+
if (this.history[this.historyIndex] === sig) return;
|
|
286
|
+
this.history = this.history.slice(0, this.historyIndex + 1);
|
|
287
|
+
this.history.push(sig);
|
|
288
|
+
if (this.history.length > 100) this.history.shift();
|
|
289
|
+
this.historyIndex = this.history.length - 1;
|
|
290
|
+
}, 350);
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
applyHistory (index) {
|
|
294
|
+
if (this.frozen || index < 0 || index >= this.history.length) return false;
|
|
295
|
+
clearTimeout(this._histTimer);
|
|
296
|
+
this._histSuppress = true;
|
|
297
|
+
this.historyIndex = index;
|
|
298
|
+
Object.assign(this.db.months, { [this.key]: JSON.parse(this.history[index]) });
|
|
299
|
+
this.refreshRateCache(); // reverted rates must not re-apply as avail deltas
|
|
300
|
+
this.$nextTick(() => { this._histSuppress = false; });
|
|
301
|
+
return true;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
undo () {
|
|
305
|
+
if (this.applyHistory(this.historyIndex - 1)) showToast('Undo', 'info', 1200);
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
redo () {
|
|
309
|
+
if (this.applyHistory(this.historyIndex + 1)) showToast('Redo', 'info', 1200);
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
hotkeys (e) {
|
|
313
|
+
if (this.ui.view !== 'month' || !(e.ctrlKey || e.metaKey)) return;
|
|
314
|
+
const k = (e.key || '').toLowerCase();
|
|
315
|
+
if (k === 's') { e.preventDefault(); this.save(); }
|
|
316
|
+
else if (k === 'z' && e.shiftKey) { e.preventDefault(); this.redo(); }
|
|
317
|
+
else if (k === 'z') { e.preventDefault(); this.undo(); }
|
|
318
|
+
else if (k === 'y') { e.preventDefault(); this.redo(); }
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
renderChart () {
|
|
322
|
+
if (!this.month) return;
|
|
323
|
+
Charts.savingsLine(this.$refs.savingsChart, this.db, this.key, this.currentSavings);
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
scheduleChart () {
|
|
327
|
+
clearTimeout(this._chartTimer);
|
|
328
|
+
this._chartTimer = setTimeout(() => this.renderChart(), 400);
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
snapshot () { this.ui.savedSnapshot = JSON.stringify(this.month); },
|
|
332
|
+
|
|
333
|
+
// ------------------------------------------------------ navigation
|
|
334
|
+
|
|
335
|
+
/** Two frames: one to get the overlay on screen, one to let it paint. */
|
|
336
|
+
painted () {
|
|
337
|
+
return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
async goto (key) {
|
|
341
|
+
if (!key || key === this.key || !this.db.months[key]) return;
|
|
342
|
+
if (this.isDirty) {
|
|
343
|
+
const okDiscard = await confirmDialog({
|
|
344
|
+
title: 'Discard unsaved changes?',
|
|
345
|
+
body: `You have unsaved edits in ${this.label}. Leaving now discards them.`,
|
|
346
|
+
confirmText: 'Discard changes', danger: true,
|
|
347
|
+
});
|
|
348
|
+
if (!okDiscard) return;
|
|
349
|
+
Object.assign(this.db.months, { [this.key]: JSON.parse(this.ui.savedSnapshot) });
|
|
350
|
+
}
|
|
351
|
+
this.ui.unlockedKeys = this.ui.unlockedKeys.filter((k) => k !== this.key);
|
|
352
|
+
|
|
353
|
+
// Rendering a month's rows blocks the main thread, so swap to
|
|
354
|
+
// "loading…" BEFORE the work starts — set the flag, wait for a real
|
|
355
|
+
// paint, and only then change month. (Hiding main also spares the
|
|
356
|
+
// browser laying out rows nobody can see yet.)
|
|
357
|
+
this.ui.switching = true;
|
|
358
|
+
await this.painted();
|
|
359
|
+
this.ui.currentKey = key;
|
|
360
|
+
this.snapshot();
|
|
361
|
+
await this.painted(); // the new month has rendered by now
|
|
362
|
+
this.ui.switching = false;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async unlock () {
|
|
366
|
+
const okUnlock = await confirmDialog({
|
|
367
|
+
title: `Edit ${this.label}?`,
|
|
368
|
+
body: 'This month is closed. You can edit it, but saving will recompute every later month - '
|
|
369
|
+
+ 'envelope balances, starting savings, and the savings history - so the books stay consistent. '
|
|
370
|
+
+ 'A backup is taken automatically before anything is rewritten.',
|
|
371
|
+
confirmText: 'Unlock for editing', danger: true,
|
|
372
|
+
});
|
|
373
|
+
if (okUnlock) this.ui.unlockedKeys.push(this.key);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
cancelEditing () {
|
|
377
|
+
Object.assign(this.db.months, { [this.key]: JSON.parse(this.ui.savedSnapshot) });
|
|
378
|
+
this.ui.unlockedKeys = this.ui.unlockedKeys.filter((k) => k !== this.key);
|
|
379
|
+
this.refreshRateCache(); // reverted rates must not re-apply as deltas
|
|
380
|
+
this.resetHistory();
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------- fields
|
|
384
|
+
|
|
385
|
+
addField (group, index) {
|
|
386
|
+
const f = FinEngine.newField(group.kind);
|
|
387
|
+
group.fields.splice(index === undefined ? group.fields.length : index + 1, 0, f);
|
|
388
|
+
this.closeEditors(); // a new row means you're done with the old one
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The tag / auto-fund / schedule editors belong to one row. Rather than
|
|
393
|
+
* make people dismiss them, they close as soon as attention moves on —
|
|
394
|
+
* focus landing in another row, or a new row being added.
|
|
395
|
+
*/
|
|
396
|
+
closeEditors (exceptFieldId = null) {
|
|
397
|
+
for (const k of ['tagEditorFor', 'autoEditorFor', 'schedEditorFor', 'rowMenuFor']) {
|
|
398
|
+
if (this[k] && this[k] !== exceptFieldId) this[k] = null;
|
|
399
|
+
}
|
|
400
|
+
this.tagSuggestOpen = false;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/** focusin bubbles from any control in the row, so one handler per row. */
|
|
404
|
+
rowFocused (field) {
|
|
405
|
+
this.closeEditors(field.id);
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
async removeField (group, field) {
|
|
409
|
+
if (FinEngine.isEnvelopeKind(group.kind)) {
|
|
410
|
+
const left = FinEngine.returnableBalance(this.month, field, group.kind);
|
|
411
|
+
if (left > 0) {
|
|
412
|
+
// deleting an envelope with money in it: ask what to do with it
|
|
413
|
+
const answer = await confirmDialog({
|
|
414
|
+
title: `Remove “${field.label || 'envelope'}”?`,
|
|
415
|
+
body: `${fmt(left)} of it was committed in earlier months - return that to Income as `
|
|
416
|
+
+ `“Returned from budget: ${field.label || 'unnamed'}” to keep the ledger accurate, `
|
|
417
|
+
+ 'or discard it so the balance disappears from your books. '
|
|
418
|
+
+ '(This month’s contribution simply stops either way.)',
|
|
419
|
+
confirmText: 'Return to Income',
|
|
420
|
+
altText: 'Discard',
|
|
421
|
+
});
|
|
422
|
+
if (answer === false) return;
|
|
423
|
+
if (answer === true) {
|
|
424
|
+
const res = FinEngine.emptyEnvelope(this.month, field.id);
|
|
425
|
+
if (res && res.returned) showToast(`${fmt(res.remaining)} returned to Income`, 'success');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// 'alt' → fall through and discard
|
|
429
|
+
}
|
|
430
|
+
for (const f of FinEngine.allFields(this.month)) {
|
|
431
|
+
if (f.budgetId === field.id) f.budgetId = null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
group.fields.splice(group.fields.indexOf(field), 1);
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// ------------------------------------------------------- auto-fund
|
|
438
|
+
|
|
439
|
+
toggleAutoEditor (field) {
|
|
440
|
+
this.autoEditorFor = this.autoEditorFor === field.id ? null : field.id;
|
|
441
|
+
this.tagEditorFor = null;
|
|
442
|
+
this.schedEditorFor = null;
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
toggleSchedEditor (field) {
|
|
446
|
+
this.schedEditorFor = this.schedEditorFor === field.id ? null : field.id;
|
|
447
|
+
this.tagEditorFor = null;
|
|
448
|
+
this.autoEditorFor = null;
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
async clearSchedule (field) {
|
|
452
|
+
const okClear = await confirmDialog({
|
|
453
|
+
title: 'Remove the schedule?',
|
|
454
|
+
body: 'The goal keeps its balance and target, but the monthly deposit stops being calculated - '
|
|
455
|
+
+ 'set it manually like a plain envelope budget with a cap.',
|
|
456
|
+
confirmText: 'Remove schedule',
|
|
457
|
+
});
|
|
458
|
+
if (okClear) { field.dueKey = null; this.schedEditorFor = null; }
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
ensureAuto (field) {
|
|
462
|
+
if (!field.auto) field.auto = { pct: 0, groups: [] };
|
|
463
|
+
return field.auto;
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
setAutoPct (field, pct) {
|
|
467
|
+
this.ensureAuto(field).pct = Math.max(0, Math.min(100, FinEngine.num(pct)));
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
toggleAutoGroup (field, groupId) {
|
|
471
|
+
const auto = this.ensureAuto(field);
|
|
472
|
+
auto.groups = auto.groups.includes(groupId)
|
|
473
|
+
? auto.groups.filter((id) => id !== groupId)
|
|
474
|
+
: [...auto.groups, groupId];
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
clearAuto (field) {
|
|
478
|
+
field.auto = null;
|
|
479
|
+
this.autoEditorFor = null;
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
// ------------------------------------------------------------ tags
|
|
483
|
+
|
|
484
|
+
toggleTagEditor (field) {
|
|
485
|
+
this.tagEditorFor = this.tagEditorFor === field.id ? null : field.id;
|
|
486
|
+
this.autoEditorFor = null;
|
|
487
|
+
this.schedEditorFor = null;
|
|
488
|
+
this.newTag = '';
|
|
489
|
+
this.tagSuggestOpen = false;
|
|
490
|
+
this.tagSuggestIndex = -1;
|
|
491
|
+
// computed here (imperatively, outside any effect) so the dropdown
|
|
492
|
+
// never becomes a reactive dependency on every field of every month
|
|
493
|
+
if (this.tagEditorFor) this.knownTags = this.allKnownTags();
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
/** Existing tags matching the typed text (substring), minus the ones
|
|
497
|
+
already on the field - so near-duplicates like "grocery" surface
|
|
498
|
+
the existing "groceries" before it gets created. */
|
|
499
|
+
tagSuggestions (field) {
|
|
500
|
+
const q = this.newTag.trim().toLowerCase().replace(/\s+/g, '-');
|
|
501
|
+
return this.knownTags
|
|
502
|
+
.filter((t) => !field.tags.includes(t) && (!q || t.includes(q)))
|
|
503
|
+
.slice(0, 5);
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
openTagSuggest (e) {
|
|
507
|
+
const r = e.target.getBoundingClientRect();
|
|
508
|
+
this.tagSuggestPos = { x: r.left, y: r.bottom + 4 };
|
|
509
|
+
this.tagSuggestOpen = true;
|
|
510
|
+
this.tagSuggestIndex = -1;
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
pickTag (field, tag) {
|
|
514
|
+
this.newTag = tag;
|
|
515
|
+
this.addTag(field);
|
|
516
|
+
this.tagSuggestIndex = -1;
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
tagKeydown (field, e) {
|
|
520
|
+
const list = this.tagSuggestions(field);
|
|
521
|
+
if (e.key === 'ArrowDown') {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
this.tagSuggestOpen = true;
|
|
524
|
+
this.tagSuggestIndex = Math.min(this.tagSuggestIndex + 1, list.length - 1);
|
|
525
|
+
} else if (e.key === 'ArrowUp') {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
this.tagSuggestIndex = Math.max(this.tagSuggestIndex - 1, -1);
|
|
528
|
+
} else if (e.key === 'Enter' || e.key === ',') {
|
|
529
|
+
e.preventDefault();
|
|
530
|
+
if (this.tagSuggestIndex >= 0 && list[this.tagSuggestIndex]) this.pickTag(field, list[this.tagSuggestIndex]);
|
|
531
|
+
else this.addTag(field);
|
|
532
|
+
this.tagSuggestIndex = -1;
|
|
533
|
+
} else if (e.key === 'Escape') {
|
|
534
|
+
this.tagSuggestOpen = false;
|
|
535
|
+
this.tagSuggestIndex = -1;
|
|
536
|
+
} else {
|
|
537
|
+
this.tagSuggestIndex = -1; // typing resets the highlight
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
addTag (field) {
|
|
542
|
+
const tag = this.newTag.trim().toLowerCase().replace(/\s+/g, '-');
|
|
543
|
+
this.newTag = '';
|
|
544
|
+
if (!tag || field.tags.includes(tag)) return;
|
|
545
|
+
this.syncTag(field, [tag], null);
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
removeTag (field, tag) {
|
|
549
|
+
this.syncTag(field, [], tag);
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Tags are a field-lifetime property: the operation applies to every
|
|
554
|
+
* month the field appears in, past included - mirrored locally and
|
|
555
|
+
* persisted immediately (tags never affect any totals).
|
|
556
|
+
*/
|
|
557
|
+
async syncTag (field, add, remove) {
|
|
558
|
+
if (this.offline) return; // tags persist server-side immediately
|
|
559
|
+
const wasDirty = this.isDirty;
|
|
560
|
+
const monthsTouched = FinEngine.applyTagsAcrossMonths(this.db, field.id, add, remove);
|
|
561
|
+
try {
|
|
562
|
+
const res = unwrap(await window.api.applyTagsEverywhere({
|
|
563
|
+
fieldId: field.id, add, remove, expectedRev: this.db.meta.rev,
|
|
564
|
+
}));
|
|
565
|
+
this.db.meta.rev = res.rev;
|
|
566
|
+
// rev changed: re-sync the delta-watcher contexts or the next
|
|
567
|
+
// rate/schedule change would be swallowed by the ctx guard
|
|
568
|
+
this.refreshRateCache();
|
|
569
|
+
// the tag change itself is already persisted - don't leave the
|
|
570
|
+
// month looking dirty unless it already was
|
|
571
|
+
if (!wasDirty) this.snapshot();
|
|
572
|
+
if (monthsTouched > 1) {
|
|
573
|
+
showToast(`${add.length ? '#' + add[0] : 'Tag'} ${add.length ? 'applied to' : 'removed from'} ${monthsTouched} months`, 'info', 2500);
|
|
574
|
+
}
|
|
575
|
+
} catch (e) {
|
|
576
|
+
showToast(e.message, 'error', 7000);
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
|
|
580
|
+
allKnownTags () {
|
|
581
|
+
const tags = new Set();
|
|
582
|
+
for (const k of this.keys) {
|
|
583
|
+
for (const f of FinEngine.allFields(this.db.months[k])) {
|
|
584
|
+
(f.tags || []).forEach((t) => tags.add(t));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return [...tags].sort();
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
// ----------------------------------------------------- persistence
|
|
591
|
+
|
|
592
|
+
async save () {
|
|
593
|
+
if (this.frozen) return;
|
|
594
|
+
if (this.isClosed) return this.saveClosed();
|
|
595
|
+
try {
|
|
596
|
+
const res = unwrap(await window.api.saveMonth({
|
|
597
|
+
key: this.key,
|
|
598
|
+
month: FinEngine.clone(this.month),
|
|
599
|
+
// the month as this session loaded it - the merge base
|
|
600
|
+
// when another session saved in the meantime
|
|
601
|
+
base: this.ui.savedSnapshot ? JSON.parse(this.ui.savedSnapshot) : null,
|
|
602
|
+
expectedRev: this.db.meta.rev,
|
|
603
|
+
}));
|
|
604
|
+
if (res.merged) {
|
|
605
|
+
// another session saved since we loaded: the server did a
|
|
606
|
+
// field-level three-way merge and recomputed - adopt its state
|
|
607
|
+
Alpine.store('data').db = res.data;
|
|
608
|
+
this.snapshot();
|
|
609
|
+
this.refreshRateCache();
|
|
610
|
+
this.resetHistory();
|
|
611
|
+
this.renderChart();
|
|
612
|
+
const n = (res.conflicts || []).length;
|
|
613
|
+
showToast(n
|
|
614
|
+
? `Saved - merged with another session's edits (${n} overlapping ${n === 1 ? 'change' : 'changes'}, yours kept)`
|
|
615
|
+
: 'Saved - merged with another session\'s edits', 'info', 6000);
|
|
616
|
+
} else {
|
|
617
|
+
this.db.meta.rev = res.rev;
|
|
618
|
+
this.db.savingsHistory[this.key] = FinEngine.num(this.month.startingSavings);
|
|
619
|
+
this.snapshot();
|
|
620
|
+
this.refreshRateCache();
|
|
621
|
+
showToast('Month saved', 'success');
|
|
622
|
+
}
|
|
623
|
+
} catch (e) {
|
|
624
|
+
showToast(e.message, 'error', 7000);
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
async saveClosed () {
|
|
629
|
+
const okSave = await confirmDialog({
|
|
630
|
+
title: 'Save & recompute forward?',
|
|
631
|
+
body: `Saving ${this.label} rewrites the derived numbers of every later month. `
|
|
632
|
+
+ 'A backup of the current data is taken first.',
|
|
633
|
+
confirmText: 'Save & Recompute', danger: true,
|
|
634
|
+
});
|
|
635
|
+
if (!okSave) return;
|
|
636
|
+
try {
|
|
637
|
+
const res = unwrap(await window.api.saveClosedMonth({
|
|
638
|
+
key: this.key,
|
|
639
|
+
month: FinEngine.clone(this.month),
|
|
640
|
+
expectedRev: this.db.meta.rev,
|
|
641
|
+
}));
|
|
642
|
+
Alpine.store('data').db = res.data;
|
|
643
|
+
this.ui.unlockedKeys = this.ui.unlockedKeys.filter((k) => k !== this.key);
|
|
644
|
+
this.snapshot();
|
|
645
|
+
this.refreshRateCache();
|
|
646
|
+
this.resetHistory();
|
|
647
|
+
this.renderChart();
|
|
648
|
+
if (res.changes.length) {
|
|
649
|
+
this.ui.recomputeChanges = res.changes;
|
|
650
|
+
this.ui.modals.recompute = true;
|
|
651
|
+
} else {
|
|
652
|
+
showToast('Saved - no later months were affected', 'success');
|
|
653
|
+
}
|
|
654
|
+
} catch (e) {
|
|
655
|
+
showToast(e.message, 'error', 7000);
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
async closeOut () {
|
|
660
|
+
if (this.isClosed || !this.isLatest || this.offline) return;
|
|
661
|
+
const nextLabel = FinEngine.keyLabel(FinEngine.nextKey(this.key));
|
|
662
|
+
const okClose = await confirmDialog({
|
|
663
|
+
title: `Close out ${this.label}?`,
|
|
664
|
+
body: `Finalizes ${this.label} with ${fmt(this.currentSavings)} in savings, rolls pinned fields and `
|
|
665
|
+
+ `envelope balances forward, and starts ${nextLabel}. You can still edit it later.`,
|
|
666
|
+
confirmText: 'Close Out Month',
|
|
667
|
+
});
|
|
668
|
+
if (!okClose) return;
|
|
669
|
+
try {
|
|
670
|
+
const res = unwrap(await window.api.closeMonth({
|
|
671
|
+
key: this.key,
|
|
672
|
+
month: FinEngine.clone(this.month),
|
|
673
|
+
expectedRev: this.db.meta.rev,
|
|
674
|
+
}));
|
|
675
|
+
Alpine.store('data').db = res.data;
|
|
676
|
+
this.ui.currentKey = res.nextKey;
|
|
677
|
+
this.snapshot();
|
|
678
|
+
this.refreshRateCache();
|
|
679
|
+
this.resetHistory();
|
|
680
|
+
this.renderChart();
|
|
681
|
+
showToast(`${FinEngine.keyLabel(res.nextKey)} is ready`, 'success');
|
|
682
|
+
} catch (e) {
|
|
683
|
+
showToast(e.message, 'error', 7000);
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|