Last active
March 14, 2026 14:35
-
-
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 | |
| // @namespace https://sure.am | |
| // @version 5.2 | |
| // @description Custom charts: new chart types, inline date picker, category tag-pills, period comparison | |
| // @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'; | |
| 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 | |
| // ───────────────────────────────────────────────────────────── | |
| const DEFAULT_TEMPLATES = [ | |
| { | |
| id:'quarterly_outflows', name:'Quarterly Outflows by Category', | |
| 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', | |
| 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', | |
| 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, | |
| }, | |
| ]; | |
| // ───────────────────────────────────────────────────────────── | |
| // 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', | |
| purple:'#8b5cf6', cyan:'#06b6d4', | |
| }; | |
| const PALETTE=[ | |
| '#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6', | |
| '#ec4899','#06b6d4','#84cc16','#f97316','#6366f1', | |
| '#14b8a6','#e11d48','#7c3aed','#0891b2','#65a30d', | |
| '#d97706','#db2777','#9333ea','#0284c7','#16a34a', | |
| ]; | |
| const catColor = 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:140px; } | |
| .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:200px; overflow-y:auto; min-width:200px; } | |
| .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:580px; 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; } | |
| /* 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-val.up { color:${C.success}; } | |
| .cjs-stat-val.down { color:${C.danger}; } | |
| .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); } | |
| /* Misc */ | |
| .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-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; } | |
| @keyframes cjs-spin { to { transform:rotate(360deg); } } | |
| @keyframes cjs-fade-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } } | |
| @keyframes cjs-pulse { 0%,100%{opacity:1} 50%{opacity:.4} } | |
| .cjs-refresh-dot { width:6px; height:6px; border-radius:50%; background:${C.success}; animation:cjs-pulse 2s ease infinite; display:inline-block; } | |
| .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-divider { border:none; border-top:1px solid ${C.border}; margin:5px 0; } | |
| .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-empty { text-align:center; padding:32px 14px; color:${C.textMuted}; } | |
| .cjs-empty-icon { font-size:28px; margin-bottom:7px; } | |
| .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}; } | |
| `; | |
| 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 => ({ | |
| isPinned:false, compareWithPrevious:false, autoRefreshMinutes:0, ...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('sure_chart_api_key')||''; } catch(e){return'';} } | |
| function setApiKey(k) { try { k?localStorage.setItem('sure_chart_api_key',k.trim()):localStorage.removeItem('sure_chart_api_key'); 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 authFetch('/api/v1/accounts').then(r=>r.json()).then(d=>{accountsCache=d.accounts||[];return accountsCache;}); | |
| } | |
| 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 | |
| // ───────────────────────────────────────────────────────────── | |
| 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-s; | |
| 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 | |
| // ───────────────────────────────────────────────────────────── | |
| 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]||{}; | |
| grouped[pk][cid]=(grouped[pk][cid]||0)+Math.abs((tx.amount_cents||0)/100); | |
| totals[cid]=(totals[cid]||0)+Math.abs((tx.amount_cents||0)/100); | |
| } | |
| 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||catColor(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 processTemplate(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||catColor(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}}; | |
| }); | |
| }); | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // 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 | |
| // ───────────────────────────────────────────────────────────── | |
| 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||catColor(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||catColor(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; | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // INLINE DATE RANGE PICKER | |
| // ───────────────────────────────────────────────────────────── | |
| 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 | |
| // ───────────────────────────────────────────────────────────── | |
| 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;} | |
| // ───────────────────────────────────────────────────────────── | |
| // RENDER LIST | |
| // ───────────────────────────────────────────────────────────── | |
| const TICONS={line:'📈',bar:'📊',doughnut:'🍩','bar-cumulative':'📶','bar-stacked':'🗂',area:'🌊',scatter:'✦',bubble:'⬤'}; | |
| const QPERIODS=[{l:'Month',v:'month'},{l:'Quarter',v:'quarter'},{l:'Year',v:'year'},{l:'Last 30d',v:'last30'}]; | |
| 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=>t.name.toLowerCase().includes(templateSearch)||t.chartType.includes(templateSearch)||t.dataType.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 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=(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(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};`; | |
| sub.textContent=`${t.chartType} · ${t.dataType} · ${t.periodType}`+((t.includedCategories?.length||t.includedAccounts?.length)?' · filtered':''); | |
| const chips=document.createElement('div');chips.className='cjs-quick-chips'; | |
| 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); | |
| }); | |
| 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.textMuted}" 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 ico=document.createElement('span');ico.style.fontSize='15px';ico.textContent=TICONS[t.chartType]||'📊'; | |
| 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=`${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.0',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 | |
| // ───────────────────────────────────────────────────────────── | |
| 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:'',chartType:'line',dataType:'expense',groupBy:'week',periodType:'quarter',customStartDate:'',customEndDate:'',maxCategories:8,showSubcategories:false,isDefault:false,isPinned:false,compareWithPrevious:false,autoRefreshMinutes:0,includedCategories:[],includedAccounts:[]}; | |
| 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)); | |
| // Chart + Data type | |
| const r1=document.createElement('div');r1.className='cjs-grid-2'; | |
| r1.appendChild(fg('Chart Type',mkSel('cjs-f-ct',[['line','📈 Line'],['area','🌊 Area'],['bar','📊 Bar'],['bar-stacked','🗂 Stacked Bar'],['bar-cumulative','📶 Cumulative Bar'],['doughnut','🍩 Doughnut'],['scatter','✦ Scatter'],['bubble','⬤ Bubble']],fd.chartType))); | |
| r1.appendChild(fg('Data Type',mkSel('cjs-f-dt',[['expense','Expenses'],['income','Income'],['all','All']],fd.dataType))); | |
| mbd.appendChild(r1); | |
| // GroupBy + Max | |
| const r2=document.createElement('div');r2.className='cjs-grid-2'; | |
| r2.appendChild(fg('Group By',mkSel('cjs-f-gb',[['day','Day'],['week','Week'],['month','Month']],fd.groupBy))); | |
| 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'; | |
| r2.appendChild(fg('Max Categories',maxIn)); | |
| mbd.appendChild(r2); | |
| // Date picker | |
| const dpLbl=document.createElement('label');dpLbl.className='cjs-label';dpLbl.textContent='Time Period'; | |
| const dp=makeDatePicker(fd); | |
| const dpWrap=document.createElement('div');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); | |
| [['cjs-f-sub','Show subcategories',fd.showSubcategories],['cjs-f-cmp','Compare with previous period',fd.compareWithPrevious],['cjs-f-pin','Pin to top',fd.isPinned]].forEach(([id,lbl,chk])=>{ | |
| const row=document.createElement('div');row.className='cjs-flex cjs-items-center cjs-gap-2';row.style.marginBottom='7px'; | |
| const cb=document.createElement('input');cb.type='checkbox';cb.id=id;cb.className='cjs-checkbox';cb.checked=chk; | |
| const l=document.createElement('label');l.htmlFor=id;l.style.cssText=`font-size:13px;color:${C.textSecondary};cursor:pointer;`;l.textContent=lbl; | |
| row.appendChild(cb);row.appendChild(l);optCard.appendChild(row); | |
| }); | |
| 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'],[30,'30 min']],fd.autoRefreshMinutes))); | |
| optCard.appendChild(rfRow); | |
| mbd.appendChild(optCard); | |
| // Category tag 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>`; | |
| mbd.appendChild(catSect); | |
| // Account multi-select | |
| 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>`; | |
| mbd.appendChild(accSect); | |
| Promise.all([fetchCategories(),fetchAccounts()]).then(([cats,accs])=>{ | |
| 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 (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); | |
| }); | |
| // 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,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,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,andApply){ | |
| const dr=dp.getValue(); | |
| const t={ | |
| id,isDefault:false, | |
| name: document.getElementById('cjs-f-name').value.trim(), | |
| chartType: document.getElementById('cjs-f-ct').value, | |
| 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, | |
| autoRefreshMinutes:parseInt(document.getElementById('cjs-f-rf').value)||0, | |
| showSubcategories: document.getElementById('cjs-f-sub').checked, | |
| compareWithPrevious: document.getElementById('cjs-f-cmp').checked, | |
| isPinned: document.getElementById('cjs-f-pin').checked, | |
| includedCategories: catPicker?catPicker.getValue():[], | |
| includedAccounts: accSel?Array.from(accSel.selectedOptions).map(o=>o.value):[], | |
| }; | |
| if(!t.name){toast('Please enter a name','error');return;} | |
| 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/categories').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()); | |
| 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 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=`<div class="cjs-refresh-dot"></div>${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.id='cjs-stats-bar';sb.className='cjs-stats-bar'; | |
| const fmt=n=>n.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2}); | |
| const pct=cd.stats.pct; | |
| const pTxt=pct!==null?(pct>=0?'▲ ':'▼ ')+Math.abs(pct).toFixed(1)+'%':'—'; | |
| const pCls=pct===null?'':(pct>0?'up':'down'); | |
| [[fmt(cd.stats.grand),'Total '+template.dataType,''],[fmt(cd.stats.avgPer),'Avg / '+template.groupBy,''],[fmt(cd.stats.maxPer),'Peak period',''],[cd.stats.periodCount+'','Periods',''],[pTxt,'vs prev period',pCls]].forEach(([v,l,cls])=>{ | |
| const st=document.createElement('div');st.className='cjs-stat'; | |
| const sv=document.createElement('div');sv.className='cjs-stat-val'+(cls?' '+cls:'');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=cd.breakdown[0]?.total||1; | |
| const tbl=document.createElement('table');tbl.className='cjs-bd-table'; | |
| const thead=document.createElement('thead');['Category','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.toFixed(1)+'%'; | |
| const bTd=document.createElement('td');const bc=document.createElement('div');bc.className='cjs-bar-wrap';const bg=document.createElement('div');bg.className='cjs-bar-bg';const fill=document.createElement('div');fill.className='cjs-bar-fill';fill.style.width=(row.total/maxT*100)+'%';fill.style.background=row.color;bg.appendChild(fill);bc.appendChild(bg);bTd.appendChild(bc); | |
| [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 annotPlugin=Chart.registry?.plugins?.get?.('annotation'); | |
| const annotations={}; | |
| if(!isDonut&&cd.labels.length>1&&annotPlugin){ | |
| const pi=cd.labels.reduce((bi,_,i)=>{const cur=cd.datasets.filter(d=>!d._isPrev).reduce((s,d)=>s+(d.data[i]||0),0);const best=cd.datasets.filter(d=>!d._isPrev).reduce((s,d)=>s+(d.data[bi]||0),0);return cur>best?i:bi;},0); | |
| annotations.peak={type:'line',xMin:pi,xMax:pi,borderColor:C.warning+'99',borderWidth:1.5,borderDash:[4,3],label:{display:true,content:'▲ Peak',position:'start',backgroundColor:C.warning+'dd',color:'#111',font:{size:10,weight:'bold'},padding:{x:5,y:3},yAdjust:-8}}; | |
| } | |
| const cfg={ | |
| type:cd.chartType==='area'?'line':cd.chartType, | |
| data:{labels:cd.labels,datasets:cd.datasets}, | |
| options:{ | |
| responsive:true,maintainAspectRatio:false,animation:{duration:600,easing:'easeInOutQuart'},interaction:{mode:'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 pre=cd.isCumul?'∑ ':'';return ` ${ctx.dataset.label}: ${pre}${(+v).toLocaleString(undefined,{minimumFractionDigits:2})}`;}, | |
| afterBody:items=>{if(isDonut||items.length<2)return[];const cur=items.filter(i=>!cd.datasets[i.datasetIndex]?._isPrev);if(cur.length<2)return[];const tot=cur.reduce((s,i)=>s+(i.parsed?.y||0),0);return['─────────────────',`Total: ${tot.toLocaleString(undefined,{minimumFractionDigits:2})}`];} | |
| } | |
| }, | |
| annotation:annotPlugin?{annotations}:undefined, | |
| }, | |
| scales:isDonut?{}:{ | |
| x:{stacked:cd.isStacked,grid:{display:false},ticks:{color:C.textSecondary,maxRotation:45}}, | |
| y:{stacked:cd.isStacked,beginAtZero:true,grid:{color:C.bgHover+'aa'},ticks:{color:C.textSecondary,callback:v=>v>=1000?(v/1000).toFixed(1)+'k':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); | |
| } | |
| } | |
| // ───────────────────────────────────────────────────────────── | |
| // TINY HELPERS | |
| // ───────────────────────────────────────────────────────────── | |
| 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