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,185 @@
|
|
|
1
|
+
/* global Alpine, FinEngine, showToast, unwrap */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
document.addEventListener('alpine:init', () => {
|
|
5
|
+
Alpine.store('data', {
|
|
6
|
+
loaded: false,
|
|
7
|
+
path: '',
|
|
8
|
+
version: '',
|
|
9
|
+
db: null,
|
|
10
|
+
// update check: { available, latest, url, unreachable, enabled }
|
|
11
|
+
update: { available: false, latest: null, url: '', checking: false, checked: false },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
Alpine.store('ui', {
|
|
15
|
+
view: 'month', // month | insights | settings
|
|
16
|
+
locked: false, // app-password lock screen (desktop only - web logs in at the server)
|
|
17
|
+
offline: false, // web only: no server → read-only until it's back
|
|
18
|
+
switching: false, // month change rendering: hides main, shows "loading…"
|
|
19
|
+
settingsDirty: false, // mirrored by settingsView for the unsaved-changes gate
|
|
20
|
+
currentKey: null,
|
|
21
|
+
unlockedKeys: [],
|
|
22
|
+
savedSnapshot: '',
|
|
23
|
+
monthGoto: null, // set by monthView: header picker → goto(key)
|
|
24
|
+
recomputeChanges: [],
|
|
25
|
+
importSummary: null,
|
|
26
|
+
importMap: { folder: '', groups: [] },
|
|
27
|
+
modals: {
|
|
28
|
+
recompute: false,
|
|
29
|
+
importMap: false,
|
|
30
|
+
importSummary: false,
|
|
31
|
+
backups: false,
|
|
32
|
+
confirm: false,
|
|
33
|
+
},
|
|
34
|
+
confirm: {
|
|
35
|
+
open: false, title: '', body: '', confirmText: 'Confirm',
|
|
36
|
+
cancelText: 'Cancel', altText: null, danger: false, resolve: null,
|
|
37
|
+
},
|
|
38
|
+
toast: { show: false, message: '', type: 'info', timeout: 3500 },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function appRoot () {
|
|
43
|
+
return {
|
|
44
|
+
async init () {
|
|
45
|
+
try {
|
|
46
|
+
const res = unwrap(await window.api.loadDb());
|
|
47
|
+
const data = Alpine.store('data');
|
|
48
|
+
data.db = res.data;
|
|
49
|
+
data.path = res.path;
|
|
50
|
+
data.version = res.version || '';
|
|
51
|
+
data.loaded = true;
|
|
52
|
+
|
|
53
|
+
const ui = Alpine.store('ui');
|
|
54
|
+
const keys = FinEngine.monthKeys(res.data);
|
|
55
|
+
const open = keys.filter((k) => res.data.months[k].status === 'open');
|
|
56
|
+
ui.currentKey = open.length ? open[open.length - 1] : keys[keys.length - 1];
|
|
57
|
+
ui.savedSnapshot = JSON.stringify(res.data.months[ui.currentKey]);
|
|
58
|
+
// desktop privacy lock; web clients authenticated at the server
|
|
59
|
+
ui.locked = !!(res.data.settings && res.data.settings.auth) && !window.IS_WEB;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
showToast('Failed to load data: ' + e.message, 'error', 12000);
|
|
62
|
+
}
|
|
63
|
+
this.watchConnection();
|
|
64
|
+
this.startPolling();
|
|
65
|
+
this.guardUnsaved();
|
|
66
|
+
this.checkUpdate(); // deliberately not awaited - never delays first paint
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ask the main/server process whether a newer release is tagged. Any
|
|
71
|
+
* failure is a non-event: no toast, no error, the app does not need
|
|
72
|
+
* GitHub. The nudge is a dot on the settings gear plus a line in
|
|
73
|
+
* Settings - never a modal in the way of the books.
|
|
74
|
+
*/
|
|
75
|
+
async checkUpdate ({ force = false } = {}) {
|
|
76
|
+
const data = Alpine.store('data');
|
|
77
|
+
data.update.checking = true;
|
|
78
|
+
try {
|
|
79
|
+
const res = unwrap(await window.api.checkUpdate({ force }));
|
|
80
|
+
data.update = {
|
|
81
|
+
...res,
|
|
82
|
+
checking: false,
|
|
83
|
+
checked: true,
|
|
84
|
+
available: !!res.available,
|
|
85
|
+
};
|
|
86
|
+
if (res.available) {
|
|
87
|
+
showToast(`Spend Wise ${res.latest} is available - see Settings`, 'info', 8000);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
data.update.checking = false;
|
|
91
|
+
data.update.checked = true;
|
|
92
|
+
data.update.unreachable = true;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async openReleases () {
|
|
97
|
+
try { await window.api.openReleases(); } catch { /* nothing to do */ }
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Nothing is written until you press Update, so closing or reloading
|
|
102
|
+
* with edits in flight would drop them silently.
|
|
103
|
+
*
|
|
104
|
+
* The web gets the browser's own leave-confirmation. Electron does NOT:
|
|
105
|
+
* a beforeunload handler that returns a value there just cancels the
|
|
106
|
+
* close without asking, so the desktop is gated in the main process
|
|
107
|
+
* instead (main.js), which calls the hook below.
|
|
108
|
+
*/
|
|
109
|
+
guardUnsaved () {
|
|
110
|
+
window.__unsavedSummary = () => {
|
|
111
|
+
const data = Alpine.store('data');
|
|
112
|
+
const ui = Alpine.store('ui');
|
|
113
|
+
if (!data.loaded || !data.db) return null;
|
|
114
|
+
const cur = ui.currentKey && data.db.months[ui.currentKey];
|
|
115
|
+
const monthDirty = !!cur && JSON.stringify(cur) !== ui.savedSnapshot;
|
|
116
|
+
if (!monthDirty && !ui.settingsDirty) return null;
|
|
117
|
+
const parts = [];
|
|
118
|
+
if (monthDirty) parts.push(FinEngine.keyLabel(ui.currentKey));
|
|
119
|
+
if (ui.settingsDirty) parts.push('Settings');
|
|
120
|
+
return parts.join(' and ');
|
|
121
|
+
};
|
|
122
|
+
if (!window.IS_WEB) return;
|
|
123
|
+
window.addEventListener('beforeunload', (e) => {
|
|
124
|
+
if (!window.__unsavedSummary()) return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.returnValue = ''; // required by older browsers to trigger the prompt
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A web client needs the server for every read and write, so losing it
|
|
132
|
+
* puts the app in read-only rather than letting edits pile up with
|
|
133
|
+
* nowhere to go. The desktop app talks to its own process - it is
|
|
134
|
+
* never offline, even with the network down.
|
|
135
|
+
*/
|
|
136
|
+
watchConnection () {
|
|
137
|
+
if (!window.IS_WEB) return;
|
|
138
|
+
const ui = Alpine.store('ui');
|
|
139
|
+
ui.offline = navigator.onLine === false;
|
|
140
|
+
// instant signal for a dropped network; the rev poll below is what
|
|
141
|
+
// catches "connected to wifi, but the server is gone"
|
|
142
|
+
window.addEventListener('offline', () => { ui.offline = true; });
|
|
143
|
+
window.addEventListener('online', () => { ui.offline = false; });
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Co-editing freshness: when another session bumps the rev while this
|
|
148
|
+
* one has no unsaved edits, quietly reload. With edits in flight we
|
|
149
|
+
* leave the screen alone - the merge happens at save time instead.
|
|
150
|
+
*/
|
|
151
|
+
startPolling () {
|
|
152
|
+
if (this._pollTimer || !window.api.rev) return;
|
|
153
|
+
this._pollTimer = setInterval(async () => {
|
|
154
|
+
const data = Alpine.store('data');
|
|
155
|
+
const ui = Alpine.store('ui');
|
|
156
|
+
if (!data.loaded || ui.locked) return;
|
|
157
|
+
let r;
|
|
158
|
+
try {
|
|
159
|
+
r = await window.api.rev();
|
|
160
|
+
} catch {
|
|
161
|
+
if (window.IS_WEB) ui.offline = true; // server unreachable
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (window.IS_WEB && r.ok) ui.offline = false;
|
|
165
|
+
try {
|
|
166
|
+
if (!r.ok || r.rev === data.db.meta.rev) return;
|
|
167
|
+
const cur = ui.currentKey && data.db.months[ui.currentKey];
|
|
168
|
+
if (cur && JSON.stringify(cur) !== ui.savedSnapshot) return; // dirty - don't clobber
|
|
169
|
+
const res = unwrap(await window.api.loadDb());
|
|
170
|
+
data.db = res.data;
|
|
171
|
+
if (!res.data.months[ui.currentKey]) {
|
|
172
|
+
const keys = FinEngine.monthKeys(res.data);
|
|
173
|
+
ui.currentKey = keys[keys.length - 1];
|
|
174
|
+
}
|
|
175
|
+
ui.savedSnapshot = JSON.stringify(res.data.months[ui.currentKey]);
|
|
176
|
+
window.dispatchEvent(new CustomEvent('db-refreshed'));
|
|
177
|
+
} catch { /* transient - next tick retries */ }
|
|
178
|
+
}, 20000);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
switchView (view) {
|
|
182
|
+
Alpine.store('ui').view = view;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* global ApexCharts, FinEngine */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const Charts = {
|
|
5
|
+
instances: {},
|
|
6
|
+
|
|
7
|
+
destroy (id) {
|
|
8
|
+
const inst = this.instances[id];
|
|
9
|
+
if (inst) {
|
|
10
|
+
// destroy() can throw when the chart's DOM was already torn down
|
|
11
|
+
// (the Insights view is x-if gated) - the map entry must go anyway
|
|
12
|
+
try { inst.destroy(); } catch (e) { /* detached DOM */ }
|
|
13
|
+
delete this.instances[id];
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Must run BEFORE the Insights DOM is torn down (view switch): an
|
|
19
|
+
* ApexCharts instance that outlives its element keeps a ResizeObserver
|
|
20
|
+
* and repaints its stale content over later renders (zombie chart).
|
|
21
|
+
*/
|
|
22
|
+
teardownInsights () {
|
|
23
|
+
['insights-donut', 'insights-bar', 'insights-savings',
|
|
24
|
+
'insights-income-donut', 'insights-income-bar'].forEach((id) => this.destroy(id));
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/** Savings history line: last 12 months plus a live "Now" point - the
|
|
28
|
+
longer view lives in Insights. */
|
|
29
|
+
savingsLine (el, db, currentKey, currentSavings, lastN = 12) {
|
|
30
|
+
const keys = Object.keys(db.savingsHistory || {}).sort()
|
|
31
|
+
.filter((k) => k <= currentKey)
|
|
32
|
+
.slice(-lastN);
|
|
33
|
+
const labels = keys.map((k) => FinEngine.keyLabel(k).slice(0, 3) + ' ' + k.slice(2, 4));
|
|
34
|
+
const values = keys.map((k) => FinEngine.num(db.savingsHistory[k]));
|
|
35
|
+
labels.push('Now');
|
|
36
|
+
values.push(FinEngine.num(currentSavings));
|
|
37
|
+
|
|
38
|
+
// fast path: same months, only the values moved → update in place
|
|
39
|
+
// (a full destroy/render costs hundreds of ms and ran on every edit)
|
|
40
|
+
const sig = labels.join('|');
|
|
41
|
+
if (this.instances.savings && this._savingsSig === sig && this._savingsEl === el) {
|
|
42
|
+
this.instances.savings.updateSeries([{ name: 'Savings', data: values }], false);
|
|
43
|
+
return this.instances.savings;
|
|
44
|
+
}
|
|
45
|
+
this._savingsSig = sig;
|
|
46
|
+
this._savingsEl = el;
|
|
47
|
+
|
|
48
|
+
const options = {
|
|
49
|
+
chart: { type: 'area', height: 240, toolbar: { show: false }, zoom: { enabled: false }, animations: { enabled: false }, fontFamily: 'inherit' },
|
|
50
|
+
series: [{ name: 'Savings', data: values }],
|
|
51
|
+
xaxis: { categories: labels, labels: { rotate: -45, style: { fontSize: '10px' } }, tickAmount: 6 },
|
|
52
|
+
yaxis: { labels: { formatter: (v) => fmt(v) } },
|
|
53
|
+
colors: ['#10b981'],
|
|
54
|
+
stroke: { curve: 'smooth', width: 2.5 },
|
|
55
|
+
fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.1 } },
|
|
56
|
+
dataLabels: { enabled: false },
|
|
57
|
+
tooltip: { y: { formatter: (v) => fmt(v) } },
|
|
58
|
+
grid: { borderColor: '#e4e4e7', strokeDashArray: 3 },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return this._render('savings', el, options);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
donut (id, el, labels, values) {
|
|
65
|
+
const options = {
|
|
66
|
+
chart: { type: 'donut', height: 300, animations: { enabled: false }, fontFamily: 'inherit' },
|
|
67
|
+
series: values,
|
|
68
|
+
labels,
|
|
69
|
+
legend: { position: 'bottom' },
|
|
70
|
+
dataLabels: { enabled: true, formatter: (pct) => Math.round(pct) + '%' },
|
|
71
|
+
tooltip: { y: { formatter: (v) => fmt(v) } },
|
|
72
|
+
};
|
|
73
|
+
return this._render(id, el, options);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
stackedBar (id, el, categories, series) {
|
|
77
|
+
const options = {
|
|
78
|
+
chart: { type: 'bar', height: 320, stacked: true, toolbar: { show: false }, animations: { enabled: false }, fontFamily: 'inherit' },
|
|
79
|
+
series,
|
|
80
|
+
xaxis: { categories, labels: { rotate: -45, style: { fontSize: '10px' } } },
|
|
81
|
+
yaxis: { labels: { formatter: (v) => fmt(v) } },
|
|
82
|
+
legend: { position: 'bottom' },
|
|
83
|
+
dataLabels: { enabled: false },
|
|
84
|
+
tooltip: { y: { formatter: (v) => fmt(v) } },
|
|
85
|
+
grid: { borderColor: '#e4e4e7', strokeDashArray: 3 },
|
|
86
|
+
};
|
|
87
|
+
return this._render(id, el, options);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
line (id, el, categories, series, opts = {}) {
|
|
91
|
+
const options = {
|
|
92
|
+
chart: { type: 'line', height: 300, toolbar: { show: false }, zoom: { enabled: false }, animations: { enabled: false }, fontFamily: 'inherit' },
|
|
93
|
+
series,
|
|
94
|
+
xaxis: { categories, labels: { rotate: -45, style: { fontSize: '10px' } }, tickAmount: 12 },
|
|
95
|
+
yaxis: { labels: { formatter: (v) => fmt(v) } },
|
|
96
|
+
stroke: { curve: 'smooth', width: 2.5, dashArray: opts.dashArray || 0 },
|
|
97
|
+
legend: { position: 'bottom' },
|
|
98
|
+
dataLabels: { enabled: false },
|
|
99
|
+
tooltip: { y: { formatter: (v) => fmt(v) } },
|
|
100
|
+
grid: { borderColor: '#e4e4e7', strokeDashArray: 3 },
|
|
101
|
+
};
|
|
102
|
+
if (opts.colors) options.colors = opts.colors;
|
|
103
|
+
return this._render(id, el, options);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
_render (id, el, options) {
|
|
107
|
+
if (!el) return null;
|
|
108
|
+
// destroy + clear + create: updateOptions() does not reliably apply
|
|
109
|
+
// changed label sets (donut legends stick), so a fresh instance it is
|
|
110
|
+
this.destroy(id);
|
|
111
|
+
el.innerHTML = ''; // never stack SVGs, even if a stale instance failed to clean up
|
|
112
|
+
this.instances[id] = new ApexCharts(el, options);
|
|
113
|
+
this.instances[id].render();
|
|
114
|
+
return this.instances[id];
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x-component system: each <template x-component="name"> in index.html becomes
|
|
3
|
+
* a <x-name> custom element - one source of truth per component. Attributes on
|
|
4
|
+
* the element flow into the template's Alpine scope via xProps (helpers.js);
|
|
5
|
+
* <slot> (default and named) is replaced with the element's original children.
|
|
6
|
+
*
|
|
7
|
+
* Must run before Alpine boots (Alpine is loaded with defer; this is not).
|
|
8
|
+
*/
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
function parseCustomComponents () {
|
|
12
|
+
document.querySelectorAll('template[x-component]').forEach((template) => {
|
|
13
|
+
const name = `x-${template.getAttribute('x-component')}`;
|
|
14
|
+
|
|
15
|
+
class Component extends HTMLElement {
|
|
16
|
+
connectedCallback () {
|
|
17
|
+
const content = template.content.cloneNode(true);
|
|
18
|
+
|
|
19
|
+
content.querySelectorAll('slot').forEach((slot) => {
|
|
20
|
+
const slotName = slot.getAttribute('name');
|
|
21
|
+
if (slotName) {
|
|
22
|
+
const match = this.querySelector(`[slot="${slotName}"]`);
|
|
23
|
+
if (match) slot.replaceWith(match.cloneNode(true));
|
|
24
|
+
else slot.remove();
|
|
25
|
+
} else {
|
|
26
|
+
const children = Array.from(this.childNodes).filter((n) =>
|
|
27
|
+
n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE);
|
|
28
|
+
slot.replaceWith(...children.map((n) => n.cloneNode(true)));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.innerHTML = '';
|
|
33
|
+
this.appendChild(content);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
customElements.define(name, Component);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* global Alpine, FinEngine */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const E = FinEngine;
|
|
5
|
+
|
|
6
|
+
let _fmt = null;
|
|
7
|
+
function fmt (value) {
|
|
8
|
+
if (!_fmt) {
|
|
9
|
+
const currency = (Alpine.store('data').db?.settings?.currency) || 'USD';
|
|
10
|
+
_fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 });
|
|
11
|
+
}
|
|
12
|
+
return _fmt.format(E.num(value));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmtSigned (value) {
|
|
16
|
+
const n = E.num(value);
|
|
17
|
+
return (n > 0 ? '+' : '') + fmt(n);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function showToast (message, type = 'info', timeout = 3500) {
|
|
21
|
+
const t = Alpine.store('ui').toast;
|
|
22
|
+
t.message = message;
|
|
23
|
+
t.type = type;
|
|
24
|
+
t.timeout = timeout;
|
|
25
|
+
t.show = true;
|
|
26
|
+
clearTimeout(window._toastTimeout);
|
|
27
|
+
window._toastTimeout = setTimeout(() => { t.show = false; }, timeout);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Promise-based confirm dialog backed by the shared modal in index.html.
|
|
32
|
+
* confirmDialog({ title, body, confirmText, danger, altText }) → Promise
|
|
33
|
+
* Resolves true (confirm), false (cancel), or 'alt' when the optional
|
|
34
|
+
* secondary action (altText) is chosen.
|
|
35
|
+
*/
|
|
36
|
+
function confirmDialog (opts) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const c = Alpine.store('ui').confirm;
|
|
39
|
+
c.title = opts.title || 'Are you sure?';
|
|
40
|
+
c.body = opts.body || '';
|
|
41
|
+
c.confirmText = opts.confirmText || 'Confirm';
|
|
42
|
+
c.cancelText = opts.cancelText || 'Cancel';
|
|
43
|
+
c.altText = opts.altText || null;
|
|
44
|
+
c.danger = !!opts.danger;
|
|
45
|
+
c.open = true;
|
|
46
|
+
c.resolve = (answer) => { c.open = false; c.resolve = null; resolve(answer); };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Unwrap an IPC response; throws on { ok: false } and shows nothing itself. */
|
|
51
|
+
function unwrap (res) {
|
|
52
|
+
if (!res || res.ok !== true) throw new Error(res && res.error || 'Unknown error');
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Shared prop plumbing for x-components: parent attributes become reactive data. */
|
|
57
|
+
function xProps (el, defaults = {}) {
|
|
58
|
+
const parent = el.parentElement;
|
|
59
|
+
const data = { ...defaults };
|
|
60
|
+
for (const attr of parent.getAttributeNames()) data[attr] = parent.getAttribute(attr);
|
|
61
|
+
return {
|
|
62
|
+
...data,
|
|
63
|
+
_initProps () {
|
|
64
|
+
const observer = new MutationObserver((mutations) => {
|
|
65
|
+
for (const m of mutations) {
|
|
66
|
+
if (m.type === 'attributes') this[m.attributeName] = parent.getAttribute(m.attributeName);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
observer.observe(parent, { attributes: true });
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function boolAttr (value) {
|
|
75
|
+
return !(value === '0' || value === 'false' || value === 'null' || value === false || value === undefined || value === null || value === '');
|
|
76
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/* global Alpine, FinEngine, Charts, fmt */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
function insightsView () {
|
|
5
|
+
return {
|
|
6
|
+
range: '12mo', // '12mo' | 'ytd' | 'all'
|
|
7
|
+
projected12: null, // savings 12 months out at the current trend
|
|
8
|
+
|
|
9
|
+
get db () { return Alpine.store('data').db; },
|
|
10
|
+
get keys () { return this.db ? FinEngine.monthKeys(this.db) : []; },
|
|
11
|
+
|
|
12
|
+
get rangeKeys () {
|
|
13
|
+
const keys = this.keys;
|
|
14
|
+
if (!keys.length) return [];
|
|
15
|
+
const last = keys[keys.length - 1];
|
|
16
|
+
if (this.range === '12mo') return keys.slice(-12);
|
|
17
|
+
if (this.range === 'ytd') return keys.filter((k) => k.startsWith(last.slice(0, 4)));
|
|
18
|
+
return keys;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Spend aggregated per tag per month - computed at chart time, never
|
|
23
|
+
* stored. An expense counts under the UNION of its own tags and the
|
|
24
|
+
* tags of the envelope it draws from (a budget's tags are enforced on
|
|
25
|
+
* its linked expenses; the expense's own tags are never overridden).
|
|
26
|
+
* With no tags from either source it lands in the 'untagged' bucket.
|
|
27
|
+
* Direct envelope spending counts under the envelope's own tags.
|
|
28
|
+
*/
|
|
29
|
+
aggregate () {
|
|
30
|
+
const perMonth = {}; // key → { tag → amount }
|
|
31
|
+
const totals = {}; // tag → amount
|
|
32
|
+
|
|
33
|
+
for (const key of this.rangeKeys) {
|
|
34
|
+
const month = this.db.months[key];
|
|
35
|
+
const bucket = {};
|
|
36
|
+
const add = (tag, amount) => {
|
|
37
|
+
if (!amount) return;
|
|
38
|
+
bucket[tag] = (bucket[tag] || 0) + amount;
|
|
39
|
+
totals[tag] = (totals[tag] || 0) + amount;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// envelope tags, for inheritance by their linked expenses
|
|
43
|
+
const envTags = {};
|
|
44
|
+
for (const g of month.groups) {
|
|
45
|
+
if (!FinEngine.isEnvelopeKind(g.kind)) continue;
|
|
46
|
+
for (const f of g.fields) {
|
|
47
|
+
if (f.tags && f.tags.length) envTags[f.id] = f.tags;
|
|
48
|
+
// direct envelope spending under the envelope's own tags
|
|
49
|
+
if (f.tags && f.tags.length) for (const t of f.tags) add(t, FinEngine.num(f.spent));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const g of month.groups) {
|
|
54
|
+
if (g.kind !== 'expense') continue;
|
|
55
|
+
for (const f of g.fields) {
|
|
56
|
+
const merged = new Set([
|
|
57
|
+
...(f.tags || []),
|
|
58
|
+
...((f.budgetId && envTags[f.budgetId]) || []),
|
|
59
|
+
]);
|
|
60
|
+
const tags = merged.size ? [...merged] : ['untagged'];
|
|
61
|
+
for (const t of tags) add(t, FinEngine.num(f.value));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
perMonth[key] = bucket;
|
|
65
|
+
}
|
|
66
|
+
return { perMonth, totals };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Income aggregated per tag per month - income streams (clients, side
|
|
71
|
+
* gigs…). Only TAGGED income counts: there is deliberately no
|
|
72
|
+
* 'untagged' bucket, and the whole section hides when nothing is
|
|
73
|
+
* tagged. Face values; a field with several tags counts under each.
|
|
74
|
+
*/
|
|
75
|
+
incomeAggregate () {
|
|
76
|
+
const perMonth = {}; // key → { tag → amount }
|
|
77
|
+
const totals = {}; // tag → amount
|
|
78
|
+
for (const key of this.rangeKeys) {
|
|
79
|
+
const month = this.db.months[key];
|
|
80
|
+
const bucket = {};
|
|
81
|
+
for (const g of month.groups) {
|
|
82
|
+
if (g.kind !== 'income') continue;
|
|
83
|
+
for (const f of g.fields) {
|
|
84
|
+
if (!f.tags || !f.tags.length) continue;
|
|
85
|
+
const amount = FinEngine.num(f.value);
|
|
86
|
+
if (!amount) continue;
|
|
87
|
+
for (const t of f.tags) {
|
|
88
|
+
bucket[t] = (bucket[t] || 0) + amount;
|
|
89
|
+
totals[t] = (totals[t] || 0) + amount;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
perMonth[key] = bucket;
|
|
94
|
+
}
|
|
95
|
+
return { perMonth, totals };
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
incomeRows () {
|
|
99
|
+
const { totals } = this.incomeAggregate();
|
|
100
|
+
const taggedTotal = Object.values(totals).reduce((a, b) => a + b, 0) || 1;
|
|
101
|
+
const n = this.rangeKeys.length || 1;
|
|
102
|
+
return Object.entries(totals)
|
|
103
|
+
.sort((a, b) => b[1] - a[1])
|
|
104
|
+
.map(([tag, total]) => ({
|
|
105
|
+
tag,
|
|
106
|
+
total,
|
|
107
|
+
avg: Math.round(total / n),
|
|
108
|
+
pct: Math.round(total / taggedTotal * 100),
|
|
109
|
+
}));
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
stats () {
|
|
113
|
+
const keys = this.rangeKeys;
|
|
114
|
+
let income = 0, spend = 0;
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
const m = this.db.months[key];
|
|
117
|
+
for (const g of m.groups) {
|
|
118
|
+
const t = FinEngine.groupTotal(m, g);
|
|
119
|
+
if (g.kind === 'income') income += t; else spend += t;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const n = keys.length || 1;
|
|
123
|
+
return {
|
|
124
|
+
months: keys.length,
|
|
125
|
+
avgIncome: Math.round(income / n),
|
|
126
|
+
avgSpend: Math.round(spend / n),
|
|
127
|
+
avgNet: Math.round((income - spend) / n),
|
|
128
|
+
totalIncome: income,
|
|
129
|
+
totalSpend: spend,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
tagRows () {
|
|
134
|
+
const { totals } = this.aggregate();
|
|
135
|
+
const spendTotal = Object.values(totals).reduce((a, b) => a + b, 0) || 1;
|
|
136
|
+
const n = this.rangeKeys.length || 1;
|
|
137
|
+
return Object.entries(totals)
|
|
138
|
+
.sort((a, b) => b[1] - a[1])
|
|
139
|
+
.map(([tag, total]) => ({
|
|
140
|
+
tag,
|
|
141
|
+
total,
|
|
142
|
+
avg: Math.round(total / n),
|
|
143
|
+
pct: Math.round(total / spendTotal * 100),
|
|
144
|
+
}));
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
/** Envelope health from the most recent month. */
|
|
148
|
+
envelopes () {
|
|
149
|
+
const keys = this.keys;
|
|
150
|
+
if (!this.db || !keys.length) return [];
|
|
151
|
+
const month = this.db.months[keys[keys.length - 1]];
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const g of month.groups) {
|
|
154
|
+
if (!FinEngine.isEnvelopeKind(g.kind)) continue;
|
|
155
|
+
for (const f of g.fields) {
|
|
156
|
+
const spent = FinEngine.effectiveSpent(month, f);
|
|
157
|
+
out.push({
|
|
158
|
+
label: f.label || '(unnamed)',
|
|
159
|
+
kind: g.kind,
|
|
160
|
+
avail: FinEngine.num(f.avail),
|
|
161
|
+
spent,
|
|
162
|
+
left: FinEngine.num(f.avail) - spent,
|
|
163
|
+
target: FinEngine.num(f.target),
|
|
164
|
+
progress: g.kind === 'goal' ? FinEngine.goalProgress(month, f) : null,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
render () {
|
|
172
|
+
if (!this.db) return;
|
|
173
|
+
// resolve containers from the LIVE document - $refs go stale when
|
|
174
|
+
// the x-if gate re-creates the Insights DOM (charts painted into
|
|
175
|
+
// detached elements, appearing "one toggle behind")
|
|
176
|
+
const donutEl = document.getElementById('insights-chart-donut');
|
|
177
|
+
const barEl = document.getElementById('insights-chart-bar');
|
|
178
|
+
const savingsEl = document.getElementById('insights-chart-savings');
|
|
179
|
+
if (!donutEl || !barEl || !savingsEl) return; // view not mounted
|
|
180
|
+
const { perMonth, totals } = this.aggregate();
|
|
181
|
+
|
|
182
|
+
const top = Object.entries(totals).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
183
|
+
Charts.donut('insights-donut', donutEl, top.map(([t]) => t), top.map(([, v]) => Math.round(v)));
|
|
184
|
+
|
|
185
|
+
const topTags = top.slice(0, 6).map(([t]) => t);
|
|
186
|
+
const categories = this.rangeKeys.map((k) => FinEngine.keyLabel(k).slice(0, 3) + ' ' + k.slice(2, 4));
|
|
187
|
+
const series = topTags.map((tag) => ({
|
|
188
|
+
name: tag,
|
|
189
|
+
data: this.rangeKeys.map((k) => Math.round(perMonth[k][tag] || 0)),
|
|
190
|
+
}));
|
|
191
|
+
Charts.stackedBar('insights-bar', barEl, categories, series);
|
|
192
|
+
|
|
193
|
+
// income streams - only when something is tagged; the section is
|
|
194
|
+
// x-show-gated on incomeRows().length so destroy stale instances
|
|
195
|
+
// when the range change empties it
|
|
196
|
+
const incomeDonutEl = document.getElementById('insights-chart-income-donut');
|
|
197
|
+
const incomeBarEl = document.getElementById('insights-chart-income-bar');
|
|
198
|
+
const inc = this.incomeAggregate();
|
|
199
|
+
const incTop = Object.entries(inc.totals).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
200
|
+
if (incTop.length && incomeDonutEl && incomeBarEl) {
|
|
201
|
+
Charts.donut('insights-income-donut', incomeDonutEl,
|
|
202
|
+
incTop.map(([t]) => t), incTop.map(([, v]) => Math.round(v)));
|
|
203
|
+
const incTags = incTop.slice(0, 6).map(([t]) => t);
|
|
204
|
+
const incSeries = incTags.map((tag) => ({
|
|
205
|
+
name: tag,
|
|
206
|
+
data: this.rangeKeys.map((k) => Math.round(inc.perMonth[k][tag] || 0)),
|
|
207
|
+
}));
|
|
208
|
+
Charts.stackedBar('insights-income-bar', incomeBarEl, categories, incSeries);
|
|
209
|
+
} else {
|
|
210
|
+
Charts.destroy('insights-income-donut');
|
|
211
|
+
Charts.destroy('insights-income-bar');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// savings history, windowed to the selected range
|
|
215
|
+
const allHist = Object.keys(this.db.savingsHistory || {}).sort();
|
|
216
|
+
let histKeys = allHist;
|
|
217
|
+
if (this.range === '12mo') {
|
|
218
|
+
histKeys = allHist.slice(-12);
|
|
219
|
+
} else if (this.range === 'ytd') {
|
|
220
|
+
const last = this.keys[this.keys.length - 1] || allHist[allHist.length - 1] || '';
|
|
221
|
+
histKeys = allHist.filter((k) => k.startsWith(last.slice(0, 4)));
|
|
222
|
+
}
|
|
223
|
+
const shortLabel = (k) => FinEngine.keyLabel(k).slice(0, 3) + ' ' + k.slice(2, 4);
|
|
224
|
+
const histVals = histKeys.map((k) => FinEngine.num(this.db.savingsHistory[k]));
|
|
225
|
+
let savingsCats = histKeys.map(shortLabel);
|
|
226
|
+
const savingsSeries = [{ name: 'Savings', data: [...histVals] }];
|
|
227
|
+
|
|
228
|
+
// projection: least-squares trend over the selected window,
|
|
229
|
+
// anchored at the latest actual point, 12 months out
|
|
230
|
+
this.projected12 = null;
|
|
231
|
+
const PROJ = 12;
|
|
232
|
+
if (histVals.length >= 3) {
|
|
233
|
+
const n = histVals.length;
|
|
234
|
+
const xMean = (n - 1) / 2;
|
|
235
|
+
const yMean = histVals.reduce((a, b) => a + b, 0) / n;
|
|
236
|
+
let cov = 0, varx = 0;
|
|
237
|
+
histVals.forEach((y, x) => { cov += (x - xMean) * (y - yMean); varx += (x - xMean) ** 2; });
|
|
238
|
+
const slope = varx ? cov / varx : 0;
|
|
239
|
+
|
|
240
|
+
let k = histKeys[histKeys.length - 1];
|
|
241
|
+
const futureKeys = [];
|
|
242
|
+
for (let s = 1; s <= PROJ; s++) { k = FinEngine.nextKey(k); futureKeys.push(k); }
|
|
243
|
+
savingsCats = savingsCats.concat(futureKeys.map(shortLabel));
|
|
244
|
+
|
|
245
|
+
const last = histVals[n - 1];
|
|
246
|
+
savingsSeries[0].data = histVals.concat(new Array(PROJ).fill(null));
|
|
247
|
+
savingsSeries.push({
|
|
248
|
+
name: 'Projected',
|
|
249
|
+
data: new Array(n - 1).fill(null)
|
|
250
|
+
.concat([last], futureKeys.map((_, s) => Math.round(last + slope * (s + 1)))),
|
|
251
|
+
});
|
|
252
|
+
this.projected12 = Math.round(last + slope * PROJ);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
Charts.line('insights-savings', savingsEl, savingsCats, savingsSeries,
|
|
256
|
+
{ dashArray: [0, 6], colors: ['#3b82f6', '#8b5cf6'] });
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|