Skip to content

Instantly share code, notes, and snippets.

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