Skip to content

Instantly share code, notes, and snippets.

@malys
Last active March 14, 2026 14:35
Show Gist options
  • Select an option

  • Save malys/eae223f9fc04a50ac5e9108cfe29cf1f to your computer and use it in GitHub Desktop.

Select an option

Save malys/eae223f9fc04a50ac5e9108cfe29cf1f to your computer and use it in GitHub Desktop.
[Sure Chart] chart#userscript #violentmonkey #Sure
// ==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