Last active
April 28, 2026 17:17
-
-
Save malys/eae223f9fc04a50ac5e9108cfe29cf1f to your computer and use it in GitHub Desktop.
[Sure Chart] chart#userscript #violentmonkey #Sure
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Sure Finance – Chart Templates v5 (tx + account N-groups) | |
| // @namespace https://sure.am | |
| // @version 5.4.1 | |
| // @description Transaction templates + Account balance templates with N groups (current balances via /api/v1/accounts). Robust balance parsing. | |
| // @match *://sure.am/* | |
| // @match *://*.sure.am/* | |
| // @match *://sure.l.malys.ovh/* | |
| // @grant none | |
| // @require https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js | |
| // @require https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js | |
| // @downloadURL https://gist.githubusercontent.com/malys/eae223f9fc04a50ac5e9108cfe29cf1f/raw/userscript.js | |
| // @updateURL https://gist.githubusercontent.com/malys/eae223f9fc04a50ac5e9108cfe29cf1f/raw/userscript.js | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ───────────────────────────────────────────────────────────── | |
| // STATE | |
| // ───────────────────────────────────────────────────────────── | |
| const STORAGE_KEY = 'sure_chart_templates_v5'; | |
| const APIKEY_STORAGE = 'sure_chart_api_key'; | |
| window.activeChart = null; | |
| let categoriesCache = null; | |
| let accountsCache = null; | |
| let isPanelOpen = false; | |
| let isModalOpen = false; | |
| let isChartOpen = false; | |
| let refreshTimer = null; | |
| let panelViewMode = 'cards'; | |
| let templateSearch = ''; | |
| // ───────────────────────────────────────────────────────────── | |
| // DEFAULT TEMPLATES | |
| // kinds: | |
| // - 'transactions' => /api/v1/transactions | |
| // - 'account_groups' => /api/v1/accounts (current balances), N groups | |
| // ───────────────────────────────────────────────────────────── | |
| const DEFAULT_TEMPLATES = [ | |
| { | |
| id:'quarterly_outflows', name:'Quarterly Outflows by Category', | |
| kind:'transactions', | |
| chartType:'line', dataType:'expense', groupBy:'month', periodType:'quarter', | |
| customStartDate:null, customEndDate:null, maxCategories:8, | |
| showSubcategories:false, isDefault:true, isPinned:false, | |
| includedCategories:[], includedAccounts:[], | |
| compareWithPrevious:false, autoRefreshMinutes:0, | |
| }, | |
| { | |
| id:'monthly_income', name:'Monthly Income by Category', | |
| kind:'transactions', | |
| chartType:'bar', dataType:'income', groupBy:'week', periodType:'month', | |
| customStartDate:null, customEndDate:null, maxCategories:6, | |
| showSubcategories:false, isDefault:true, isPinned:false, | |
| includedCategories:[], includedAccounts:[], | |
| compareWithPrevious:false, autoRefreshMinutes:0, | |
| }, | |
| { | |
| id:'yearly_cumulative', name:'Yearly Cumulative Spending', | |
| kind:'transactions', | |
| chartType:'bar-cumulative', dataType:'expense', groupBy:'month', periodType:'year', | |
| customStartDate:null, customEndDate:null, maxCategories:6, | |
| showSubcategories:false, isDefault:true, isPinned:false, | |
| includedCategories:[], includedAccounts:[], | |
| compareWithPrevious:false, autoRefreshMinutes:0, | |
| }, | |
| // Example account template (starts with All accounts so it never shows empty) | |
| { | |
| id:'acct_groups_example', name:'Accounts: Groups (edit me)', | |
| kind:'account_groups', | |
| chartType:'bar', | |
| groups: [ | |
| { id:'g1', name:'All accounts', all:true, accountIds:[] }, | |
| { id:'g2', name:'Group 2', all:false, accountIds:[] }, | |
| ], | |
| isDefault:true, isPinned:false, | |
| autoRefreshMinutes:10, | |
| }, | |
| ]; | |
| // ───────────────────────────────────────────────────────────── | |
| // COLORS & PALETTE | |
| // ───────────────────────────────────────────────────────────── | |
| const C = { | |
| bgPrimary:'#1a1a2e', bgSecondary:'#16213e', bgTertiary:'#0f3460', | |
| bgCard:'#1e2746', bgHover:'#2a3a5c', border:'#3a4a6c', | |
| textPrimary:'#e4e4e7', textSecondary:'#a1a1aa', textMuted:'#71717a', | |
| accent:'#3b82f6', accentHover:'#2563eb', | |
| danger:'#ef4444', dangerHover:'#dc2626', | |
| success:'#10b981', warning:'#f59e0b', | |
| }; | |
| const PALETTE=[ | |
| '#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6', | |
| '#ec4899','#06b6d4','#84cc16','#f97316','#6366f1', | |
| '#14b8a6','#e11d48','#7c3aed','#0891b2','#65a30d', | |
| '#d97706','#db2777','#9333ea','#0284c7','#16a34a', | |
| ]; | |
| const pickColor = i => PALETTE[i % PALETTE.length]; | |
| // ───────────────────────────────────────────────────────────── | |
| // STYLES | |
| // ───────────────────────────────────────────────────────────── | |
| function injectStyles() { | |
| if (document.getElementById('cjs-styles')) return; | |
| const s = document.createElement('style'); | |
| s.id = 'cjs-styles'; | |
| s.textContent = ` | |
| #cjs-root * { box-sizing:border-box; font-family:system-ui,-apple-system,sans-serif; } | |
| .cjs-btn { padding:7px 14px; border-radius:6px; font-size:13px; font-weight:500; cursor:pointer; border:none; transition:all .18s; display:inline-flex; align-items:center; justify-content:center; gap:5px; white-space:nowrap; } | |
| .cjs-btn-primary { background:${C.accent}; color:#fff; } | |
| .cjs-btn-primary:hover { background:${C.accentHover}; } | |
| .cjs-btn-secondary { background:${C.bgTertiary}; color:${C.textPrimary}; border:1px solid ${C.border}; } | |
| .cjs-btn-secondary:hover { background:${C.bgHover}; } | |
| .cjs-btn-danger { background:${C.danger}; color:#fff; } | |
| .cjs-btn-ghost { background:transparent; color:${C.textSecondary}; border:1px solid transparent; } | |
| .cjs-btn-ghost:hover { background:${C.bgHover}; color:${C.textPrimary}; border-color:${C.border}; } | |
| .cjs-btn-sm { padding:5px 10px; font-size:12px; } | |
| .cjs-btn-xs { padding:3px 7px; font-size:11px; } | |
| .cjs-btn-icon { padding:6px; background:transparent; color:${C.textSecondary}; border:none; cursor:pointer; border-radius:5px; line-height:1; transition:all .15s; } | |
| .cjs-btn-icon:hover { color:${C.textPrimary}; background:${C.bgHover}; } | |
| .cjs-btn-icon.active { color:${C.warning}; } | |
| .cjs-btn-icon.active-blue { color:${C.accent}; } | |
| .cjs-input,.cjs-select { padding:8px 11px; border:1px solid ${C.border}; border-radius:6px; font-size:13px; width:100%; background:${C.bgSecondary}; color:${C.textPrimary}; transition:border-color .18s,box-shadow .18s; } | |
| .cjs-input:focus,.cjs-select:focus { outline:none; border-color:${C.accent}; box-shadow:0 0 0 3px rgba(59,130,246,.18); } | |
| .cjs-select[multiple] { min-height:80px; padding:5px; } | |
| .cjs-select[multiple] option { padding:4px 7px; border-radius:3px; margin-bottom:1px; } | |
| .cjs-select[multiple] option:checked { background:${C.accent}; color:#fff; } | |
| .cjs-label { display:block; font-size:11px; font-weight:600; color:${C.textSecondary}; margin-bottom:5px; text-transform:uppercase; letter-spacing:.04em; } | |
| .cjs-checkbox { width:15px; height:15px; accent-color:${C.accent}; cursor:pointer; } | |
| .cjs-card { background:${C.bgCard}; border-radius:8px; border:1px solid ${C.border}; padding:13px; transition:border-color .18s,box-shadow .18s; } | |
| .cjs-card:hover { border-color:${C.accent}44; box-shadow:0 2px 14px rgba(59,130,246,.07); } | |
| .cjs-card.pinned { border-color:${C.warning}66; } | |
| .cjs-card-row { background:${C.bgCard}; border-radius:6px; border:1px solid ${C.border}; padding:8px 12px; display:flex; align-items:center; gap:10px; transition:border-color .15s; } | |
| .cjs-card-row:hover { border-color:${C.accent}44; } | |
| /* Tag picker */ | |
| .cjs-tag-area { display:flex; flex-wrap:wrap; gap:5px; min-height:36px; padding:6px; background:${C.bgSecondary}; border:1px solid ${C.border}; border-radius:6px; cursor:text; transition:border-color .18s; } | |
| .cjs-tag-area:focus-within { border-color:${C.accent}; box-shadow:0 0 0 3px rgba(59,130,246,.15); } | |
| .cjs-tag { display:inline-flex; align-items:center; gap:4px; padding:3px 8px; background:${C.bgTertiary}; border:1px solid ${C.border}; border-radius:20px; font-size:11px; color:${C.textPrimary}; max-width:240px; } | |
| .cjs-tag .dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; } | |
| .cjs-tag span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| .cjs-tag button { background:none; border:none; color:${C.textMuted}; cursor:pointer; padding:0 1px; font-size:13px; line-height:1; transition:color .12s; } | |
| .cjs-tag button:hover { color:${C.danger}; } | |
| .cjs-tag-input { border:none; background:transparent; color:${C.textPrimary}; font-size:12px; outline:none; min-width:80px; flex:1; padding:2px 4px; } | |
| .cjs-tag-input::placeholder { color:${C.textMuted}; } | |
| .cjs-tag-dropdown { position:absolute; z-index:10010; background:${C.bgCard}; border:1px solid ${C.border}; border-radius:7px; box-shadow:0 8px 30px rgba(0,0,0,.4); max-height:220px; overflow-y:auto; min-width:280px; } | |
| .cjs-tag-opt { padding:7px 12px; font-size:12px; color:${C.textPrimary}; cursor:pointer; display:flex; align-items:center; gap:8px; transition:background .1s; } | |
| .cjs-tag-opt:hover,.cjs-tag-opt.hi { background:${C.bgHover}; } | |
| .cjs-tag-opt .dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } | |
| .cjs-tag-opt-nm { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| .cjs-tag-opt-par { font-size:10px; color:${C.textMuted}; } | |
| /* Date range picker */ | |
| .cjs-period-chips { display:flex; flex-wrap:wrap; gap:5px; } | |
| .cjs-period-chip { font-size:11px; padding:3px 10px; border-radius:20px; background:${C.bgTertiary}; color:${C.textSecondary}; border:1px solid ${C.border}; cursor:pointer; transition:all .14s; } | |
| .cjs-period-chip:hover { border-color:${C.accent}; color:${C.textPrimary}; } | |
| .cjs-period-chip.active { background:${C.accent}; color:#fff; border-color:${C.accent}; } | |
| .cjs-daterange { display:flex; align-items:center; gap:6px; margin-top:8px; } | |
| .cjs-daterange input[type=date] { flex:1; padding:7px 10px; border:1px solid ${C.border}; border-radius:6px; background:${C.bgSecondary}; color:${C.textPrimary}; font-size:13px; } | |
| .cjs-daterange input[type=date]:focus { outline:none; border-color:${C.accent}; } | |
| .cjs-daterange input[type=date]::-webkit-calendar-picker-indicator { filter:invert(.7); cursor:pointer; } | |
| .cjs-daterange-sep { color:${C.textMuted}; font-size:12px; flex-shrink:0; } | |
| /* Panel */ | |
| .cjs-panel { position:fixed; right:0; top:0; height:100%; width:340px; background:${C.bgPrimary}; border-left:1px solid ${C.border}; box-shadow:-8px 0 36px rgba(0,0,0,.28); z-index:10000; transform:translateX(100%); transition:transform .28s cubic-bezier(.4,0,.2,1); display:flex; flex-direction:column; } | |
| .cjs-panel.open { transform:translateX(0); } | |
| .cjs-panel-header { padding:13px 14px; border-bottom:1px solid ${C.border}; display:flex; justify-content:space-between; align-items:center; flex-shrink:0; gap:8px; } | |
| .cjs-panel-header h2 { margin:0; font-size:15px; font-weight:700; color:${C.textPrimary}; flex:1; } | |
| .cjs-panel-search { padding:8px 12px; border-bottom:1px solid ${C.border}; flex-shrink:0; } | |
| .cjs-panel-toolbar { padding:5px 10px; border-bottom:1px solid ${C.border}; display:flex; align-items:center; justify-content:space-between; flex-shrink:0; } | |
| .cjs-panel-body { flex:1; overflow-y:auto; padding:10px; } | |
| .cjs-panel-footer { padding:11px 12px; border-top:1px solid ${C.border}; } | |
| .cjs-search-wrap { position:relative; } | |
| .cjs-search-icon { position:absolute; left:9px; top:50%; transform:translateY(-50%); color:${C.textMuted}; pointer-events:none; } | |
| .cjs-search-input { width:100%; padding:7px 10px 7px 30px; background:${C.bgSecondary}; border:1px solid ${C.border}; border-radius:6px; color:${C.textPrimary}; font-size:13px; } | |
| .cjs-search-input:focus { outline:none; border-color:${C.accent}; } | |
| /* Modals */ | |
| .cjs-overlay { position:fixed; inset:0; background:rgba(0,0,0,.72); backdrop-filter:blur(5px); z-index:10001; display:flex; align-items:center; justify-content:center; padding:20px; } | |
| .cjs-modal { background:${C.bgPrimary}; border-radius:12px; border:1px solid ${C.border}; max-width:740px; width:100%; max-height:92vh; overflow-y:auto; box-shadow:0 28px 55px -10px rgba(0,0,0,.55); } | |
| .cjs-modal-hd { padding:15px 20px; border-bottom:1px solid ${C.border}; display:flex; justify-content:space-between; align-items:center; } | |
| .cjs-modal-hd h3 { margin:0; font-size:16px; font-weight:700; color:${C.textPrimary}; } | |
| .cjs-modal-bd { padding:18px 20px; } | |
| .cjs-modal-ft { padding:13px 20px; border-top:1px solid ${C.border}; display:flex; justify-content:flex-end; gap:8px; flex-wrap:wrap; } | |
| /* Chart window */ | |
| .cjs-chart-overlay { position:fixed; inset:0; background:rgba(0,0,0,.86); backdrop-filter:blur(6px); z-index:10002; display:flex; align-items:center; justify-content:center; padding:18px; } | |
| .cjs-chart-win { background:${C.bgPrimary}; border-radius:14px; border:1px solid ${C.border}; width:100%; max-width:1200px; height:calc(100vh - 72px); max-height:880px; display:flex; flex-direction:column; box-shadow:0 28px 56px -10px rgba(0,0,0,.7); } | |
| .cjs-chart-hd { padding:12px 18px; border-bottom:1px solid ${C.border}; display:flex; align-items:center; gap:12px; flex-shrink:0; } | |
| .cjs-chart-hd-info { flex:1; min-width:0; } | |
| .cjs-chart-hd-info h2 { margin:0; font-size:15px; font-weight:700; color:${C.textPrimary}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| .cjs-chart-hd-info p { margin:2px 0 0; font-size:11px; color:${C.textSecondary}; } | |
| .cjs-chart-hd-actions { display:flex; gap:5px; align-items:center; flex-shrink:0; } | |
| /* Stats bar */ | |
| .cjs-stats-bar { display:grid; grid-template-columns:repeat(5,1fr); border-bottom:1px solid ${C.border}; flex-shrink:0; } | |
| .cjs-stat { text-align:center; padding:9px 6px; border-right:1px solid ${C.border}; } | |
| .cjs-stat:last-child { border-right:none; } | |
| .cjs-stat-val { font-size:14px; font-weight:700; color:${C.textPrimary}; font-variant-numeric:tabular-nums; } | |
| .cjs-stat-lbl { font-size:10px; color:${C.textMuted}; margin-top:1px; } | |
| /* Tabs + panes */ | |
| .cjs-chart-tabs { display:flex; gap:4px; padding:8px 18px 0; flex-shrink:0; } | |
| .cjs-tab { padding:5px 13px; border-radius:5px 5px 0 0; font-size:12px; font-weight:500; cursor:pointer; border:1px solid transparent; border-bottom:none; color:${C.textSecondary}; transition:all .14s; user-select:none; } | |
| .cjs-tab.active { background:${C.bgCard}; color:${C.textPrimary}; border-color:${C.border}; } | |
| .cjs-tab:not(.active):hover { color:${C.textPrimary}; } | |
| .cjs-chart-pane { flex:1; min-height:0; display:none; flex-direction:column; } | |
| .cjs-chart-pane.active { display:flex; } | |
| .cjs-chart-body { flex:1; padding:14px 18px; min-height:0; position:relative; } | |
| /* Breakdown table */ | |
| .cjs-bd-scroll { flex:1; overflow-y:auto; padding:14px 18px; } | |
| .cjs-bd-table { width:100%; border-collapse:collapse; font-size:12px; } | |
| .cjs-bd-table th { text-align:left; padding:5px 8px; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:${C.textMuted}; border-bottom:1px solid ${C.border}; } | |
| .cjs-bd-table td { padding:6px 8px; border-bottom:1px solid ${C.border}22; color:${C.textPrimary}; } | |
| .cjs-bd-table tr:last-child td { border-bottom:none; } | |
| .cjs-bd-table tr:hover td { background:${C.bgHover}22; } | |
| .cjs-bar-wrap { display:flex; align-items:center; gap:7px; } | |
| .cjs-bar-bg { flex:1; height:5px; background:${C.border}44; border-radius:3px; overflow:hidden; } | |
| .cjs-bar-fill { height:100%; border-radius:3px; transition:width .35s ease; } | |
| /* FAB */ | |
| .cjs-fab { position:fixed; bottom:20px; right:20px; z-index:9999; background:${C.accent}; color:#fff; padding:10px 18px; border-radius:50px; border:none; font-size:13px; font-weight:600; cursor:pointer; box-shadow:0 4px 20px rgba(59,130,246,.4); display:flex; align-items:center; gap:7px; transition:all .18s; } | |
| .cjs-fab:hover { background:${C.accentHover}; transform:translateY(-2px); } | |
| /* Group rows */ | |
| .cjs-grp-row { border:1px solid ${C.border}; border-radius:10px; padding:10px; background:${C.bgSecondary}; } | |
| .cjs-grp-top { display:flex; align-items:center; gap:8px; } | |
| .cjs-grp-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; } | |
| .cjs-grp-actions { display:flex; gap:6px; } | |
| .cjs-help { font-size:12px; color:${C.textSecondary}; line-height:1.35; background:${C.bgSecondary}; border:1px solid ${C.border}; border-radius:10px; padding:10px 12px; } | |
| .cjs-help code { font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size:11px; } | |
| @keyframes cjs-spin { to { transform:rotate(360deg); } } | |
| .cjs-spinner-sm { width:18px; height:18px; border-width:2px; border:2px solid ${C.border}; border-top-color:${C.accent}; border-radius:50%; animation:cjs-spin .8s linear infinite; display:inline-block; } | |
| .cjs-spinner { width:34px; height:34px; border:3px solid ${C.border}; border-top-color:${C.accent}; border-radius:50%; animation:cjs-spin .8s linear infinite; } | |
| .cjs-space-y > * + * { margin-top:9px; } | |
| .cjs-space-y-sm > * + * { margin-top:6px; } | |
| .cjs-grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:11px; } | |
| .cjs-flex { display:flex; } | |
| .cjs-flex-1 { flex:1; } | |
| .cjs-gap-1 { gap:4px; } | |
| .cjs-gap-2 { gap:8px; } | |
| .cjs-items-center { align-items:center; } | |
| .cjs-justify-between { justify-content:space-between; } | |
| .cjs-w-full { width:100%; } | |
| .cjs-truncate { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| .cjs-sec-lbl { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:${C.textMuted}; padding:4px 0 3px; } | |
| .cjs-badge { font-size:10px; padding:2px 6px; border-radius:4px; background:${C.bgTertiary}; color:${C.textSecondary}; border:1px solid ${C.border}; white-space:nowrap; } | |
| .cjs-badge-warn { background:${C.warning}18; color:${C.warning}; border-color:${C.warning}44; } | |
| .cjs-badge-blue { background:${C.accent}18; color:${C.accent}; border-color:${C.accent}44; } | |
| .cjs-quick-chips { display:flex; flex-wrap:wrap; gap:4px; margin-top:8px; } | |
| .cjs-qchip { font-size:11px; padding:2px 9px; border-radius:20px; background:${C.bgTertiary}; color:${C.textSecondary}; border:1px solid ${C.border}; cursor:pointer; transition:all .13s; } | |
| .cjs-qchip:hover { border-color:${C.accent}; color:${C.textPrimary}; } | |
| .cjs-empty { text-align:center; padding:32px 14px; color:${C.textMuted}; } | |
| .cjs-empty-icon { font-size:28px; margin-bottom:7px; } | |
| `; | |
| document.head.appendChild(s); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // STORAGE | |
| // ───────────────────────────────────────────────────────────── | |
| function getData() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (raw) { | |
| const d = JSON.parse(raw); | |
| d.templates = (d.templates||[]).map(t => ({ | |
| // common defaults | |
| kind:'transactions', | |
| isPinned:false, | |
| autoRefreshMinutes:0, | |
| // tx defaults | |
| chartType:'line', | |
| dataType:'expense', | |
| groupBy:'month', | |
| periodType:'month', | |
| customStartDate:null, | |
| customEndDate:null, | |
| maxCategories:8, | |
| showSubcategories:false, | |
| includedCategories:[], | |
| includedAccounts:[], | |
| compareWithPrevious:false, | |
| // account defaults (start with all accounts so not empty) | |
| groups:[{ id:'g1', name:'All accounts', all:true, accountIds:[] }], | |
| ...t | |
| })); | |
| return d; | |
| } | |
| } catch(e) {} | |
| return { templates: DEFAULT_TEMPLATES.map(t=>({...t})) }; | |
| } | |
| function saveData(d) { localStorage.setItem(STORAGE_KEY, JSON.stringify(d)); } | |
| function getApiKey() { try { return localStorage.getItem(APIKEY_STORAGE)||''; } catch(e){return'';} } | |
| function setApiKey(k){ try { k?localStorage.setItem(APIKEY_STORAGE,k.trim()):localStorage.removeItem(APIKEY_STORAGE); return true; } catch(e){return false;} } | |
| // ───────────────────────────────────────────────────────────── | |
| // FETCH HELPERS | |
| // ───────────────────────────────────────────────────────────── | |
| function authFetch(url, opts) { | |
| opts = opts||{}; | |
| const h = { | |
| 'Accept':'application/json', | |
| 'X-Turbo-Request-Id':'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{ | |
| const r=Math.random()*16|0; | |
| return (c==='x'?r:(r&0x3|0x8)).toString(16); | |
| }), | |
| }; | |
| const k = getApiKey(); if(k) h['X-Api-Key']=k; | |
| if(opts.headers) Object.assign(h,opts.headers); | |
| return fetch(url,{method:opts.method||'GET',headers:h,credentials:'include',mode:'cors'}); | |
| } | |
| function fetchCategories() { | |
| if(categoriesCache) return Promise.resolve(categoriesCache); | |
| return authFetch('/api/v1/categories') | |
| .then(r=>r.json()) | |
| .then(d=>{categoriesCache=d.categories||[];return categoriesCache;}); | |
| } | |
| function fetchAccounts() { | |
| if(accountsCache) return Promise.resolve(accountsCache); | |
| return fetchAccountsLive().then(accs=>{accountsCache=accs; return accountsCache;}); | |
| } | |
| function fetchAccountsLive() { | |
| let all=[], page=1; | |
| function next() { | |
| const p=new URLSearchParams({per_page:'100',page:String(page)}); | |
| return authFetch('/api/v1/accounts?'+p) | |
| .then(r=>{ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }) | |
| .then(d=>{ | |
| const accs=d.accounts||[]; | |
| all = all.concat(accs); | |
| const totalPages = d.pagination?.total_pages || null; | |
| if(totalPages && page < totalPages) { page++; return next(); } | |
| if(!totalPages && accs.length===100 && page<50) { page++; return next(); } | |
| return all; | |
| }); | |
| } | |
| return next(); | |
| } | |
| function fetchTransactions(template, overrideRange) { | |
| const {startDate,endDate} = overrideRange||getDateRange(template); | |
| let all=[], page=1; | |
| function next() { | |
| const p=new URLSearchParams({start_date:startDate,end_date:endDate,per_page:'100',page:page.toString()}); | |
| if(template.dataType!=='all') p.set('type',template.dataType); | |
| return authFetch('/api/v1/transactions?'+p) | |
| .then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}) | |
| .then(d=>{ | |
| const txs=d.transactions||[]; all=all.concat(txs); | |
| if(txs.length===100&&page<50){page++;return next();} | |
| return {transactions:all,startDate,endDate}; | |
| }); | |
| } | |
| return next(); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // DATE HELPERS (transactions only) | |
| // ───────────────────────────────────────────────────────────── | |
| function getDateRange(t) { | |
| const now=new Date(); let s,e; | |
| switch(t.periodType){ | |
| case 'week': s=new Date(now); s.setDate(now.getDate()-7); e=now; break; | |
| case 'last30': s=new Date(now); s.setDate(now.getDate()-30); e=now; break; | |
| case 'last90': s=new Date(now); s.setDate(now.getDate()-90); e=now; break; | |
| case 'last6m': s=new Date(now); s.setMonth(now.getMonth()-6); e=now; break; | |
| case 'last12m': s=new Date(now); s.setFullYear(now.getFullYear()-1); e=now; break; | |
| case 'month': s=new Date(now.getFullYear(),now.getMonth(),1); e=new Date(now.getFullYear(),now.getMonth()+1,0); break; | |
| case 'quarter': {const q=Math.floor(now.getMonth()/3); s=new Date(now.getFullYear(),q*3,1); e=new Date(now.getFullYear(),q*3+3,0); break;} | |
| case 'year': s=new Date(now.getFullYear(),0,1); e=new Date(now.getFullYear(),11,31); break; | |
| case 'custom': s=t.customStartDate?new Date(t.customStartDate):new Date(now.getFullYear(),0,1); e=t.customEndDate?new Date(t.customEndDate):now; break; | |
| default: s=new Date(now.getFullYear(),now.getMonth(),1); e=now; | |
| } | |
| return {startDate:s.toISOString().split('T')[0],endDate:e.toISOString().split('T')[0]}; | |
| } | |
| function getPrevRange(t) { | |
| const {startDate:sd,endDate:ed}=getDateRange(t); | |
| const s=new Date(sd),e=new Date(ed); | |
| const ms = e.getTime() - s.getTime(); | |
| const pe=new Date(s.getTime()-86400000); | |
| const ps=new Date(pe.getTime()-ms); | |
| return {startDate:ps.toISOString().split('T')[0],endDate:pe.toISOString().split('T')[0]}; | |
| } | |
| function pKey(date,groupBy) { | |
| if(groupBy==='day') return date.toISOString().split('T')[0]; | |
| if(groupBy==='week'){const w=new Date(date);w.setDate(date.getDate()-date.getDay());return w.toISOString().split('T')[0];} | |
| return date.getFullYear()+'-'+String(date.getMonth()+1).padStart(2,'0'); | |
| } | |
| function pLabel(key,groupBy) { | |
| const d=new Date(groupBy==='month'?key+'-01':key); | |
| if(groupBy==='day') return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); | |
| if(groupBy==='week') return 'W/'+d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); | |
| return d.toLocaleDateString('en-US',{month:'short',year:'2-digit'}); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // DATA PROCESSING (transactions) | |
| // ───────────────────────────────────────────────────────────── | |
| function buildSets(transactions,template,catMap,isPrev) { | |
| const grouped={},totals={}; | |
| const selCats=template.includedCategories?.length?new Set(template.includedCategories):null; | |
| const selAccs=template.includedAccounts?.length ?new Set(template.includedAccounts) :null; | |
| for(const tx of transactions) { | |
| if(selAccs&&tx.account&&!selAccs.has(tx.account.id)) continue; | |
| let cid=tx.category?.id||'uncategorized'; | |
| if(!template.showSubcategories&&cid!=='uncategorized'){ | |
| const c=catMap[cid]; | |
| if(c?.parent_id&&catMap[c.parent_id]) cid=c.parent_id; | |
| } | |
| if(selCats&&!selCats.has(cid)) continue; | |
| const pk=pKey(new Date(tx.date),template.groupBy); | |
| grouped[pk]=grouped[pk]||{}; | |
| const amt = (tx.amount_cents!=null) ? Math.abs((tx.amount_cents||0)/100) : Math.abs(Number(tx.amount||0)); | |
| grouped[pk][cid]=(grouped[pk][cid]||0)+amt; | |
| totals[cid]=(totals[cid]||0)+amt; | |
| } | |
| const periods=Object.keys(grouped).sort(); | |
| const topCats=Object.entries(totals).sort((a,b)=>b[1]-a[1]).slice(0,template.maxCategories||8).map(e=>e[0]); | |
| const isCumul=template.chartType==='bar-cumulative'; | |
| const isStack=template.chartType==='bar-stacked'; | |
| const base=(isCumul||isStack)?'bar':template.chartType==='area'?'line':template.chartType; | |
| const isLine=base==='line', isArea=template.chartType==='area'; | |
| const datasets=topCats.map((cid,i)=>{ | |
| const cat=catMap[cid]||{name:cid==='uncategorized'?'Uncategorized':'Unknown',color:null}; | |
| const col=cat.color||pickColor(i); | |
| let vals=periods.map(p=>(grouped[p]?.[cid])||0); | |
| if(isCumul){let r=0;vals=vals.map(v=>{r+=v;return r;});} | |
| return { | |
| label:cat.name+(isPrev?' ❮prev❯':''), | |
| data:vals, | |
| borderColor:isPrev?col+'77':col, | |
| backgroundColor:(isLine&&!isArea)?col+'40':col+(isArea?'99':isPrev?'44':'cc'), | |
| fill:isArea, tension:(isLine||isArea)?0.4:0, | |
| pointRadius:isLine?3:0, pointHoverRadius:isLine?5:0, | |
| borderRadius:(!isLine&&!isArea)?3:0, borderWidth:(isLine||isArea)?2:0, | |
| borderDash:isPrev?[5,4]:undefined, | |
| stack:(isCumul||isStack)?(isPrev?'prev':'stack'):undefined, | |
| _cid:cid, _isPrev:isPrev, | |
| }; | |
| }); | |
| const grand=Object.values(totals).reduce((a,b)=>a+b,0); | |
| return {periods,datasets,totals,topCats,grand,base}; | |
| } | |
| function processTransactionsTemplate(template) { | |
| return Promise.all([fetchCategories(),fetchAccounts()]).then(([cats])=>{ | |
| const catMap={}; | |
| cats.forEach(c=>{catMap[c.id]={name:c.name,color:c.color||null,parent_id:c.parent_id||null};}); | |
| const curF=fetchTransactions(template); | |
| const prevF=template.compareWithPrevious ? fetchTransactions(template,getPrevRange(template)) : Promise.resolve(null); | |
| return Promise.all([curF,prevF]).then(([cur,prev])=>{ | |
| const cB=buildSets(cur.transactions,template,catMap,false); | |
| const {periods,datasets:curDS,totals,topCats,grand,base}=cB; | |
| let prevDS=[],prevGrand=0; | |
| if(prev){ | |
| const pB=buildSets(prev.transactions,template,catMap,true); | |
| prevDS=pB.datasets.filter(d=>topCats.includes(d._cid)); | |
| prevGrand=pB.grand; | |
| prevDS.forEach(d=>{while(d.data.length<periods.length)d.data.push(0);d.data=d.data.slice(0,periods.length);}); | |
| } | |
| const labels=periods.map(p=>pLabel(p,template.groupBy)); | |
| const allDS=[...curDS,...prevDS]; | |
| const isCumul=template.chartType==='bar-cumulative'; | |
| const isStack=template.chartType==='bar-stacked'; | |
| const avgPer=periods.length ? curDS.reduce((s,d)=>s+d.data.reduce((a,b)=>a+b,0),0)/periods.length : 0; | |
| const maxPer=periods.map((_,i)=>curDS.reduce((s,d)=>s+(d.data[i]||0),0)).reduce((a,b)=>Math.max(a,b),0); | |
| const pct=prevGrand>0?((grand-prevGrand)/prevGrand)*100:null; | |
| const breakdown=topCats.map((cid,i)=>({ | |
| name:(catMap[cid]||{name:cid}).name, | |
| color:catMap[cid]?.color||pickColor(i), | |
| total:totals[cid]||0, | |
| share:grand>0?(totals[cid]||0)/grand*100:0, | |
| })).sort((a,b)=>b.total-a.total); | |
| return { | |
| labels, | |
| datasets:allDS, | |
| breakdown, | |
| startDate:cur.startDate, | |
| endDate:cur.endDate, | |
| chartType:base, | |
| isStacked:(isCumul||isStack), | |
| isCumul, | |
| isArea:template.chartType==='area', | |
| stats:{grand,avgPer,maxPer,periodCount:periods.length,prevGrand,pct} | |
| }; | |
| }); | |
| }); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // DATA PROCESSING (account groups, current balances) | |
| // OpenAPI says: Account.balance is a string. We parse robustly. | |
| // ───────────────────────────────────────────────────────────── | |
| function parseBalance(v) { | |
| if (v == null) return 0; | |
| // Normalize common formats: | |
| // "€1,234.56" -> "1234.56" | |
| // "1 234,56" -> "1234.56" | |
| // "(123.45)" -> "-123.45" | |
| let s = String(v).trim(); | |
| // Parentheses mean negative | |
| let neg = false; | |
| if (s.startsWith('(') && s.endsWith(')')) { | |
| neg = true; | |
| s = s.slice(1, -1); | |
| } | |
| // Remove currency symbols and letters, keep digits, separators, sign | |
| s = s.replace(/[^\d.,+-]/g, ''); | |
| // If it uses comma as decimal separator: "1234,56" (and no dot) | |
| const hasDot = s.includes('.'); | |
| const commaCount = (s.match(/,/g) || []).length; | |
| if (!hasDot && commaCount === 1) s = s.replace(',', '.'); | |
| // Remove remaining thousands separators (commas) | |
| s = s.replace(/,/g, ''); | |
| let n = Number(s); | |
| if (!Number.isFinite(n)) return 0; | |
| if (neg) n = -n; | |
| return n; | |
| } | |
| function processAccountGroupsTemplate(template) { | |
| const groups = Array.isArray(template.groups) ? template.groups : []; | |
| if(!groups.length) { | |
| return Promise.resolve({ | |
| labels: ['No groups'], | |
| datasets: [{ label:'Current balance', data:[0], backgroundColor:[pickColor(0)+'cc'], borderColor:[pickColor(0)], borderWidth:1 }], | |
| breakdown: [{ name:'No groups', color:pickColor(0), total:0, share:100 }], | |
| startDate:'—', endDate:'—', | |
| chartType:'bar', isStacked:false, isCumul:false, isArea:false, | |
| stats:{grand:0,avgPer:0,maxPer:0,periodCount:1,prevGrand:0,pct:null} | |
| }); | |
| } | |
| return fetchAccountsLive().then(accs=>{ | |
| const byId = {}; | |
| accs.forEach(a=>{ byId[a.id]=a; }); | |
| const sums = groups.map(g=>{ | |
| const all = !!g.all; | |
| const ids = Array.isArray(g.accountIds) ? g.accountIds : []; | |
| let total = 0; | |
| if(all) { | |
| total = accs.reduce((t,a)=>t + parseBalance(a.balance), 0); | |
| } else { | |
| ids.forEach(id=>{ | |
| total += parseBalance(byId[id]?.balance); | |
| }); | |
| } | |
| return total; | |
| }); | |
| if (sums.every(v => Math.abs(v) < 1e-9)) { | |
| toast('All groups are 0. Select accounts in each group or enable "ALL accounts" for a group.', 'warning'); | |
| } | |
| const labels = groups.map((g,i)=> (g.name && String(g.name).trim()) ? String(g.name).trim() : `Group ${i+1}`); | |
| const colors = groups.map((_,i)=> pickColor(i)); | |
| // Force chart types that make sense for single-point compare | |
| const chartType = (template.chartType==='doughnut' || template.chartType==='bar') ? template.chartType : 'bar'; | |
| let datasets; | |
| if(chartType === 'doughnut') { | |
| datasets = [{ | |
| label:'Current balance', | |
| data:sums, | |
| backgroundColor: colors.map(c=>c+'cc'), | |
| borderColor: colors, | |
| borderWidth:1, | |
| }]; | |
| } else { | |
| datasets = [{ | |
| label:'Current balance', | |
| data:sums, | |
| backgroundColor: colors.map(c=>c+'cc'), | |
| borderColor: colors, | |
| borderWidth:1, | |
| borderRadius:4, | |
| }]; | |
| } | |
| const totalAbs = sums.reduce((t,v)=>t+Math.abs(v),0) || 1; | |
| const breakdown = labels.map((nm,i)=>({ | |
| name:nm, | |
| color:colors[i], | |
| total:sums[i]||0, | |
| share:(Math.abs(sums[i]||0)/totalAbs)*100, | |
| })).sort((a,b)=>Math.abs(b.total)-Math.abs(a.total)); | |
| const grand = sums.reduce((a,b)=>a+b,0); | |
| const maxPer = Math.max(...sums.map(v=>Math.abs(v)), 0); | |
| const avgPer = sums.length ? grand/sums.length : 0; | |
| return { | |
| labels, | |
| datasets, | |
| breakdown, | |
| startDate:'—', | |
| endDate:'—', | |
| chartType, | |
| isStacked:false, | |
| isCumul:false, | |
| isArea:false, | |
| stats:{grand,avgPer,maxPer,periodCount:1,prevGrand:0,pct:null} | |
| }; | |
| }); | |
| } | |
| function processTemplate(template) { | |
| const kind = (template.kind||'transactions'); | |
| if(kind === 'account_groups') return processAccountGroupsTemplate(template); | |
| return processTransactionsTemplate(template); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // TOAST / KEY HANDLER | |
| // ───────────────────────────────────────────────────────────── | |
| function toast(msg,type) { | |
| type=type||'success'; | |
| const t=document.createElement('div'); | |
| t.style.cssText=`position:fixed;bottom:76px;right:20px;padding:10px 16px;border-radius:7px;font-size:13px;z-index:10010;background:${type==='error'?C.danger:type==='warning'?C.warning:C.success};color:#fff;box-shadow:0 4px 18px rgba(0,0,0,.3);animation:cjs-fade-in .25s ease;`; | |
| t.textContent=msg; document.body.appendChild(t); | |
| setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .25s';setTimeout(()=>t.remove(),260);},3000); | |
| } | |
| function onKey(e) { | |
| if(e.key==='Escape'){ | |
| e.preventDefault();e.stopPropagation(); | |
| if(isChartOpen) closeChart(); | |
| else if(isModalOpen) { document.querySelectorAll('.cjs-overlay').forEach(m=>m.remove()); isModalOpen=false; } | |
| else if(isPanelOpen) closePanel(); | |
| } | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // CATEGORY TAG-PILL PICKER (transactions) | |
| // ───────────────────────────────────────────────────────────── | |
| function makeCategoryPicker(categories, selectedIds, labelText) { | |
| selectedIds=selectedIds||[]; | |
| const catById={}; | |
| categories.forEach(c=>{catById[c.id]={...c};}); | |
| let selected=new Set(selectedIds); | |
| let hiIdx=-1, ddItems=[]; | |
| const wrap=document.createElement('div'); wrap.style.position='relative'; | |
| const lbl=document.createElement('label'); lbl.className='cjs-label'; lbl.textContent=labelText||'Categories (empty = all)'; wrap.appendChild(lbl); | |
| const area=document.createElement('div'); area.className='cjs-tag-area'; wrap.appendChild(area); | |
| const inp=document.createElement('input'); inp.type='text'; inp.className='cjs-tag-input'; inp.placeholder='Type to filter…'; | |
| const dd=document.createElement('div'); dd.className='cjs-tag-dropdown'; dd.style.display='none'; wrap.appendChild(dd); | |
| function getCol(id){return catById[id]?.color||pickColor(Object.keys(catById).indexOf(id));} | |
| function renderTags(){ | |
| area.innerHTML=''; | |
| selected.forEach(id=>{ | |
| const cat=catById[id]; if(!cat) return; | |
| const pill=document.createElement('div'); pill.className='cjs-tag'; | |
| const dot=document.createElement('span'); dot.className='dot'; dot.style.background=getCol(id); | |
| const nm=document.createElement('span'); nm.textContent=cat.name; | |
| const rm=document.createElement('button'); rm.textContent='×'; rm.title='Remove '+cat.name; | |
| rm.addEventListener('click',e=>{e.stopPropagation();selected.delete(id);renderTags();renderDD();}); | |
| pill.appendChild(dot);pill.appendChild(nm);pill.appendChild(rm);area.appendChild(pill); | |
| }); | |
| area.appendChild(inp); | |
| } | |
| function renderDD(){ | |
| const q=inp.value.trim().toLowerCase(); | |
| const parents=categories.filter(c=>!c.parent_id); | |
| const children=categories.filter(c=>c.parent_id); | |
| const ordered=[]; | |
| parents.sort((a,b)=>a.name.localeCompare(b.name)).forEach(p=>{ | |
| ordered.push(p); | |
| children.filter(c=>c.parent_id===p.id).sort((a,b)=>a.name.localeCompare(b.name)).forEach(c=>ordered.push(c)); | |
| }); | |
| children.filter(c=>!catById[c.parent_id]).forEach(c=>ordered.push(c)); | |
| const filtered=ordered.filter(c=>!selected.has(c.id)&&(!q||c.name.toLowerCase().includes(q))); | |
| dd.innerHTML=''; ddItems=[]; | |
| if(!filtered.length){const none=document.createElement('div');none.className='cjs-tag-opt';none.style.color=C.textMuted;none.textContent='No matches';dd.appendChild(none);return;} | |
| filtered.forEach((cat,i)=>{ | |
| const opt=document.createElement('div'); opt.className='cjs-tag-opt'+(i===hiIdx?' hi':''); | |
| const dot=document.createElement('span'); dot.className='dot'; dot.style.background=cat.color||pickColor(i); | |
| const nm=document.createElement('span'); nm.className='cjs-tag-opt-nm'; nm.textContent=(cat.parent_id?'↳ ':'')+cat.name; | |
| const par=document.createElement('span'); par.className='cjs-tag-opt-par'; | |
| if(cat.parent_id&&catById[cat.parent_id]) par.textContent=catById[cat.parent_id].name; | |
| opt.appendChild(dot);opt.appendChild(nm);opt.appendChild(par); | |
| opt.addEventListener('mousedown',e=>{e.preventDefault();selected.add(cat.id);inp.value='';renderTags();renderDD();inp.focus();}); | |
| dd.appendChild(opt);ddItems.push(opt); | |
| }); | |
| } | |
| function showDD(){dd.style.display='block';dd.style.top=(area.offsetHeight+2)+'px';hiIdx=-1;renderDD();} | |
| function hideDD(){dd.style.display='none';} | |
| inp.addEventListener('focus',showDD); | |
| inp.addEventListener('blur',()=>setTimeout(hideDD,150)); | |
| inp.addEventListener('input',()=>{hiIdx=-1;renderDD();}); | |
| inp.addEventListener('keydown',e=>{ | |
| if(e.key==='ArrowDown'){e.preventDefault();hiIdx=Math.min(hiIdx+1,ddItems.length-1);renderDD();} | |
| else if(e.key==='ArrowUp'){e.preventDefault();hiIdx=Math.max(hiIdx-1,0);renderDD();} | |
| else if((e.key==='Enter'||e.key==='Tab')&&hiIdx>=0&&ddItems[hiIdx]){e.preventDefault();ddItems[hiIdx].dispatchEvent(new MouseEvent('mousedown',{bubbles:true}));} | |
| else if(e.key==='Backspace'&&!inp.value&&selected.size>0){const last=[...selected].pop();selected.delete(last);renderTags();renderDD();} | |
| }); | |
| area.addEventListener('click',()=>inp.focus()); | |
| const clrBtn=document.createElement('button'); clrBtn.type='button'; clrBtn.className='cjs-btn cjs-btn-ghost cjs-btn-xs'; clrBtn.style.marginTop='5px'; clrBtn.textContent='Clear all'; | |
| clrBtn.addEventListener('click',()=>{selected=new Set();renderTags();renderDD();}); | |
| wrap.appendChild(clrBtn); | |
| renderTags(); | |
| wrap.getValue=()=>[...selected]; | |
| wrap.setValue=ids=>{selected=new Set(ids||[]);renderTags();renderDD();}; | |
| return wrap; | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // ACCOUNT TAG-PILL PICKER (for groups) | |
| // ───────────────────────────────────────────────────────────── | |
| function makeAccountPicker(accounts, selectedIds, labelText) { | |
| selectedIds=selectedIds||[]; | |
| const byId={}; | |
| accounts.forEach(a=>{byId[a.id]={...a};}); | |
| let selected=new Set(selectedIds); | |
| let hiIdx=-1, ddItems=[]; | |
| const wrap=document.createElement('div'); wrap.style.position='relative'; | |
| const lbl=document.createElement('label'); lbl.className='cjs-label'; lbl.textContent=labelText||'Accounts'; wrap.appendChild(lbl); | |
| const area=document.createElement('div'); area.className='cjs-tag-area'; wrap.appendChild(area); | |
| const inp=document.createElement('input'); inp.type='text'; inp.className='cjs-tag-input'; inp.placeholder='Type to filter…'; | |
| const dd=document.createElement('div'); dd.className='cjs-tag-dropdown'; dd.style.display='none'; wrap.appendChild(dd); | |
| function renderTags(){ | |
| area.innerHTML=''; | |
| selected.forEach(id=>{ | |
| const a=byId[id]; if(!a) return; | |
| const pill=document.createElement('div'); pill.className='cjs-tag'; | |
| const dot=document.createElement('span'); dot.className='dot'; dot.style.background=pickColor(Object.keys(byId).indexOf(id)); | |
| const nm=document.createElement('span'); nm.textContent=a.name; | |
| const rm=document.createElement('button'); rm.textContent='×'; rm.title='Remove '+a.name; | |
| rm.addEventListener('click',e=>{e.stopPropagation();selected.delete(id);renderTags();renderDD();}); | |
| pill.appendChild(dot);pill.appendChild(nm);pill.appendChild(rm);area.appendChild(pill); | |
| }); | |
| area.appendChild(inp); | |
| } | |
| function renderDD(){ | |
| const q=inp.value.trim().toLowerCase(); | |
| const ordered=[...accounts].sort((a,b)=>a.name.localeCompare(b.name)); | |
| const filtered=ordered.filter(a=>!selected.has(a.id)&&(!q||a.name.toLowerCase().includes(q))); | |
| dd.innerHTML=''; ddItems=[]; | |
| if(!filtered.length){const none=document.createElement('div');none.className='cjs-tag-opt';none.style.color=C.textMuted;none.textContent='No matches';dd.appendChild(none);return;} | |
| filtered.forEach((a,i)=>{ | |
| const opt=document.createElement('div'); opt.className='cjs-tag-opt'+(i===hiIdx?' hi':''); | |
| const dot=document.createElement('span'); dot.className='dot'; dot.style.background=pickColor(i); | |
| const nm=document.createElement('span'); nm.className='cjs-tag-opt-nm'; nm.textContent=a.name; | |
| const par=document.createElement('span'); par.className='cjs-tag-opt-par'; par.textContent=(a.account_type||a.classification||''); | |
| opt.appendChild(dot);opt.appendChild(nm);opt.appendChild(par); | |
| opt.addEventListener('mousedown',e=>{e.preventDefault();selected.add(a.id);inp.value='';renderTags();renderDD();inp.focus();}); | |
| dd.appendChild(opt);ddItems.push(opt); | |
| }); | |
| } | |
| function showDD(){dd.style.display='block';dd.style.top=(area.offsetHeight+2)+'px';hiIdx=-1;renderDD();} | |
| function hideDD(){dd.style.display='none';} | |
| inp.addEventListener('focus',showDD); | |
| inp.addEventListener('blur',()=>setTimeout(hideDD,150)); | |
| inp.addEventListener('input',()=>{hiIdx=-1;renderDD();}); | |
| inp.addEventListener('keydown',e=>{ | |
| if(e.key==='ArrowDown'){e.preventDefault();hiIdx=Math.min(hiIdx+1,ddItems.length-1);renderDD();} | |
| else if(e.key==='ArrowUp'){e.preventDefault();hiIdx=Math.max(hiIdx-1,0);renderDD();} | |
| else if((e.key==='Enter'||e.key==='Tab')&&hiIdx>=0&&ddItems[hiIdx]){e.preventDefault();ddItems[hiIdx].dispatchEvent(new MouseEvent('mousedown',{bubbles:true}));} | |
| else if(e.key==='Backspace'&&!inp.value&&selected.size>0){const last=[...selected].pop();selected.delete(last);renderTags();renderDD();} | |
| }); | |
| area.addEventListener('click',()=>inp.focus()); | |
| const clrBtn=document.createElement('button'); clrBtn.type='button'; clrBtn.className='cjs-btn cjs-btn-ghost cjs-btn-xs'; clrBtn.style.marginTop='5px'; clrBtn.textContent='Clear'; | |
| clrBtn.addEventListener('click',()=>{selected=new Set();renderTags();renderDD();}); | |
| wrap.appendChild(clrBtn); | |
| renderTags(); | |
| wrap.getValue=()=>[...selected]; | |
| wrap.setValue=(ids)=>{selected=new Set(ids||[]);renderTags();renderDD();}; | |
| wrap.setDisabled=(dis)=>{inp.disabled=!!dis; area.style.opacity=dis?0.55:1;}; | |
| return wrap; | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // INLINE DATE RANGE PICKER (transactions) | |
| // ───────────────────────────────────────────────────────────── | |
| const PERIOD_PRESETS=[ | |
| {label:'Last 7d',value:'week'},{label:'Last 30d',value:'last30'}, | |
| {label:'Last 90d',value:'last90'},{label:'This Month',value:'month'}, | |
| {label:'This Quarter',value:'quarter'},{label:'This Year',value:'year'}, | |
| {label:'Last 6M',value:'last6m'},{label:'Last 12M',value:'last12m'}, | |
| {label:'Custom',value:'custom'}, | |
| ]; | |
| function makeDatePicker(template) { | |
| const wrap=document.createElement('div'); | |
| const chips=document.createElement('div'); chips.className='cjs-period-chips'; | |
| PERIOD_PRESETS.forEach(p=>{ | |
| const c=document.createElement('button'); c.type='button'; c.className='cjs-period-chip'+(template.periodType===p.value?' active':''); c.textContent=p.label; c.dataset.val=p.value; | |
| c.addEventListener('click',()=>{ | |
| chips.querySelectorAll('.cjs-period-chip').forEach(x=>x.classList.remove('active')); c.classList.add('active'); | |
| customRow.style.display=p.value==='custom'?'flex':'none'; wrap._period=p.value; | |
| }); | |
| chips.appendChild(c); | |
| }); | |
| const customRow=document.createElement('div'); customRow.className='cjs-daterange'; customRow.style.display=template.periodType==='custom'?'flex':'none'; | |
| const sIn=document.createElement('input'); sIn.type='date'; sIn.value=template.customStartDate||''; sIn.id='cjs-dp-start'; | |
| const sep=document.createElement('span'); sep.className='cjs-daterange-sep'; sep.textContent='→'; | |
| const eIn=document.createElement('input'); eIn.type='date'; eIn.value=template.customEndDate||''; eIn.id='cjs-dp-end'; | |
| customRow.appendChild(sIn); customRow.appendChild(sep); customRow.appendChild(eIn); | |
| wrap.appendChild(chips); wrap.appendChild(customRow); | |
| wrap._period=template.periodType||'month'; | |
| wrap.getValue=()=>({periodType:wrap._period,customStartDate:sIn.value||null,customEndDate:eIn.value||null}); | |
| return wrap; | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // PANEL + LIST RENDER (same behavior as earlier version) | |
| // ───────────────────────────────────────────────────────────── | |
| const TICONS={line:'📈',bar:'📊',doughnut:'🍩','bar-cumulative':'📶','bar-stacked':'🗂',area:'🌊',scatter:'✦',bubble:'⬤'}; | |
| const KINDICONS={transactions:'🧾',account_groups:'🏦'}; | |
| const QPERIODS=[{l:'Month',v:'month'},{l:'Quarter',v:'quarter'},{l:'Year',v:'year'},{l:'Last 30d',v:'last30'},{l:'Last 12M',v:'last12m'}]; | |
| function createPanel() { | |
| if(document.getElementById('cjs-root')) return; | |
| injectStyles(); | |
| const root=document.createElement('div'); root.id='cjs-root'; | |
| const fab=document.createElement('button'); fab.className='cjs-fab'; fab.title='Chart Templates (Alt+C)'; | |
| fab.innerHTML=`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg> Charts`; | |
| fab.addEventListener('click',togglePanel); | |
| const panel=document.createElement('div'); panel.id='cjs-panel'; panel.className='cjs-panel'; | |
| // header | |
| const hd=document.createElement('div'); hd.className='cjs-panel-header'; | |
| const htitle=document.createElement('h2'); htitle.textContent='Chart Templates'; | |
| const hclose=document.createElement('button'); hclose.className='cjs-btn-icon'; hclose.innerHTML=svgX(20); hclose.addEventListener('click',closePanel); | |
| hd.appendChild(htitle); hd.appendChild(hclose); | |
| // search | |
| const srchSect=document.createElement('div'); srchSect.className='cjs-panel-search'; | |
| const srchWrap=document.createElement('div'); srchWrap.className='cjs-search-wrap'; | |
| const srchIco=document.createElement('span'); srchIco.className='cjs-search-icon'; | |
| srchIco.innerHTML=`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>`; | |
| const srchIn=document.createElement('input'); srchIn.type='text'; srchIn.className='cjs-search-input'; srchIn.placeholder='Search templates…'; | |
| srchIn.addEventListener('input',e=>{templateSearch=e.target.value.toLowerCase();renderList();}); | |
| srchWrap.appendChild(srchIco); srchWrap.appendChild(srchIn); srchSect.appendChild(srchWrap); | |
| // toolbar | |
| const tb=document.createElement('div'); tb.className='cjs-panel-toolbar'; | |
| const cntLbl=document.createElement('span'); cntLbl.id='cjs-tpl-count'; cntLbl.style.cssText=`font-size:11px;color:${C.textMuted};`; | |
| const vBtns=document.createElement('div'); vBtns.className='cjs-flex cjs-gap-1'; | |
| const bCards=document.createElement('button'); bCards.className='cjs-btn-icon active-blue'; bCards.title='Card view'; | |
| bCards.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>`; | |
| const bList=document.createElement('button'); bList.className='cjs-btn-icon'; bList.title='List view'; | |
| bList.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`; | |
| bCards.addEventListener('click',()=>{panelViewMode='cards';bCards.classList.add('active-blue');bList.classList.remove('active-blue');renderList();}); | |
| bList.addEventListener('click', ()=>{panelViewMode='list'; bList.classList.add('active-blue'); bCards.classList.remove('active-blue');renderList();}); | |
| vBtns.appendChild(bCards); vBtns.appendChild(bList); tb.appendChild(cntLbl); tb.appendChild(vBtns); | |
| // body | |
| const body=document.createElement('div'); body.className='cjs-panel-body'; | |
| const listEl=document.createElement('div'); listEl.id='cjs-tpl-list'; listEl.className='cjs-space-y'; body.appendChild(listEl); | |
| // footer | |
| const ft=document.createElement('div'); ft.className='cjs-panel-footer cjs-space-y-sm'; | |
| const settBtn=document.createElement('button'); settBtn.className='cjs-btn cjs-btn-secondary cjs-btn-sm cjs-w-full'; | |
| settBtn.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v6m0 6v6m11-7h-6m-6 0H1"/></svg> Settings`; | |
| settBtn.addEventListener('click',openSettings); | |
| const newBtn=document.createElement('button'); newBtn.className='cjs-btn cjs-btn-primary cjs-w-full'; | |
| newBtn.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg> New Template`; | |
| newBtn.addEventListener('click',()=>openEditor()); | |
| const row2=document.createElement('div'); row2.className='cjs-flex cjs-gap-2'; | |
| const expBtn=document.createElement('button'); expBtn.className='cjs-btn cjs-btn-secondary cjs-btn-sm cjs-flex-1'; expBtn.textContent='Export'; expBtn.addEventListener('click',exportTemplates); | |
| const impLbl=document.createElement('label'); impLbl.className='cjs-btn cjs-btn-secondary cjs-btn-sm cjs-flex-1'; impLbl.style.cursor='pointer'; impLbl.textContent='Import'; | |
| const impIn=document.createElement('input'); impIn.type='file'; impIn.accept='.json'; impIn.style.display='none'; | |
| impIn.addEventListener('change',e=>{if(e.target.files[0])importTemplates(e.target.files[0]);e.target.value='';}); | |
| impLbl.appendChild(impIn); row2.appendChild(expBtn); row2.appendChild(impLbl); | |
| ft.appendChild(settBtn); ft.appendChild(newBtn); ft.appendChild(row2); | |
| panel.appendChild(hd); panel.appendChild(srchSect); panel.appendChild(tb); panel.appendChild(body); panel.appendChild(ft); | |
| const mc=document.createElement('div'); mc.id='cjs-modal-container'; | |
| const cc=document.createElement('div'); cc.id='cjs-chart-container'; | |
| root.appendChild(fab); root.appendChild(panel); root.appendChild(mc); root.appendChild(cc); | |
| document.body.appendChild(root); | |
| document.addEventListener('keydown',onKey); | |
| document.addEventListener('keydown',e=>{if(e.altKey&&e.key==='c'){e.preventDefault();togglePanel();}}); | |
| renderList(); | |
| } | |
| function togglePanel(){isPanelOpen?closePanel():(document.getElementById('cjs-panel').classList.add('open'),isPanelOpen=true);} | |
| function closePanel(){document.getElementById('cjs-panel')?.classList.remove('open');isPanelOpen=false;} | |
| function renderList(){ | |
| const listEl=document.getElementById('cjs-tpl-list'); | |
| const cntEl =document.getElementById('cjs-tpl-count'); | |
| if(!listEl) return; | |
| const d=getData(); | |
| let tpls=d.templates; | |
| if(templateSearch) { | |
| tpls=tpls.filter(t=>{ | |
| const k=(t.kind||'transactions'); | |
| const hay=(t.name+' '+t.chartType+' '+(t.dataType||'')+' '+k).toLowerCase(); | |
| return hay.includes(templateSearch); | |
| }); | |
| } | |
| tpls=[...tpls.filter(t=>t.isPinned),...tpls.filter(t=>!t.isPinned&&t.isDefault),...tpls.filter(t=>!t.isPinned&&!t.isDefault)]; | |
| if(cntEl) cntEl.textContent=`${tpls.length} template${tpls.length!==1?'s':''}`; | |
| listEl.innerHTML=''; | |
| if(!tpls.length){ | |
| const e=document.createElement('div');e.className='cjs-empty'; | |
| e.innerHTML=`<div class="cjs-empty-icon">📊</div><div style="font-size:13px;">${templateSearch?'No match':'No templates yet'}</div><div style="font-size:11px;margin-top:4px;color:${C.border};">${templateSearch?'Try a different search':'Create your first template!'}</div>`; | |
| listEl.appendChild(e);return; | |
| } | |
| if(panelViewMode==='list'){tpls.forEach(t=>listEl.appendChild(buildRowCard(t)));return;} | |
| const pinned=tpls.filter(t=>t.isPinned),rest=tpls.filter(t=>!t.isPinned); | |
| if(pinned.length){ | |
| const l=document.createElement('div');l.className='cjs-sec-lbl';l.textContent='⭐ Pinned';listEl.appendChild(l); | |
| pinned.forEach(t=>listEl.appendChild(buildCard(t))); | |
| } | |
| if(rest.length){ | |
| if(pinned.length){const l=document.createElement('div');l.className='cjs-sec-lbl';l.textContent='All Templates';listEl.appendChild(l);} | |
| rest.forEach(t=>listEl.appendChild(buildCard(t))); | |
| } | |
| } | |
| function mkBadge(text,type){ | |
| const b=document.createElement('span'); | |
| b.className='cjs-badge'+(type==='warn'?' cjs-badge-warn':type==='blue'?' cjs-badge-blue':''); | |
| b.textContent=text;return b; | |
| } | |
| function buildCard(t){ | |
| const card=document.createElement('div');card.className='cjs-card'+(t.isPinned?' pinned':''); | |
| const top=document.createElement('div');top.className='cjs-flex cjs-justify-between cjs-items-center';top.style.marginBottom='3px'; | |
| const kind = (t.kind||'transactions'); | |
| const nm=document.createElement('span');nm.className='cjs-truncate'; | |
| nm.style.cssText=`font-weight:600;font-size:13px;color:${C.textPrimary};flex:1;min-width:0;`; | |
| nm.title=t.name; | |
| nm.textContent=(KINDICONS[kind]||'📊')+' '+(TICONS[t.chartType]||'📊')+' '+t.name; | |
| const bds=document.createElement('div');bds.className='cjs-flex cjs-items-center cjs-gap-1';bds.style.flexShrink='0'; | |
| if(t.isPinned) bds.appendChild(mkBadge('⭐','warn')); | |
| if(t.isDefault) bds.appendChild(mkBadge('Built-in','')); | |
| if(kind==='transactions' && t.compareWithPrevious) bds.appendChild(mkBadge('± prev','blue')); | |
| if(t.autoRefreshMinutes>0)bds.appendChild(mkBadge(`🔄${t.autoRefreshMinutes}m`,'')); | |
| top.appendChild(nm);top.appendChild(bds); | |
| const sub=document.createElement('p');sub.style.cssText=`margin:0 0 7px;font-size:11px;color:${C.textSecondary};`; | |
| if(kind==='account_groups') { | |
| const n = (Array.isArray(t.groups)?t.groups.length:0); | |
| sub.textContent=`account balances · ${t.chartType} · ${n} group${n===1?'':'s'}`; | |
| } else { | |
| sub.textContent=`transactions · ${t.chartType} · ${t.dataType} · ${t.periodType}`+((t.includedCategories?.length||t.includedAccounts?.length)?' · filtered':''); | |
| } | |
| const chips=document.createElement('div');chips.className='cjs-quick-chips'; | |
| if(kind==='transactions'){ | |
| QPERIODS.forEach(qp=>{ | |
| const c=document.createElement('button');c.type='button';c.className='cjs-qchip';c.textContent=qp.l;c.title=`Run "${t.name}" – ${qp.l}`; | |
| c.addEventListener('click',e=>{e.stopPropagation();applyOverride(t.id,{periodType:qp.v});}); | |
| chips.appendChild(c); | |
| }); | |
| } else { | |
| const c=document.createElement('div'); | |
| c.style.cssText=`font-size:11px;color:${C.textMuted};padding:2px 0;`; | |
| c.textContent='(current balances)'; | |
| chips.appendChild(c); | |
| } | |
| const acts=document.createElement('div');acts.className='cjs-flex cjs-gap-1';acts.style.marginTop='9px'; | |
| const apB=document.createElement('button');apB.className='cjs-btn cjs-btn-primary cjs-btn-sm cjs-flex-1';apB.innerHTML='▶ Apply';apB.addEventListener('click',()=>applyTemplate(t.id)); | |
| const edB=document.createElement('button');edB.className='cjs-btn cjs-btn-secondary cjs-btn-sm';edB.title='Edit'; | |
| edB.innerHTML=`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`; | |
| edB.addEventListener('click',()=>openEditor(t.id)); | |
| const pnB=document.createElement('button');pnB.className='cjs-btn-icon'+(t.isPinned?' active':'');pnB.title=t.isPinned?'Unpin':'Pin'; | |
| pnB.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="${t.isPinned?C.warning:'none'}" stroke="${t.isPinned?C.warning:C.textSecondary}" stroke-width="2"><polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/></svg>`; | |
| pnB.addEventListener('click',()=>togglePin(t.id)); | |
| acts.appendChild(apB);acts.appendChild(edB);acts.appendChild(pnB); | |
| if(!t.isDefault){ | |
| const del=document.createElement('button');del.className='cjs-btn-icon';del.title='Delete'; | |
| del.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="${C.danger}" stroke-width="2.2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M9 6V4h6v2"/></svg>`; | |
| del.addEventListener('click',()=>delTpl(t.id)); | |
| acts.appendChild(del); | |
| } | |
| card.appendChild(top);card.appendChild(sub);card.appendChild(chips);card.appendChild(acts); | |
| return card; | |
| } | |
| function buildRowCard(t){ | |
| const row=document.createElement('div');row.className='cjs-card-row'; | |
| const kind=(t.kind||'transactions'); | |
| const ico=document.createElement('span');ico.style.fontSize='15px'; | |
| ico.textContent=(KINDICONS[kind]||'📊'); | |
| const inf=document.createElement('div');inf.className='cjs-flex-1';inf.style.minWidth='0'; | |
| const n=document.createElement('div');n.className='cjs-truncate';n.style.cssText=`font-size:13px;font-weight:600;color:${C.textPrimary};`;n.title=t.name;n.textContent=t.name; | |
| const s=document.createElement('div');s.style.cssText=`font-size:11px;color:${C.textSecondary};`; | |
| s.textContent=(kind==='account_groups') | |
| ? `accounts · ${(Array.isArray(t.groups)?t.groups.length:0)} groups` | |
| : `${t.dataType} · ${t.periodType}`; | |
| inf.appendChild(n);inf.appendChild(s); | |
| const btns=document.createElement('div');btns.className='cjs-flex cjs-gap-1 cjs-items-center'; | |
| const apB=document.createElement('button');apB.className='cjs-btn cjs-btn-primary cjs-btn-xs';apB.textContent='▶';apB.addEventListener('click',()=>applyTemplate(t.id)); | |
| const edB=document.createElement('button');edB.className='cjs-btn-icon'; | |
| edB.innerHTML=`<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`; | |
| edB.addEventListener('click',()=>openEditor(t.id)); | |
| const pnB=document.createElement('button');pnB.className='cjs-btn-icon'+(t.isPinned?' active':'');pnB.innerHTML='⭐';pnB.style.fontSize='11px';pnB.addEventListener('click',()=>togglePin(t.id)); | |
| btns.appendChild(apB);btns.appendChild(edB);btns.appendChild(pnB); | |
| row.appendChild(ico);row.appendChild(inf);row.appendChild(btns); | |
| return row; | |
| } | |
| function togglePin(id){ | |
| const d=getData(); | |
| const t=d.templates.find(x=>x.id===id); | |
| if(t){t.isPinned=!t.isPinned;saveData(d);renderList();toast(t.isPinned?'⭐ Pinned':'Unpinned');} | |
| } | |
| function delTpl(id){ | |
| if(!confirm('Delete this template?'))return; | |
| const d=getData(); | |
| d.templates=d.templates.filter(t=>t.id!==id); | |
| saveData(d);renderList();toast('Template deleted'); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // EXPORT / IMPORT | |
| // ───────────────────────────────────────────────────────────── | |
| function exportTemplates(){ | |
| const d=getData(); | |
| const blob=new Blob([JSON.stringify({version:'5.4.1',exportedAt:new Date().toISOString(),templates:d.templates.filter(t=>!t.isDefault)},null,2)],{type:'application/json'}); | |
| const a=document.createElement('a'); | |
| a.href=URL.createObjectURL(blob); | |
| a.download='sure-charts-'+new Date().toISOString().split('T')[0]+'.json'; | |
| a.click();URL.revokeObjectURL(a.href); | |
| } | |
| function importTemplates(file){ | |
| const r=new FileReader(); | |
| r.onload=e=>{ | |
| try{ | |
| const imp=JSON.parse(e.target.result); | |
| const d=getData(); | |
| (imp.templates||[]).forEach(t=>{ | |
| t.isDefault=false; | |
| t.id=t.id||('custom_'+Date.now()); | |
| const i=d.templates.findIndex(x=>x.id===t.id); | |
| i>=0 ? d.templates[i]=t : d.templates.push(t); | |
| }); | |
| saveData(d);renderList();toast('Imported!'); | |
| }catch{ | |
| toast('Invalid file','error'); | |
| } | |
| }; | |
| r.readAsText(file); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // EDITOR MODAL (transactions + account groups) | |
| // ───────────────────────────────────────────────────────────── | |
| function openEditor(templateId){ | |
| const d=getData(); | |
| const ex=templateId?d.templates.find(t=>t.id===templateId):null; | |
| const isNew=!ex; | |
| const fd = ex || { | |
| id:'custom_'+Date.now(), | |
| name:'', | |
| kind:'transactions', | |
| // tx defaults | |
| chartType:'line', | |
| dataType:'expense', | |
| groupBy:'week', | |
| periodType:'month', | |
| customStartDate:'', | |
| customEndDate:'', | |
| maxCategories:8, | |
| showSubcategories:false, | |
| includedCategories:[], | |
| includedAccounts:[], | |
| compareWithPrevious:false, | |
| // accounts defaults (all accounts so it shows) | |
| groups:[{ id:'g1', name:'All accounts', all:true, accountIds:[] }], | |
| // common | |
| isDefault:false, | |
| isPinned:false, | |
| autoRefreshMinutes:0, | |
| }; | |
| const ov=document.createElement('div');ov.className='cjs-overlay';ov.id='cjs-editor-modal'; | |
| ov.addEventListener('click',e=>{if(e.target===ov)closeEditor();}); | |
| const modal=document.createElement('div');modal.className='cjs-modal'; | |
| const mhd=document.createElement('div');mhd.className='cjs-modal-hd'; | |
| const mT=document.createElement('h3');mT.textContent=isNew?'New Template':'Edit Template'; | |
| const mX=document.createElement('button');mX.className='cjs-btn-icon';mX.innerHTML=svgX(18);mX.addEventListener('click',closeEditor); | |
| mhd.appendChild(mT);mhd.appendChild(mX); | |
| const mbd=document.createElement('div');mbd.className='cjs-modal-bd cjs-space-y'; | |
| function fg(lbl,el){const g=document.createElement('div');const l=document.createElement('label');l.className='cjs-label';l.textContent=lbl;g.appendChild(l);g.appendChild(el);return g;} | |
| // Name | |
| const nameIn=document.createElement('input');nameIn.type='text';nameIn.id='cjs-f-name';nameIn.className='cjs-input';nameIn.value=fd.name;nameIn.placeholder='My Custom Chart'; | |
| mbd.appendChild(fg('Template Name *',nameIn)); | |
| // Kind selector | |
| const kindSel = mkSel('cjs-f-kind', [ | |
| ['transactions','🧾 Transactions'], | |
| ['account_groups','🏦 Account balances (N groups)'], | |
| ], fd.kind||'transactions'); | |
| mbd.appendChild(fg('Template Type', kindSel)); | |
| // Help | |
| const help=document.createElement('div');help.className='cjs-help'; | |
| help.innerHTML = ` | |
| <b>Account balances templates</b> use <code>/api/v1/accounts</code> and chart <b>current balances</b> grouped into <b>N groups</b>. | |
| <br/>Each group can be a selection (A+B, C+D, …) or <b>ALL accounts</b>. | |
| `; | |
| mbd.appendChild(help); | |
| // Chart type | |
| const ctSel = mkSel('cjs-f-ct', [ | |
| ['line','📈 Line'], | |
| ['area','🌊 Area'], | |
| ['bar','📊 Bar'], | |
| ['bar-stacked','🗂 Stacked Bar'], | |
| ['bar-cumulative','📶 Cumulative Bar'], | |
| ['doughnut','🍩 Doughnut'], | |
| ], fd.chartType); | |
| const commonRow=document.createElement('div'); commonRow.className='cjs-grid-2'; commonRow.id='cjs-common-row'; | |
| commonRow.appendChild(fg('Chart Type', ctSel)); | |
| commonRow.appendChild(fg('Group By', mkSel('cjs-f-gb',[['day','Day'],['week','Week'],['month','Month']],fd.groupBy))); | |
| mbd.appendChild(commonRow); | |
| // Date picker (transactions only) | |
| const dpLbl=document.createElement('label');dpLbl.className='cjs-label';dpLbl.textContent='Time Period'; | |
| const dp=makeDatePicker(fd); | |
| const dpWrap=document.createElement('div');dpWrap.id='cjs-dp-wrap'; dpWrap.appendChild(dpLbl); dpWrap.appendChild(dp); | |
| mbd.appendChild(dpWrap); | |
| // Options | |
| const optCard=document.createElement('div');optCard.className='cjs-card';optCard.style.background=C.bgSecondary; | |
| const optT=document.createElement('p');optT.className='cjs-label';optT.style.marginBottom='8px';optT.textContent='Options'; | |
| optCard.appendChild(optT); | |
| const cmpRow=document.createElement('div');cmpRow.className='cjs-flex cjs-items-center cjs-gap-2';cmpRow.style.marginBottom='7px'; | |
| const cmpCb=document.createElement('input');cmpCb.type='checkbox';cmpCb.id='cjs-f-cmp';cmpCb.className='cjs-checkbox';cmpCb.checked=!!fd.compareWithPrevious; | |
| const cmpLbl=document.createElement('label');cmpLbl.htmlFor='cjs-f-cmp';cmpLbl.style.cssText=`font-size:13px;color:${C.textSecondary};cursor:pointer;`;cmpLbl.textContent='Compare with previous period'; | |
| cmpRow.appendChild(cmpCb);cmpRow.appendChild(cmpLbl);optCard.appendChild(cmpRow); | |
| const pinRow=document.createElement('div');pinRow.className='cjs-flex cjs-items-center cjs-gap-2';pinRow.style.marginBottom='7px'; | |
| const pinCb=document.createElement('input');pinCb.type='checkbox';pinCb.id='cjs-f-pin';pinCb.className='cjs-checkbox';pinCb.checked=!!fd.isPinned; | |
| const pinLbl=document.createElement('label');pinLbl.htmlFor='cjs-f-pin';pinLbl.style.cssText=`font-size:13px;color:${C.textSecondary};cursor:pointer;`;pinLbl.textContent='Pin to top'; | |
| pinRow.appendChild(pinCb);pinRow.appendChild(pinLbl);optCard.appendChild(pinRow); | |
| const rfRow=document.createElement('div');rfRow.className='cjs-grid-2'; | |
| rfRow.appendChild(fg('Auto-refresh', mkSel('cjs-f-rf',[[0,'Off'],[1,'1 min'],[5,'5 min'],[10,'10 min'],[15,'15 min'],[30,'30 min'],[60,'60 min']],fd.autoRefreshMinutes))); | |
| optCard.appendChild(rfRow); | |
| mbd.appendChild(optCard); | |
| // TRANSACTIONS SECTION | |
| const txSection=document.createElement('div'); txSection.className='cjs-space-y'; | |
| const txRow1=document.createElement('div');txRow1.className='cjs-grid-2'; | |
| txRow1.appendChild(fg('Data Type',mkSel('cjs-f-dt',[['expense','Expenses'],['income','Income'],['all','All']],fd.dataType))); | |
| const maxIn=document.createElement('input');maxIn.type='number';maxIn.id='cjs-f-mc';maxIn.className='cjs-input';maxIn.value=fd.maxCategories;maxIn.min='1';maxIn.max='20'; | |
| txRow1.appendChild(fg('Max Categories',maxIn)); | |
| txSection.appendChild(txRow1); | |
| const txOptCard=document.createElement('div');txOptCard.className='cjs-card';txOptCard.style.background=C.bgSecondary; | |
| const txOptT=document.createElement('p');txOptT.className='cjs-label';txOptT.style.marginBottom='8px';txOptT.textContent='Transaction Options'; | |
| txOptCard.appendChild(txOptT); | |
| const subRow=document.createElement('div');subRow.className='cjs-flex cjs-items-center cjs-gap-2'; | |
| const subCb=document.createElement('input');subCb.type='checkbox';subCb.id='cjs-f-sub';subCb.className='cjs-checkbox';subCb.checked=!!fd.showSubcategories; | |
| const subLbl=document.createElement('label');subLbl.htmlFor='cjs-f-sub';subLbl.style.cssText=`font-size:13px;color:${C.textSecondary};cursor:pointer;`;subLbl.textContent='Show subcategories'; | |
| subRow.appendChild(subCb);subRow.appendChild(subLbl);txOptCard.appendChild(subRow); | |
| txSection.appendChild(txOptCard); | |
| // Category picker | |
| let catPicker=null; | |
| const catSect=document.createElement('div'); | |
| catSect.innerHTML=`<div style="display:flex;align-items:center;gap:8px;padding:6px;color:${C.textMuted};font-size:12px;"><div class="cjs-spinner-sm"></div> Loading categories…</div>`; | |
| txSection.appendChild(catSect); | |
| // Accounts select (tx filter) | |
| let accSel=null; | |
| const accSect=document.createElement('div'); | |
| accSect.innerHTML=`<div style="display:flex;align-items:center;gap:8px;padding:6px;color:${C.textMuted};font-size:12px;"><div class="cjs-spinner-sm"></div> Loading accounts…</div>`; | |
| txSection.appendChild(accSect); | |
| // ACCOUNT GROUPS SECTION | |
| const acctSection=document.createElement('div'); acctSection.className='cjs-space-y'; | |
| const acctHead=document.createElement('div'); | |
| acctHead.className='cjs-flex cjs-justify-between cjs-items-center'; | |
| acctHead.innerHTML = `<div class="cjs-sec-lbl" style="padding:0;margin:0;">Groups</div>`; | |
| const addGrp=document.createElement('button'); | |
| addGrp.type='button'; | |
| addGrp.className='cjs-btn cjs-btn-secondary cjs-btn-sm'; | |
| addGrp.innerHTML=`<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg> Add Group`; | |
| acctHead.appendChild(addGrp); | |
| acctSection.appendChild(acctHead); | |
| const grpList=document.createElement('div'); grpList.className='cjs-space-y-sm'; | |
| acctSection.appendChild(grpList); | |
| // group state | |
| let accountsForPickers = null; | |
| let groupState = (Array.isArray(fd.groups) && fd.groups.length) | |
| ? fd.groups.map((g,i)=>({ | |
| id: g.id || ('g'+(i+1)+'_'+Date.now()), | |
| name: g.name || `Group ${i+1}`, | |
| all: !!g.all, | |
| accountIds: Array.isArray(g.accountIds) ? g.accountIds.slice() : [] | |
| })) | |
| : [{ id:'g1_'+Date.now(), name:'All accounts', all:true, accountIds:[] }]; | |
| const groupUIs = new Map(); // id -> {nameInput, allCb, picker} | |
| function renderGroups() { | |
| grpList.innerHTML=''; | |
| groupUIs.clear(); | |
| groupState.forEach((g, idx)=>{ | |
| const row=document.createElement('div'); row.className='cjs-grp-row'; | |
| const top=document.createElement('div'); top.className='cjs-grp-top'; | |
| const dot=document.createElement('div'); dot.className='cjs-grp-dot'; dot.style.background = pickColor(idx); | |
| const name=document.createElement('input'); name.type='text'; name.className='cjs-input cjs-flex-1'; | |
| name.placeholder='Group name'; | |
| name.value = g.name || `Group ${idx+1}`; | |
| const acts=document.createElement('div'); acts.className='cjs-grp-actions'; | |
| const rm=document.createElement('button'); rm.type='button'; rm.className='cjs-btn cjs-btn-danger cjs-btn-sm'; | |
| rm.textContent='Remove'; | |
| rm.disabled = groupState.length<=1; | |
| rm.addEventListener('click',()=>{ | |
| if(groupState.length<=1) return; | |
| groupState = groupState.filter(x=>x.id!==g.id); | |
| renderGroups(); | |
| }); | |
| acts.appendChild(rm); | |
| top.appendChild(dot); | |
| top.appendChild(name); | |
| top.appendChild(acts); | |
| const allRow=document.createElement('div'); allRow.className='cjs-flex cjs-items-center cjs-gap-2'; | |
| allRow.style.margin='10px 0 6px'; | |
| const allCb=document.createElement('input'); allCb.type='checkbox'; allCb.className='cjs-checkbox'; | |
| allCb.checked = !!g.all; | |
| const allLbl=document.createElement('label'); allLbl.style.cssText=`font-size:13px;color:${C.textSecondary};cursor:pointer;`; | |
| allLbl.textContent='Use ALL accounts (ignore selection)'; | |
| allRow.appendChild(allCb); | |
| allRow.appendChild(allLbl); | |
| const pickerWrap=document.createElement('div'); | |
| if(!accountsForPickers) { | |
| pickerWrap.innerHTML=`<div style="display:flex;align-items:center;gap:8px;padding:6px;color:${C.textMuted};font-size:12px;"><div class="cjs-spinner-sm"></div> Loading accounts…</div>`; | |
| } else { | |
| const picker = makeAccountPicker(accountsForPickers, g.accountIds, 'Accounts in this group'); | |
| picker.setDisabled(!!g.all); | |
| pickerWrap.appendChild(picker); | |
| groupUIs.set(g.id, { nameInput:name, allCb, picker }); | |
| allCb.addEventListener('change',()=>{ | |
| const ui = groupUIs.get(g.id); | |
| if(ui) ui.picker.setDisabled(allCb.checked); | |
| }); | |
| } | |
| row.appendChild(top); | |
| row.appendChild(allRow); | |
| row.appendChild(pickerWrap); | |
| grpList.appendChild(row); | |
| }); | |
| } | |
| addGrp.addEventListener('click',()=>{ | |
| const i = groupState.length+1; | |
| groupState.push({ id:'g'+i+'_'+Date.now(), name:`Group ${i}`, all:false, accountIds:[] }); | |
| renderGroups(); | |
| }); | |
| function enforceChartTypesForKind(kind) { | |
| const isAcct = (kind==='account_groups'); | |
| const allowedAcct = new Set(['bar','doughnut']); | |
| Array.from(ctSel.options).forEach(o=>{ | |
| if(isAcct) o.disabled = !allowedAcct.has(o.value); | |
| else o.disabled = false; | |
| }); | |
| if(isAcct && !allowedAcct.has(ctSel.value)) ctSel.value = 'bar'; | |
| } | |
| // Load pickers data | |
| Promise.all([fetchCategories().catch(()=>[]), fetchAccounts().catch(()=>[])]) | |
| .then(([cats,accs])=>{ | |
| // tx pickers | |
| catPicker=makeCategoryPicker(cats,fd.includedCategories,'Categories (empty = all)'); | |
| catSect.innerHTML=''; catSect.appendChild(catPicker); | |
| const aLbl=document.createElement('label');aLbl.className='cjs-label';aLbl.textContent='Accounts filter (empty = all)'; | |
| accSel=document.createElement('select');accSel.id='cjs-f-acc';accSel.className='cjs-select';accSel.multiple=true; | |
| accs.sort((a,b)=>a.name.localeCompare(b.name)).forEach(a=>{ | |
| const o=document.createElement('option');o.value=a.id;o.textContent=a.name; | |
| if(fd.includedAccounts?.includes(a.id))o.selected=true; | |
| accSel.appendChild(o); | |
| }); | |
| const clrA=document.createElement('button');clrA.type='button';clrA.className='cjs-btn cjs-btn-ghost cjs-btn-xs';clrA.style.marginTop='4px'; | |
| clrA.textContent='Clear';clrA.addEventListener('click',()=>{Array.from(accSel.options).forEach(o=>o.selected=false);}); | |
| accSect.innerHTML=''; accSect.appendChild(aLbl); accSect.appendChild(accSel); accSect.appendChild(clrA); | |
| // account group pickers | |
| accountsForPickers = accs; | |
| renderGroups(); | |
| }); | |
| mbd.appendChild(txSection); | |
| mbd.appendChild(acctSection); | |
| function applyKindUI() { | |
| const k = kindSel.value; | |
| const isAcct = (k==='account_groups'); | |
| txSection.style.display = isAcct ? 'none' : 'block'; | |
| acctSection.style.display = isAcct ? 'block' : 'none'; | |
| // Hide tx-only controls for accounts | |
| const gbSel = document.getElementById('cjs-f-gb'); | |
| if(gbSel) gbSel.parentElement.parentElement.style.display = isAcct ? 'none' : 'grid'; | |
| dpWrap.style.display = isAcct ? 'none' : 'block'; | |
| cmpCb.disabled = isAcct; | |
| if(isAcct) cmpCb.checked = false; | |
| enforceChartTypesForKind(k); | |
| } | |
| kindSel.addEventListener('change', applyKindUI); | |
| applyKindUI(); | |
| // Footer | |
| const mft=document.createElement('div');mft.className='cjs-modal-ft'; | |
| const cBtn=document.createElement('button');cBtn.className='cjs-btn cjs-btn-secondary';cBtn.textContent='Cancel';cBtn.addEventListener('click',closeEditor); | |
| const sBtn=document.createElement('button');sBtn.className='cjs-btn cjs-btn-primary';sBtn.textContent=isNew?'Create':'Save'; | |
| sBtn.addEventListener('click',()=>saveTpl(fd.id,isNew,catPicker,accSel,dp,groupState,groupUIs,false)); | |
| mft.appendChild(cBtn);mft.appendChild(sBtn); | |
| if(!isNew){ | |
| const saBtn=document.createElement('button');saBtn.className='cjs-btn';saBtn.style.cssText=`background:${C.success};color:#fff;`; | |
| saBtn.textContent='Save & Apply'; | |
| saBtn.addEventListener('click',()=>saveTpl(fd.id,isNew,catPicker,accSel,dp,groupState,groupUIs,true)); | |
| mft.appendChild(saBtn); | |
| } | |
| modal.appendChild(mhd);modal.appendChild(mbd);modal.appendChild(mft); | |
| ov.appendChild(modal); | |
| document.getElementById('cjs-modal-container').appendChild(ov); | |
| isModalOpen=true; | |
| } | |
| function closeEditor(){document.getElementById('cjs-editor-modal')?.remove();isModalOpen=false;} | |
| function saveTpl(id,isNew,catPicker,accSel,dp,groupState,groupUIs,andApply){ | |
| const kind = document.getElementById('cjs-f-kind').value; | |
| const base = { | |
| id, | |
| isDefault:false, | |
| name: document.getElementById('cjs-f-name').value.trim(), | |
| kind, | |
| chartType: document.getElementById('cjs-f-ct').value, | |
| autoRefreshMinutes: parseInt(document.getElementById('cjs-f-rf').value)||0, | |
| isPinned: document.getElementById('cjs-f-pin').checked, | |
| }; | |
| if(!base.name){toast('Please enter a name','error');return;} | |
| let t; | |
| if(kind === 'account_groups') { | |
| const groups = (groupState||[]).map((g, idx)=>{ | |
| const ui = groupUIs.get(g.id); | |
| const nm = ui?.nameInput?.value?.trim() || g.name || `Group ${idx+1}`; | |
| const all = !!(ui?.allCb?.checked ?? g.all); | |
| const ids = all ? [] : (ui?.picker?.getValue?.() ?? g.accountIds ?? []); | |
| return { id:g.id, name:nm, all, accountIds: ids }; | |
| }).filter(g=>g.name); | |
| if(!groups.length){toast('Add at least 1 group','error');return;} | |
| t = { | |
| ...base, | |
| chartType: (base.chartType==='bar' || base.chartType==='doughnut') ? base.chartType : 'bar', | |
| groups, | |
| }; | |
| } else { | |
| const dr = dp.getValue(); | |
| t = { | |
| ...base, | |
| dataType: document.getElementById('cjs-f-dt').value, | |
| groupBy: document.getElementById('cjs-f-gb').value, | |
| periodType: dr.periodType, | |
| customStartDate: dr.customStartDate, | |
| customEndDate: dr.customEndDate, | |
| maxCategories: parseInt(document.getElementById('cjs-f-mc').value)||8, | |
| showSubcategories: document.getElementById('cjs-f-sub').checked, | |
| compareWithPrevious: document.getElementById('cjs-f-cmp').checked, | |
| includedCategories: catPicker?catPicker.getValue():[], | |
| includedAccounts: accSel?Array.from(accSel.selectedOptions).map(o=>o.value):[], | |
| }; | |
| } | |
| const d=getData(); | |
| if(isNew){d.templates.push(t);} | |
| else{ | |
| const i=d.templates.findIndex(x=>x.id===id); | |
| if(i>=0){t.isDefault=d.templates[i].isDefault;d.templates[i]=t;} | |
| } | |
| saveData(d); | |
| closeEditor(); | |
| renderList(); | |
| toast(isNew?'Template created!':'Template updated!'); | |
| if(andApply) applyTemplate(id); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // SETTINGS MODAL | |
| // ───────────────────────────────────────────────────────────── | |
| function openSettings(){ | |
| const ov=document.createElement('div');ov.className='cjs-overlay';ov.id='cjs-settings-modal';ov.addEventListener('click',e=>{if(e.target===ov)closeSettings();}); | |
| const modal=document.createElement('div');modal.className='cjs-modal'; | |
| const mhd=document.createElement('div');mhd.className='cjs-modal-hd'; | |
| const mT=document.createElement('h3');mT.textContent='Settings'; | |
| const mX=document.createElement('button');mX.className='cjs-btn-icon';mX.innerHTML=svgX(18);mX.addEventListener('click',closeSettings); | |
| mhd.appendChild(mT);mhd.appendChild(mX); | |
| const mbd=document.createElement('div');mbd.className='cjs-modal-bd cjs-space-y'; | |
| function secField(id,lbl,val,ph){ | |
| const g=document.createElement('div'); | |
| const l=document.createElement('label');l.className='cjs-label';l.textContent=lbl; | |
| const i=document.createElement('input');i.type='password';i.id=id;i.className='cjs-input';i.value=val;i.placeholder=ph;i.style.fontFamily='monospace'; | |
| g.appendChild(l);g.appendChild(i);return g; | |
| } | |
| mbd.appendChild(secField('cjs-s-api','Sure Finance API Key',getApiKey(),'Enter API key…')); | |
| const stCard=document.createElement('div');stCard.className='cjs-card';stCard.style.background=C.bgSecondary; | |
| const stH=document.createElement('h4');stH.style.cssText=`margin:0 0 7px;font-size:13px;color:${C.textPrimary};`;stH.textContent='Connection Status'; | |
| const stT=document.createElement('p');stT.id='cjs-s-status';stT.style.cssText=`margin:0;font-size:12px;color:${getApiKey()?C.success:C.warning};`; | |
| stT.textContent=getApiKey()?'✓ API key configured':'⚠ No API key'; | |
| stCard.appendChild(stH);stCard.appendChild(stT); | |
| mbd.appendChild(stCard); | |
| const mft=document.createElement('div');mft.className='cjs-modal-ft'; | |
| const cBtn=document.createElement('button');cBtn.className='cjs-btn cjs-btn-secondary';cBtn.textContent='Cancel';cBtn.addEventListener('click',closeSettings); | |
| const tBtn=document.createElement('button');tBtn.className='cjs-btn cjs-btn-secondary';tBtn.textContent='Test API'; | |
| tBtn.addEventListener('click',()=>{ | |
| const k=document.getElementById('cjs-s-api').value.trim(); | |
| if(!k){toast('Enter key first','error');return;} | |
| const orig=getApiKey(); | |
| setApiKey(k); | |
| const st=document.getElementById('cjs-s-status'); | |
| st.textContent='Testing…';st.style.color=C.textSecondary; | |
| authFetch('/api/v1/accounts') | |
| .then(r=>{st.textContent=r.ok?'✓ Working':'✗ Failed '+r.status;st.style.color=r.ok?C.success:C.danger;}) | |
| .catch(()=>{st.textContent='✗ Connection failed';st.style.color=C.danger;}) | |
| .finally(()=>setApiKey(orig)); | |
| }); | |
| const sBtn=document.createElement('button');sBtn.className='cjs-btn cjs-btn-primary';sBtn.textContent='Save'; | |
| sBtn.addEventListener('click',()=>{ | |
| setApiKey(document.getElementById('cjs-s-api').value.trim()); | |
| accountsCache = null; | |
| toast('Settings saved!'); | |
| closeSettings(); | |
| }); | |
| mft.appendChild(cBtn);mft.appendChild(tBtn);mft.appendChild(sBtn); | |
| modal.appendChild(mhd);modal.appendChild(mbd);modal.appendChild(mft); | |
| ov.appendChild(modal); | |
| document.getElementById('cjs-modal-container').appendChild(ov); | |
| isModalOpen=true; | |
| } | |
| function closeSettings(){document.getElementById('cjs-settings-modal')?.remove();isModalOpen=false;} | |
| // ───────────────────────────────────────────────────────────── | |
| // CHART APPLY / RENDER | |
| // ───────────────────────────────────────────────────────────── | |
| function applyTemplate(id){ | |
| const d=getData(); | |
| const t=d.templates.find(x=>x.id===id); | |
| if(!t)return; | |
| showLoading(t.name); | |
| processTemplate(t).then(cd=>renderChart(t,cd)).catch(err=>{console.error(err);toast('Failed to load data','error');closeChart();}); | |
| } | |
| function applyOverride(id,ov){ | |
| const d=getData(); | |
| const base=d.templates.find(x=>x.id===id); | |
| if(!base)return; | |
| const t={...base,...ov}; | |
| showLoading(t.name); | |
| processTemplate(t).then(cd=>renderChart(t,cd)).catch(err=>{console.error(err);toast('Failed to load data','error');closeChart();}); | |
| } | |
| function showLoading(name){ | |
| const c=document.getElementById('cjs-chart-container'); | |
| c.innerHTML=''; | |
| const ov=document.createElement('div');ov.className='cjs-chart-overlay'; | |
| ov.innerHTML=`<div style="text-align:center"><div class="cjs-spinner" style="margin:0 auto"></div><p style="margin-top:14px;color:${C.textSecondary};font-size:14px;">Loading "${escH(name)}"…</p></div>`; | |
| c.appendChild(ov);isChartOpen=true; | |
| } | |
| function closeChart(){ | |
| clearInterval(refreshTimer);refreshTimer=null; | |
| document.getElementById('cjs-chart-container').innerHTML=''; | |
| if(window.activeChart){window.activeChart.destroy();window.activeChart=null;} | |
| isChartOpen=false; | |
| } | |
| function renderChart(template,cd){ | |
| clearInterval(refreshTimer); | |
| const c=document.getElementById('cjs-chart-container'); | |
| if(window.activeChart){window.activeChart.destroy();window.activeChart=null;} | |
| c.innerHTML=''; | |
| const ov=document.createElement('div');ov.className='cjs-chart-overlay';ov.addEventListener('click',e=>{if(e.target===ov)closeChart();}); | |
| const win=document.createElement('div');win.className='cjs-chart-win'; | |
| // header | |
| const hd=document.createElement('div');hd.className='cjs-chart-hd'; | |
| const hi=document.createElement('div');hi.className='cjs-chart-hd-info'; | |
| const hT=document.createElement('h2');hT.textContent=template.name; | |
| const hS=document.createElement('p'); | |
| const kind=(template.kind||'transactions'); | |
| if(kind==='account_groups'){ | |
| hS.textContent=`Current balances · ${cd.labels.length} group${cd.labels.length===1?'':'s'}`; | |
| } else { | |
| const pL={week:'Last 7d',last30:'Last 30d',last90:'Last 90d',month:'This Month',quarter:'This Quarter',year:'This Year',last6m:'Last 6M',last12m:'Last 12M',custom:'Custom'}[template.periodType]||template.periodType; | |
| hS.textContent=`${cd.startDate} → ${cd.endDate} · ${pL} · ${cd.datasets.filter(d=>!d._isPrev).length} categories`; | |
| } | |
| hi.appendChild(hT);hi.appendChild(hS); | |
| const ha=document.createElement('div');ha.className='cjs-chart-hd-actions'; | |
| if(template.autoRefreshMinutes>0){ | |
| const rd=document.createElement('div'); | |
| rd.style.cssText=`display:flex;align-items:center;gap:5px;font-size:11px;color:${C.textMuted};`; | |
| rd.innerHTML=`<span style="width:6px;height:6px;border-radius:50%;background:${C.success};display:inline-block;"></span>${template.autoRefreshMinutes}m`; | |
| ha.appendChild(rd); | |
| } | |
| const dlB=document.createElement('button');dlB.className='cjs-btn cjs-btn-secondary cjs-btn-sm'; | |
| dlB.innerHTML=`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> PNG`; | |
| dlB.addEventListener('click',()=>{ | |
| const cv=document.getElementById('cjs-canvas');if(!cv)return; | |
| const a=document.createElement('a');a.download='chart-'+new Date().toISOString().split('T')[0]+'.png';a.href=cv.toDataURL();a.click(); | |
| }); | |
| const clB=document.createElement('button');clB.className='cjs-btn cjs-btn-secondary cjs-btn-sm';clB.innerHTML=svgX(12)+' Close';clB.addEventListener('click',closeChart); | |
| ha.appendChild(dlB);ha.appendChild(clB); | |
| hd.appendChild(hi);hd.appendChild(ha); | |
| // stats bar | |
| const sb=document.createElement('div');sb.className='cjs-stats-bar'; | |
| const fmt=n=>Number(n||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); | |
| const stats = cd.stats || {grand:0,avgPer:0,maxPer:0,periodCount:0}; | |
| [[fmt(stats.grand),'Total'],[fmt(stats.avgPer),'Avg'],[fmt(stats.maxPer),'Max'],[(stats.periodCount||1)+'','Periods'],['—','vs prev']].forEach(([v,l])=>{ | |
| const st=document.createElement('div');st.className='cjs-stat'; | |
| const sv=document.createElement('div');sv.className='cjs-stat-val';sv.textContent=v; | |
| const sl=document.createElement('div');sl.className='cjs-stat-lbl';sl.textContent=l; | |
| st.appendChild(sv);st.appendChild(sl);sb.appendChild(st); | |
| }); | |
| // tabs | |
| const tabBar=document.createElement('div');tabBar.className='cjs-chart-tabs'; | |
| const tabs=[['chart','📊 Chart'],['breakdown','🗃 Breakdown']]; | |
| const tabEls=tabs.map(([key,label])=>{const t=document.createElement('div');t.className='cjs-tab'+(key==='chart'?' active':'');t.textContent=label;t.dataset.key=key;return t;}); | |
| tabEls.forEach(te=>tabBar.appendChild(te)); | |
| // chart pane | |
| const chartPane=document.createElement('div');chartPane.className='cjs-chart-pane active';chartPane.dataset.pane='chart'; | |
| const cBody=document.createElement('div');cBody.className='cjs-chart-body'; | |
| const canvas=document.createElement('canvas');canvas.id='cjs-canvas'; | |
| cBody.appendChild(canvas); | |
| chartPane.appendChild(cBody); | |
| // breakdown pane | |
| const bdPane=document.createElement('div');bdPane.className='cjs-chart-pane';bdPane.dataset.pane='breakdown'; | |
| const bdScr=document.createElement('div');bdScr.className='cjs-bd-scroll'; | |
| const maxT = Math.max(...(cd.breakdown||[]).map(r=>Math.abs(r.total||0)), 1); | |
| const tbl=document.createElement('table');tbl.className='cjs-bd-table'; | |
| const thead=document.createElement('thead');['Item','Total','Share','Bar'].forEach(h=>{const th=document.createElement('th');th.textContent=h;thead.appendChild(th);});tbl.appendChild(thead); | |
| const tbody=document.createElement('tbody'); | |
| (cd.breakdown||[]).forEach(row=>{ | |
| const tr=document.createElement('tr'); | |
| const nTd=document.createElement('td'); | |
| nTd.innerHTML=`<div style="display:flex;align-items:center;gap:7px"><div style="width:9px;height:9px;border-radius:50%;background:${row.color};flex-shrink:0"></div>${escH(row.name)}</div>`; | |
| const tTd=document.createElement('td');tTd.textContent=fmt(row.total);tTd.style.fontVariantNumeric='tabular-nums'; | |
| const sTd=document.createElement('td');sTd.textContent=(row.share||0).toFixed(1)+'%'; | |
| const bTd=document.createElement('td'); | |
| const bg=document.createElement('div');bg.className='cjs-bar-bg'; | |
| const fill=document.createElement('div');fill.className='cjs-bar-fill'; | |
| fill.style.width=(Math.abs(row.total||0)/maxT*100)+'%'; | |
| fill.style.background=row.color; | |
| bg.appendChild(fill);bTd.appendChild(bg); | |
| [nTd,tTd,sTd,bTd].forEach(td=>tr.appendChild(td)); | |
| tbody.appendChild(tr); | |
| }); | |
| tbl.appendChild(tbody); | |
| bdScr.appendChild(tbl); | |
| bdPane.appendChild(bdScr); | |
| // tab switching | |
| tabEls.forEach(te=>{ | |
| te.addEventListener('click',()=>{ | |
| tabEls.forEach(x=>x.classList.remove('active')); | |
| te.classList.add('active'); | |
| win.querySelectorAll('.cjs-chart-pane').forEach(p=>p.classList.remove('active')); | |
| win.querySelector(`.cjs-chart-pane[data-pane="${te.dataset.key}"]`).classList.add('active'); | |
| }); | |
| }); | |
| win.appendChild(hd); | |
| win.appendChild(sb); | |
| win.appendChild(tabBar); | |
| win.appendChild(chartPane); | |
| win.appendChild(bdPane); | |
| ov.appendChild(win); | |
| c.appendChild(ov); | |
| isChartOpen=true; | |
| // Chart.js | |
| Chart.defaults.color=C.textSecondary; | |
| Chart.defaults.borderColor=C.border; | |
| const isDonut = (cd.chartType==='doughnut'); | |
| const cfg={ | |
| type:cd.chartType, | |
| data:{labels:cd.labels,datasets:cd.datasets}, | |
| options:{ | |
| responsive:true,maintainAspectRatio:false, | |
| interaction:{mode:isDonut?'nearest':'index',intersect:false}, | |
| plugins:{ | |
| legend:{position:isDonut?'right':'bottom',labels:{boxWidth:11,padding:13,color:C.textPrimary,usePointStyle:true}}, | |
| tooltip:{ | |
| backgroundColor:C.bgCard,titleColor:C.textPrimary,bodyColor:C.textSecondary,borderColor:C.border,borderWidth:1,padding:10, | |
| callbacks:{ | |
| label:ctx=>{ | |
| const v=ctx.parsed?.y ?? ctx.parsed; | |
| const label = isDonut ? ctx.label : (ctx.dataset.label || ctx.label); | |
| return ` ${label}: ${(+v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}`; | |
| } | |
| } | |
| } | |
| }, | |
| scales:isDonut?{}:{ | |
| x:{grid:{display:false},ticks:{color:C.textSecondary,maxRotation:45}}, | |
| // important: beginAtZero true so zeros still show axis/space | |
| y:{beginAtZero:true,grid:{color:C.bgHover+'aa'},ticks:{color:C.textSecondary,callback:v=>Number(v).toLocaleString()}}, | |
| }, | |
| }, | |
| }; | |
| window.activeChart=new Chart(canvas,cfg); | |
| if(template.autoRefreshMinutes>0){ | |
| refreshTimer=setInterval(()=>{ | |
| if(!isChartOpen){clearInterval(refreshTimer);return;} | |
| processTemplate(template).then(ncd=>{ | |
| if(window.activeChart){ | |
| window.activeChart.data.labels=ncd.labels; | |
| window.activeChart.data.datasets=ncd.datasets; | |
| window.activeChart.update('active'); | |
| toast('Chart refreshed'); | |
| } | |
| }).catch(()=>toast('Auto-refresh failed','warning')); | |
| },template.autoRefreshMinutes*60000); | |
| } | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // MISC | |
| // ───────────────────────────────────────────────────────────── | |
| function mkSel(id,options,val){ | |
| const s=document.createElement('select');s.id=id;s.className='cjs-select'; | |
| options.forEach(([v,t])=>{const o=document.createElement('option');o.value=v;o.textContent=t;if(String(v)===String(val))o.selected=true;s.appendChild(o);}); | |
| return s; | |
| } | |
| function svgX(sz){return `<svg width="${sz}" height="${sz}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M18 6L6 18M6 6l12 12"/></svg>`;} | |
| function escH(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML;} | |
| // ───────────────────────────────────────────────────────────── | |
| // INIT / CLEANUP | |
| // ───────────────────────────────────────────────────────────── | |
| window.cjsCleanup=function(){ | |
| clearInterval(refreshTimer); | |
| document.removeEventListener('keydown',onKey); | |
| document.getElementById('cjs-root')?.remove(); | |
| document.getElementById('cjs-styles')?.remove(); | |
| if(window.activeChart)window.activeChart.destroy(); | |
| }; | |
| function init(){window.cjsCleanup?.();createPanel();} | |
| document.addEventListener('turbo:load',init); | |
| if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',init); | |
| else init(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment