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.
@@ -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,4 @@
1
+ /* global parseCustomComponents */
2
+ // Runs at the end of <body>, after the x-component templates exist and before
3
+ // Alpine (deferred) boots - external file so the CSP can stay inline-script-free.
4
+ parseCustomComponents();
@@ -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
+ }