let D=null,TIMER=null;
const PLOT_CONFIG={responsive:true,displaylogo:false};
let LOCAL_BACKUPS=[];
let BACKUPS_LOADED=false;
function e(id) { return document.getElementById(id); }
function resizeOnePlot(id){let el=typeof id==='string'?e(id):id;if(!el)return;try{Plotly.Plots.resize(el)}catch(err){}}
function resizePlots(){document.querySelectorAll('.chart').forEach(el=>{if(el.offsetParent!==null)resizeOnePlot(el);});}
function drawPlot(id,traces,layoutObj,configObj){Plotly.react(id,traces,layoutObj,configObj||PLOT_CONFIG);setTimeout(()=>resizeOnePlot(id),80);}
function tab(id,b){document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.tabbtn').forEach(x=>x.classList.remove('active'));document.getElementById(id).classList.add('active');b.classList.add('active');setTimeout(resizePlots,120);setTimeout(resizePlots,450)}
window.addEventListener('resize',()=>setTimeout(resizePlots,120));
function esc(v){return String(v??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]))}
function money(v){if(v===null||v===undefined||v==='')return '-';v=Number(v);return isFinite(v)?v.toLocaleString(undefined,{maximumFractionDigits:2})+' EUR':'-'}
function pct(v){v=Number(v);return isFinite(v)?(v*100).toLocaleString(undefined,{maximumFractionDigits:2})+'%':'-'}
function round2(v){v=Number(v);return isFinite(v)?Math.round(v*100)/100:0}
function topN(id, fallback=30){
  let el=e(id); if(!el)return fallback;
  if(el.value==='custom'){
    let cid=id.replace('_n','_custom');
    let c=e(cid), n=Number(c?c.value:fallback);
    return isFinite(n)&&n>0?n:fallback;
  }
  let n=Number(el.value); return isFinite(n)?n:fallback;
}
function cleanTickerLabel(v){let s=String(v??''); return s.replace(/_US_EQ$/i,'').replace(/_EQ$/i,'').replace(/_US$/i,'');}
function normSymbol(v){return cleanTickerLabel(String(v??'').trim()).toLowerCase();}
function splitTerms(s){return String(s||'').toLowerCase().split(/[;,\n]+|\s{2,}/).map(x=>x.trim()).filter(Boolean);}
function symbolCandidates(row){
  let vals=[row.true_ticker,row.ticker,row.ticker_display,row.symbol_raw,row.display_name].map(v=>String(v??'')).filter(Boolean);
  let extra=vals.map(cleanTickerLabel);
  return [...new Set(vals.concat(extra).map(normSymbol).filter(Boolean))];
}
function matchesTerms(row, terms){
  if(!terms.length)return true;
  let candidates=symbolCandidates(row);
  let containsHay=[row.display_name,row.true_ticker,row.ticker,row.ticker_display,row.symbol_raw].map(x=>normSymbol(x)).join(' | ');
  return terms.some(t=>{
    let raw=String(t||'').trim().toLowerCase();
    if(!raw)return false;
    if(raw.includes('*')) return containsHay.includes(normSymbol(raw.replace(/\*/g,'')));
    return candidates.includes(normSymbol(raw));
  });
}
function defaultCleanValue(sym, value){
  let clean=cleanTickerLabel(sym), v=String(value??'').trim();
  if(!v || v.toLowerCase()==='nan' || v===String(sym) || /_US_EQ$/i.test(v) || /_EQ$/i.test(v) || v==='IPOE_US_EQ / old SOFI SPAC' || v==='YNDX_US_EQ / old Yandex') return clean;
  return v;
}
function copyEl(id){let e=document.getElementById(id);e.select();document.execCommand('copy')}
function copyTextValue(v){navigator.clipboard?.writeText(String(v||''));}
const DONATION_OPTIONS={
  buymeacoffee:{title:'Buy Me a Coffee',body:'Scan the Buy Me a Coffee QR code if you found value using the tool.',copy:'',qr:'/static/donations/buymeacoffee_qr.png'},
  btc_lightning:{title:'BTC Lightning',body:'Lightning address: doableshrimp862@walletofsatoshi.com',copy:'doableshrimp862@walletofsatoshi.com',qr:'/static/donations/btc_lightning_qr.jpg',warning:'BTC Lightning only. Do not send BTC on-chain to this address/QR unless your wallet explicitly says Lightning.'}
};
function renderDonationDetails(){
  let box=e('donation_details'); if(!box)return;
  let key=e('donation_option')?.value||'';
  if(!key){box.style.display='none';box.innerHTML='';return;}
  let opt=DONATION_OPTIONS[key]||DONATION_OPTIONS.buymeacoffee;
  box.style.display='block';
  box.innerHTML='<b>'+esc(opt.title)+'</b><br><div style="overflow-wrap:anywhere;word-break:break-word">'+esc(opt.body)+'</div>'+(opt.qr?'<br><img src="'+esc(opt.qr)+'" alt="'+esc(opt.title)+' QR code" style="width:180px;max-width:100%;margin-top:12px;border-radius:12px;background:white;padding:8px">':'')+(opt.warning?'<br><br><span class="smallnote">'+esc(opt.warning)+'</span>':'')+(opt.copy?'<br><br><button class="btn2 mini-btn" onclick="copyTextValue(&quot;'+esc(opt.copy)+'&quot;)">Copy details</button>':'');
}
const THEME_STYLES={
  navy:{bg:'#070b13',side:'#0b1020',panel:'#111827',panel2:'#172033',input:'#0b1220',line:'#263244',thead:'#101827',theadText:'#bfdbfe',text:'#e5e7eb',muted:'#9ca3af'},
  black:{bg:'#000000',side:'#050505',panel:'#0d0d0d',panel2:'#171717',input:'#050505',line:'#2a2a2a',thead:'#101010',theadText:'#e5e5e5',text:'#f5f5f5',muted:'#a3a3a3'},
  graphite:{bg:'#111111',side:'#171717',panel:'#1f2937',panel2:'#263244',input:'#111827',line:'#374151',thead:'#1f2937',theadText:'#dbeafe',text:'#f3f4f6',muted:'#d1d5db'},
  purple:{bg:'#12071f',side:'#160b2e',panel:'#211238',panel2:'#2f1a4d',input:'#13091f',line:'#49316d',thead:'#261447',theadText:'#ddd6fe',text:'#f5f3ff',muted:'#c4b5fd'},
  light:{bg:'#f3f4f6',side:'#ffffff',panel:'#ffffff',panel2:'#eef2ff',input:'#ffffff',line:'#d1d5db',thead:'#e0ecff',theadText:'#1e3a8a',text:'#111827',muted:'#4b5563'},
  paper:{bg:'#f8f5ef',side:'#fffaf0',panel:'#ffffff',panel2:'#f3eadc',input:'#fffdf8',line:'#d8cbbb',thead:'#efe2cf',theadText:'#7c2d12',text:'#1f2937',muted:'#6b5f52'},
  sky:{bg:'#eef7ff',side:'#f8fbff',panel:'#ffffff',panel2:'#e0f2fe',input:'#ffffff',line:'#bfdbfe',thead:'#dbeafe',theadText:'#1d4ed8',text:'#0f172a',muted:'#475569'}
};
const PLOT_PALETTES={
  default:['#3b82f6','#22c55e','#f59e0b','#ef4444','#8b5cf6','#06b6d4','#e11d48','#a3e635'],
  vibrant:['#00d4ff','#ff4ecd','#ffe156','#7cff6b','#ff8a00','#9b5cff','#00ffa3','#ff3b3b'],
  soft:['#93c5fd','#86efac','#fde68a','#fca5a5','#c4b5fd','#67e8f9','#f9a8d4','#bef264'],
  warm:['#f97316','#facc15','#fb7185','#ef4444','#d97706','#f59e0b','#a16207','#fdba74']
};
function cssVar(name){return getComputedStyle(document.documentElement).getPropertyValue(name).trim();}
function currentPalette(){return PLOT_PALETTES[localStorage.getItem('portfolio_dashboard_plot_palette')||'default']||PLOT_PALETTES.default;}
function applyUiSettings(){
  let accent=localStorage.getItem('portfolio_dashboard_accent')||'#3b82f6';
  let tableHeight=localStorage.getItem('portfolio_dashboard_table_height')||'420px';
  let theme=localStorage.getItem('portfolio_dashboard_theme_style')||'navy';
  let palette=localStorage.getItem('portfolio_dashboard_plot_palette')||'default';
  let colors=THEME_STYLES[theme]||THEME_STYLES.navy;
  Object.entries(colors).forEach(([k,v])=>{let css='--'+k.replace(/[A-Z]/g,m=>'-'+m.toLowerCase());document.documentElement.style.setProperty(css,v);});
  document.documentElement.style.setProperty('--blue',accent);
  document.documentElement.style.setProperty('--table-height',tableHeight);
  if(e('accent_color')) e('accent_color').value=accent;
  if(e('table_height')) e('table_height').value=tableHeight;
  if(e('theme_style')) e('theme_style').value=theme;
  if(e('plot_palette')) e('plot_palette').value=palette;
  if(e('show_chart_sources')) e('show_chart_sources').value=localStorage.getItem('portfolio_dashboard_show_chart_sources')||'hide';
  if(D) render(); else setTimeout(resizePlots,120);
}
function saveUiSettings(){
  if(e('accent_color')) localStorage.setItem('portfolio_dashboard_accent',e('accent_color').value);
  if(e('table_height')) localStorage.setItem('portfolio_dashboard_table_height',e('table_height').value);
  if(e('theme_style')) localStorage.setItem('portfolio_dashboard_theme_style',e('theme_style').value);
  if(e('plot_palette')) localStorage.setItem('portfolio_dashboard_plot_palette',e('plot_palette').value);
  if(e('show_chart_sources')) localStorage.setItem('portfolio_dashboard_show_chart_sources',e('show_chart_sources').value);
  applyUiSettings();
}
let MSG_CLEAR_TIMER=null;
function setMainMessage(html, autoClearMs=0){
  let box=e('msg'); if(!box)return;
  if(MSG_CLEAR_TIMER){clearTimeout(MSG_CLEAR_TIMER);MSG_CLEAR_TIMER=null;}
  box.innerHTML=html||'';
  if(autoClearMs>0){
    MSG_CLEAR_TIMER=setTimeout(()=>{if(box.innerHTML===html) box.innerHTML='';},autoClearMs);
  }
}
async function runAnalysis(){
  setMainMessage('');
  e('status').innerHTML='Starting...';
  e('logs').innerHTML='Sending request to local app...';
  const payload={
    api_key:e('api_key').value,
    api_secret:e('api_secret').value,
    data_mode:e('data_mode').value,
    fx_mode:e('fx_mode').value
  };
  try {
    const r=await fetch('/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
    const o=await r.json();
    if(!o.ok){setMainMessage('<div class="err">'+esc(o.error)+'</div>');return;}
    if(TIMER) clearInterval(TIMER);
    TIMER=setInterval(poll,1000);
    await poll();
  } catch(err) {
    setMainMessage('<div class="err">Could not start analysis: '+esc(err)+'</div>');
  }
}

async function installDemoData(){
  let ok=confirm('Install synthetic demo data? Existing local data/output/mapping folders will be backed up first. You can later paste your Trading212 API key and run a normal refresh to replace the demo data.');
  if(!ok)return;
  setMainMessage('');
  e('status').innerHTML='Creating demo data...';
  e('logs').innerHTML='Preparing synthetic demo portfolio...';
  try{
    const r=await fetch('/install_demo_data',{method:'POST'});
    const o=await r.json();
    if(!o.ok){setMainMessage('<div class="err">'+esc(o.error||'Could not install demo data')+'</div>');return;}
    if(e('data_mode'))e('data_mode').value='cache_only';
    if(e('fx_mode'))e('fx_mode').value='cache_only';
    if(TIMER) clearInterval(TIMER);
    TIMER=setInterval(poll,1000);
    await poll();
  }catch(err){
    setMainMessage('<div class="err">Could not install demo data: '+esc(err)+'</div>');
  }
}

function backupLabel(row){
  let name=String(row.name||'');
  let kind='Local backup';
  if(name.startsWith('before_demo_data_')) kind='Before demo data';
  else if(name.startsWith('before_restore_')) kind='Before restore';
  else if(name.startsWith('before_real_data_refresh_')) kind='Before real API refresh';
  let parts=[kind];
  if(row.modified)parts.push(row.modified);
  let folders=[];
  if(row.has_data)folders.push('data');
  if(row.has_output)folders.push('output');
  if(row.has_mapping)folders.push('mapping');
  if(folders.length)parts.push(folders.join('/'));
  parts.push(row.is_demo?'contains demo data':'portfolio data');
  return parts.join(' - ');
}

async function refreshBackupOptions(){
  let sel=e('backup_restore_select'); if(!sel)return;
  try{
    let out=await (await fetch('/local_backups')).json();
    let backups=(out&&out.backups)||[];
    LOCAL_BACKUPS=backups;
    BACKUPS_LOADED=true;
    renderBackupManagerTable(backups);
    if(!backups.length){
      sel.innerHTML='<option value="">No backups found yet</option>';
      return;
    }
    sel.innerHTML='<option value="">Select backup...</option>'+backups.map(b=>'<option value="'+esc(b.name)+'">'+esc(backupLabel(b))+'</option>').join('');
  }catch(err){
    LOCAL_BACKUPS=[];
    BACKUPS_LOADED=true;
    renderBackupManagerTable([]);
    sel.innerHTML='<option value="">Could not load backups</option>';
  }
}

async function restoreSelectedBackup(){
  let sel=e('backup_restore_select');
  let name=sel?sel.value:'';
  if(!name){setMainMessage('<div class="err">Choose a backup to restore first.</div>');return;}
  let ok=confirm('Restore backup "'+name+'"? Current active data/output/mapping folders will be backed up first, then the selected backup will be restored and rebuilt from local cache.');
  if(!ok)return;
  setMainMessage('');
  e('status').innerHTML='Restoring backup...';
  e('logs').innerHTML='Preparing local restore...';
  try{
    const r=await fetch('/restore_local_backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({backup_name:name})});
    const o=await r.json();
    if(!o.ok){setMainMessage('<div class="err">'+esc(o.error||'Could not restore backup')+'</div>');return;}
    if(e('data_mode'))e('data_mode').value='cache_only';
    if(e('fx_mode'))e('fx_mode').value='cache_only';
    if(TIMER) clearInterval(TIMER);
    TIMER=setInterval(poll,1000);
    await poll();
  }catch(err){
    setMainMessage('<div class="err">Could not restore backup: '+esc(err)+'</div>');
  }
}

async function restoreBackupByName(name){
  let sel=e('backup_restore_select');
  if(sel)sel.value=name||'';
  await restoreSelectedBackup();
}

async function deleteBackupByName(name){
  if(!name)return;
  let ok=confirm('Delete backup "'+name+'"? This only deletes the selected local backup folder. Your active dashboard data is not changed.');
  if(!ok)return;
  let status=e('backup_delete_status');
  if(status)status.innerHTML='Deleting selected backup...';
  try{
    const r=await fetch('/delete_local_backup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({backup_name:name})});
    const o=await r.json();
    if(!o.ok){
      if(status)status.innerHTML='<span style="color:var(--red)">Could not delete backup: '+esc(o.error||'Unknown error')+'</span>';
      return;
    }
    LOCAL_BACKUPS=o.backups||[];
    if(status)status.innerHTML='<span style="color:var(--green)">Backup deleted.</span>';
    await refreshBackupOptions();
  }catch(err){
    if(status)status.innerHTML='<span style="color:var(--red)">Could not delete backup: '+esc(err)+'</span>';
  }
}

function renderBackupManagerTable(backups=LOCAL_BACKUPS){
  let target=e('backup_manager_table');
  if(!target)return;
  if(!backups||!backups.length){
    target.innerHTML='<p class="note">No local backups found yet. Backups are created before demo data, restores, and real API refreshes when active data already exists.</p>';
    return;
  }
  let rows=backups.map(b=>{
    let name=esc(String(b.name||''));
    return '<tr><td>'+esc(backupLabel(b))+'</td><td>'+esc(b.name||'')+'</td><td>'+esc(b.is_demo?'Demo data':'Portfolio data')+'</td><td><button class="btn2 mini-btn" onclick="restoreBackupByName(&quot;'+name+'&quot;)">Restore</button> <button class="btn2 mini-btn" onclick="deleteBackupByName(&quot;'+name+'&quot;)">Delete</button></td></tr>';
  }).join('');
  target.innerHTML='<div class="tablebox"><div class="tablewrap backup-tablewrap"><table><thead><tr><th>Backup</th><th>Folder</th><th>Type</th><th>Action</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';
}

async function poll(){
  const s=await (await fetch('/status')).json();
  e('status').innerHTML=s.busy?'Running...':(s.error?'Failed':(s.ready?'Ready':'Waiting'));
  e('logs').innerHTML=(s.logs||[]).map(esc).join('<br>');
  e('logs').scrollTop=e('logs').scrollHeight;
  if(s.error){clearInterval(TIMER);setMainMessage('<div class="err">'+esc(s.error)+'</div>');}
  if(!s.busy&&s.ready){clearInterval(TIMER);await loadData();}
}

async function loadData(){
  let response = await fetch('/data');
  if(!response.ok){
    let txt = await response.text();
    setMainMessage('<div class="err">The backend finished, but the browser could not load the dashboard data. Error from /data:<br><pre>'+esc(txt)+'</pre></div>');
    return;
  }

  D=await response.json();
  if(D.error){
    setMainMessage('<div class="err">'+esc(D.error)+'</div>');
    return;
  }
  BACKUPS_LOADED=false;

  e('llm_prompt').value=D.llm_prompt||'';
  try{
    filters();
    ssDatasets();
    render();
  }catch(err){
    setMainMessage('<div class="err">Browser render error: '+esc(err.message||err)+'</div>');
    throw err;
  }

  let holdings=(D.tables&&D.tables.gold_current_positions_by_symbol)?D.tables.gold_current_positions_by_symbol:[];
  if(!holdings.length){
    setMainMessage('<div class="err">Dashboard run completed, but no holdings data was loaded. If you used cache-only mode, run once with "Refresh current year only - recommended" or copy the old data/ folder into this app folder.</div>');
  }else{
    setMainMessage('<div class="ok">Dashboard updated successfully.</div>',120000);
  }
  refreshBackupOptions();
}

function tbl(n){return (D&&D.tables&&D.tables[n])?D.tables[n]:[]}
function raw(n){return (D&&D.raw_clean&&D.raw_clean[n])?D.raw_clean[n]:[]}

function fmtCell(v){
  if(v===null || v===undefined || v==='') return '';
  if(typeof v === 'number' && isFinite(v)) return v.toLocaleString(undefined,{maximumFractionDigits:2});
  let s = String(v);
  let n = Number(s);
  if(s.trim()!=='' && isFinite(n) && !s.match(/^0\d+/) && !s.match(/^\d{4}-\d{2}/)) return n.toLocaleString(undefined,{maximumFractionDigits:2});
  return v;
}

function sortHeaderHtml(c){
  return '<th onclick="sortTable(this)" title="Click: descending, ascending, original order"><span class="th-label">'+esc(c)+'</span> <span class="sort-icon" aria-hidden="true">↕</span></th>';
}
function cellDisplayText(cell){
  if(!cell)return '';
  let input=cell.querySelector('input,select,textarea');
  if(input)return String(input.value??'');
  let label=cell.querySelector('.th-label');
  if(label)return String(label.textContent??'');
  return String(cell.innerText??'').replace(/[↕↑↓]\s*$/,'').trim();
}
function updateSortIcons(table){
  if(!table)return;
  let header=table.tHead?table.tHead.querySelector('tr.sortrow'):null;
  if(!header)return;
  [...header.children].forEach(th=>{
    let icon=th.querySelector('.sort-icon');
    if(!icon)return;
    icon.textContent=th.dataset.sortDir==='asc'?'↑':(th.dataset.sortDir==='desc'?'↓':'↕');
  });
}
function table(rows,max=1000){
  if(!rows||!rows.length)return'<p class="note">No data.</p>';
  let cols=Object.keys(rows[0]);
  let h='<div class="tablebox"><input class="table-search table-global-search" placeholder="Global table filter..." oninput="applyTableFilters(this.closest(\'.tablebox\'))"> <button class="btn btn2" onclick="copyTable(this)">Copy visible table</button><br><div class="tablewrap"><table><thead><tr class="sortrow">'+cols.map(c=>sortHeaderHtml(c)).join('')+'</tr><tr class="filterrow">'+cols.map((c,i)=>'<th><input data-col-filter="'+i+'" placeholder="Filter '+esc(c)+'" onclick="event.stopPropagation()" oninput="applyTableFilters(this.closest(\'.tablebox\'))"></th>').join('')+'</tr></thead><tbody>';
  rows.slice(0,max).forEach((r,i)=>{h+='<tr data-original-index="'+i+'">'+cols.map(c=>'<td>'+esc(fmtCell(r[c]))+'</td>').join('')+'</tr>'});
  h+='</tbody></table></div>';
  if(rows.length>max)h+='<p class="note">Showing '+max+' of '+rows.length+' rows. Use CSV/Excel export for full data.</p>';
  return h+'</div>';
}


let CHART_SOURCE_DATA={};
let TABLE_DOWNLOAD_DATA={};
function storageKey(chartKey){return 'portfolio_dashboard_plot_override_'+chartKey;}
function chartRows(chartKey, defaultRows){
  let raw=localStorage.getItem(storageKey(chartKey));
  if(raw){
    try{
      let rows=JSON.parse(raw);
      if(Array.isArray(rows)) return rows;
    }catch(err){}
  }
  return defaultRows||[];
}
function showChartSources(){return (localStorage.getItem('portfolio_dashboard_show_chart_sources')||'hide')==='show';}
function sortChronoRows(rows,key){return [...(rows||[])].sort((a,b)=>chronoCompare(a[key],b[key]));}
function prepareBarRows(rows,y,top=30,desc=true){
  let d=(rows||[]).filter(r=>isFinite(Number(r[y])));
  d.sort((a,b)=>desc?Number(b[y])-Number(a[y]):Number(a[y])-Number(b[y]));
  if(top)d=d.slice(0,top);
  return d;
}
function chronoValue(v){
  let s=String(v??'').trim();
  if(/^\d{4}-\d{1,2}$/.test(s)){let [y,m]=s.split('-').map(Number);return y*100+m;}
  if(/^\d{4}-Q\d$/i.test(s)){let [y,q]=s.toUpperCase().split('-Q').map(Number);return y*10+q;}
  if(/^\d{4}$/.test(s))return Number(s);
  if(/^\d{1,2}$/.test(s))return Number(s);
  let t=Date.parse(s); if(!isNaN(t))return t;
  let n=Number(s); if(isFinite(n))return n;
  return null;
}
function chronoCompare(a,b){
  let av=chronoValue(a), bv=chronoValue(b);
  if(av!==null && bv!==null) return av-bv;
  return String(a??'').localeCompare(String(b??''),undefined,{numeric:true,sensitivity:'base'});
}
function csvEscape(v){let s=String(v??''); return /[",\n\r]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;}
function downloadRowsCsv(rows, filename){
  if(!rows||!rows.length){alert('No rows to download.');return;}
  let cols=Object.keys(rows[0]);
  let csv=cols.map(csvEscape).join(',')+'\n'+rows.map(r=>cols.map(c=>csvEscape(r[c])).join(',')).join('\n');
  let blob=new Blob([csv],{type:'text/csv;charset=utf-8;'}), url=URL.createObjectURL(blob), a=document.createElement('a');
  a.href=url; a.download=filename||'plot_source_data.csv'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
function collectEditableRows(chartKey){
  let box=document.querySelector('[data-chart-source="'+chartKey+'"]'); if(!box)return [];
  let cols=JSON.parse(box.dataset.cols||'[]');
  let rows=[];
  box.querySelectorAll('tbody tr').forEach(tr=>{
    let r={};
    cols.forEach((c,i)=>{
      let v=(tr.cells[i]?.querySelector('[data-edit-cell]')?.value)??'';
      let n=Number(String(v).replace(/,/g,''));
      r[c]=(v!=='' && isFinite(n) && !String(v).match(/^0\d+/) && !String(v).match(/^\d{4}-\d{2}/))?n:v;
    });
    rows.push(r);
  });
  return rows;
}
function applyPlotOverride(chartKey){
  let rows=collectEditableRows(chartKey);
  localStorage.setItem(storageKey(chartKey),JSON.stringify(rows));
  render();
}
function resetPlotOverride(chartKey){
  localStorage.removeItem(storageKey(chartKey));
  render();
}
function downloadPlotSource(chartKey){
  let rows=CHART_SOURCE_DATA[chartKey]||[];
  downloadRowsCsv(rows, chartKey+'_chart_data.csv');
}
function editableChartSourceTable(chartKey, rows, max=120){
  if(!rows||!rows.length)return'<p class="note">No chart source data available.</p>';
  let cols=Object.keys(rows[0]);
  let html='<div class="tablebox" data-chart-source="'+esc(chartKey)+'" data-cols=\''+esc(JSON.stringify(cols))+'\'><input class="table-search table-global-search" placeholder="Global table filter..." oninput="applyTableFilters(this.closest(\'.tablebox\'))"> <button class="btn btn2" onclick="copyTable(this)">Copy visible table</button><br><div class="tablewrap"><table><thead><tr class="sortrow">'+cols.map(c=>sortHeaderHtml(c)).join('')+'</tr><tr class="filterrow">'+cols.map((c,i)=>'<th><input data-col-filter="'+i+'" placeholder="Filter '+esc(c)+'" onclick="event.stopPropagation()" oninput="applyTableFilters(this.closest(\'.tablebox\'))"></th>').join('')+'</tr></thead><tbody>';
  rows.slice(0,max).forEach((r,i)=>{html+='<tr data-original-index="'+i+'">'+cols.map(c=>'<td><input data-edit-cell class="editable-cell" value="'+esc(r[c]??'')+'"></td>').join('')+'</tr>'});
  html+='</tbody></table></div>';
  if(rows.length>max)html+='<p class="note">Showing '+max+' of '+rows.length+' rows. Download CSV for full data.</p>';
  return html+'</div>';
}
function plotSourceTable(containerId,chartKey,rows,title,max=120){
  let target=e(containerId); if(!target)return;
  rows=(rows||[]).slice(0,max); CHART_SOURCE_DATA[chartKey]=rows;
  if(!showChartSources()){target.innerHTML='';return;}
  if(!rows.length){target.innerHTML='<p class="note">No chart source data available.</p>';return;}
  let hasOverride=!!localStorage.getItem(storageKey(chartKey));
  let html='<div class="subtle-title">'+esc(title||'Chart source data')+'</div><p class="smallnote">Editable local chart-source override for testing only. Click Save local override to redraw this chart from the edited rows. To make proper permanent data changes, edit Data Manager or Mapping and rebuild.</p><div class="plot-data-tools"><button class="btn2 mini-btn" onclick="downloadPlotSource(\''+chartKey+'\')">Download this chart data CSV</button><button class="green mini-btn" onclick="applyPlotOverride(\''+chartKey+'\')">Save local override and redraw</button><button class="btn2 mini-btn" onclick="resetPlotOverride(\''+chartKey+'\')">Reset override</button>'+(hasOverride?'<span class="smallnote">Override active</span>':'')+'</div>';
  html+=editableChartSourceTable(chartKey,rows,max);
  target.innerHTML=html;
}

function dataTableBlock(key,title,rows,filename,max=1000,note=''){
  rows=rows||[]; TABLE_DOWNLOAD_DATA[key]=rows;
  let html='<h2>'+esc(title)+'</h2>';
  if(note) html+='<p class="note">'+esc(note)+'</p>';
  html+='<div class="plot-data-tools"><button class="btn2 mini-btn" onclick="downloadRowsCsv(TABLE_DOWNLOAD_DATA[\''+key+'\']||[], \' '+filename+' \'.trim())">Download this table CSV</button></div>';
  html+=table(rows,max);
  return html;
}

function plainReportTable(rows,max=25){
  rows=rows||[];
  if(!rows.length)return '<p class="muted">No data.</p>';
  let cols=Object.keys(rows[0]);
  let html='<div class="report-table-wrap"><table class="report-table"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
  rows.slice(0,max).forEach(r=>{html+='<tr>'+cols.map(c=>'<td>'+esc(fmtCell(r[c]))+'</td>').join('')+'</tr>';});
  html+='</tbody></table></div>';
  if(rows.length>max)html+='<p class="muted">Showing '+max+' of '+rows.length+' rows.</p>';
  return html;
}

function closestTableControl(el){return el.closest('.tablebox')||el.closest('.card')||el.parentElement;}
function filterTable(inp){applyTableFilters(closestTableControl(inp));}
function applyTableFilters(box){
  if(!box)return;
  let table=box.querySelector('table'); if(!table)return;
  let global=(box.querySelector('.table-global-search')?.value||'').toLowerCase();
  let colFilters=[...box.querySelectorAll('[data-col-filter]')].map(inp=>({idx:Number(inp.dataset.colFilter),q:inp.value.toLowerCase()})).filter(x=>x.q);
  [...table.tBodies[0].rows].forEach(r=>{
    let txt=[...r.cells].map(cellDisplayText).join(' ').toLowerCase();
    let ok=!global || txt.includes(global);
    if(ok){
      ok=colFilters.every(f=>cellDisplayText(r.cells[f.idx]).toLowerCase().includes(f.q));
    }
    r.style.display=ok?'':'none';
  });
}

function sortTable(th){
  let table=th.closest('table'), idx=[...th.parentElement.children].indexOf(th), tbody=table.tBodies[0];
  let current=th.dataset.sortDir||'';
  let next=current===''?'desc':(current==='desc'?'asc':'');
  [...th.parentElement.children].forEach(h=>h.dataset.sortDir='');
  th.dataset.sortDir=next;
  let rows=[...tbody.rows];
  if(next===''){
    rows.sort((ra,rb)=>Number(ra.dataset.originalIndex||0)-Number(rb.dataset.originalIndex||0));
  }else{
    rows.sort((ra,rb)=>{
      let a=cellDisplayText(ra.cells[idx]).trim().replace(/,/g,''), b=cellDisplayText(rb.cells[idx]).trim().replace(/,/g,'');
      let na=Number(a), nb=Number(b), bothNum=isFinite(na)&&isFinite(nb)&&a!==''&&b!=='';
      let cmp=bothNum?(na-nb):a.localeCompare(b,undefined,{numeric:true,sensitivity:'base'});
      return next==='asc'?cmp:-cmp;
    });
  }
  rows.forEach(r=>tbody.appendChild(r));
  updateSortIcons(table);
}

function copyTable(btn){
  let t=closestTableControl(btn).querySelector('table');
  let txt='';
  [...t.rows].forEach(r=>{
    if(r.classList.contains('filterrow'))return;
    if(r.style.display!=='none')txt+=[...r.cells].map(c=>cellDisplayText(c)).join('\t')+'\n';
  });
  navigator.clipboard.writeText(txt);
}


function sel(id,vals,defaults=['All']){
  let el=e(id); if(!el)return;
  let unique=[...new Set(vals.filter(v=>v!==null&&v!==undefined&&String(v)!=='' ).map(v=>String(v)))].sort((a,b)=>a.localeCompare(b,undefined,{numeric:true}));
  el.multiple=true; el.size=Math.min(6,Math.max(3,unique.length+1));
  el.innerHTML='<option value="All">All</option>'+unique.map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join('');
  [...el.options].forEach(o=>{o.selected=defaults.includes(o.value)||defaults.includes(o.text);});
  if(![...el.options].some(o=>o.selected) && el.options[0]) el.options[0].selected=true;
}
function selectedValues(id){
  let el=e(id); if(!el)return [];
  let vals=[...el.selectedOptions].map(o=>o.value||o.text);
  if(!vals.length || vals.includes('All'))return [];
  return vals.map(String);
}
function normalizeMultiSelects(){
  document.querySelectorAll('select[multiple]').forEach(el=>{
    let opts=[...el.options], all=opts.find(o=>(o.value||o.text)==='All'), selected=opts.filter(o=>o.selected);
    if(all && all.selected && selected.length>1) all.selected=false;
  });
}
function rowDateOnly(r){
  let v=r.event_date||r.paidOn||r['fill.filledAt']||r['order.createdAt']||r.dateTime||'';
  if(!v)return '';
  return String(v).slice(0,10);
}
function inDateRange(r,fromId,toId){
  let d=rowDateOnly(r), f=e(fromId)?e(fromId).value:'', t=e(toId)?e(toId).value:'';
  if(!d)return true;
  if(f && d<f)return false;
  if(t && d>t)return false;
  return true;
}
function sumBy(rows,keys,sums){
  let m={};
  rows.forEach(r=>{
    let k=keys.map(x=>String(r[x]??'')).join('||');
    if(!m[k]){m[k]={};keys.forEach(x=>m[k][x]=r[x]);sums.forEach(x=>m[k][x]=0);}
    sums.forEach(x=>m[k][x]+=Number(r[x]||0));
  });
  return Object.values(m);
}
function allYearsFromRows(rows){return [...new Set(rows.map(r=>r.year).filter(v=>v!==null&&v!==undefined&&String(v)!==''))];}
function dateFromDividendRow(r){
  let v=r.event_date||r.paidOn||r.date||r.timestamp||r.time||'';
  if(!v && r.year_month) v=String(r.year_month)+'-01';
  let d=v?new Date(String(v)):null;
  return d && !isNaN(d.getTime()) ? d : null;
}
function trailingDividendYieldKpi(){
  let divs=raw('silver_dividends');
  let h=tbl('gold_current_positions_by_symbol');
  let currentValue=h.reduce((s,r)=>s+Number(r.market_value_eur||0),0);
  if(!currentValue) return {rate:null,amount:null,label:'No current holdings value'};
  if(divs.length){
    let dated=divs.map(r=>({row:r,date:dateFromDividendRow(r)})).filter(x=>x.date);
    if(dated.length){
      let maxDate=new Date(Math.max(...dated.map(x=>x.date.getTime())));
      let cutoff=new Date(maxDate.getTime()-365*24*60*60*1000);
      let amount=dated.filter(x=>x.date>=cutoff).reduce((s,x)=>s+Number(x.row.dividend_received_eur||0),0);
      let from=cutoff.toISOString().slice(0,10), to=maxDate.toISOString().slice(0,10);
      return {rate:amount/currentValue,amount,label:'Received '+money(amount)+' from '+from+' to '+to+' / current holdings value'};
    }
  }
  let annual=tbl('gold_annual_summary');
  let years=allYearsFromRows(annual).map(Number).filter(x=>isFinite(x)).sort((a,b)=>b-a);
  if(years.length){
    let y=years[0];
    let amount=annual.filter(r=>Number(r.year)===y).reduce((s,r)=>s+Number(r.dividend_received_eur||0),0);
    return {rate:amount/currentValue,amount,label:'Fallback: latest available year '+y+' received dividends / current holdings value'};
  }
  return {rate:null,amount:null,label:'No dividend data available'};
}

function filters(){
  let h=tbl('gold_current_positions_by_symbol');
  sel('f_asset',h.map(r=>r.asset_type||'Unknown'));
  sel('f_sector',h.map(r=>r.sector||'Unknown'));
  sel('f_broker',h.map(r=>r.broker||'trading212'));
  if(e('pnl_asset')) sel('pnl_asset',h.map(r=>r.asset_type||'Unknown'));
  if(e('pnl_sector')) sel('pnl_sector',h.map(r=>r.sector||'Unknown'));
  if(e('pnl_broker')) sel('pnl_broker',h.map(r=>r.broker||'trading212'));

  let d=tbl('gold_dividend_summary_by_symbol');
  let divRaw=raw('silver_dividends');
  let m=tbl('gold_monthly_summary');
  if(e('div_year_filter')) sel('div_year_filter',allYearsFromRows(divRaw.length?divRaw:m));
  if(e('div_broker_filter')) sel('div_broker_filter',(divRaw.length?divRaw:d).map(r=>r.broker||'trading212'));
  let dqRows=tbl('gold_decision_quality');
  if(e('dq_broker')) sel('dq_broker',dqRows.map(r=>r.broker||'trading212'));
  if(e('dq_asset')) sel('dq_asset',dqRows.map(r=>r.asset_type||'Unknown'));
  if(e('dq_sector')) sel('dq_sector',dqRows.map(r=>r.sector||'Unknown'));
  if(e('act_broker')) sel('act_broker',raw('silver_orders').map(r=>r.broker||'trading212'));
}

function holdings(){
  let rows=[...tbl('gold_current_positions_by_symbol')];
  let terms=splitTerms(e('f_symbol').value), a=selectedValues('f_asset'), se=selectedValues('f_sector'), b=selectedValues('f_broker');
  if(terms.length)rows=rows.filter(r=>matchesTerms(r,terms));
  if(a.length)rows=rows.filter(r=>a.includes(String(r.asset_type||'Unknown')));
  if(se.length)rows=rows.filter(r=>se.includes(String(r.sector||'Unknown')));
  if(b.length)rows=rows.filter(r=>b.includes(String(r.broker||'trading212')));
  return rows;
}
function pnlHoldings(){
  let rows=[...tbl('gold_current_positions_by_symbol')];
  let terms=e('pnl_symbol')?splitTerms(e('pnl_symbol').value):[];
  let assets=selectedValues('pnl_asset'), sectors=selectedValues('pnl_sector'), brokers=selectedValues('pnl_broker');
  if(terms.length)rows=rows.filter(r=>matchesTerms(r,terms));
  if(assets.length)rows=rows.filter(r=>assets.includes(String(r.asset_type||'Unknown')));
  if(sectors.length)rows=rows.filter(r=>sectors.includes(String(r.sector||'Unknown')));
  if(brokers.length)rows=rows.filter(r=>brokers.includes(String(r.broker||'trading212')));
  return rows;
}
function dividendRows(){
  let rows=[...raw('silver_dividends')];
  if(!rows.length) rows=[...tbl('gold_dividend_summary_by_symbol')];
  let terms=e('div_symbol_filter')?splitTerms(e('div_symbol_filter').value):[];
  let yrs=selectedValues('div_year_filter');
  let brs=selectedValues('div_broker_filter');
  if(terms.length) rows=rows.filter(r=>matchesTerms(r,terms));
  if(yrs.length && rows.length && ('year' in rows[0])) rows=rows.filter(r=>yrs.includes(String(r.year)));
  if(brs.length) rows=rows.filter(r=>brs.includes(String(r.broker||'trading212')));
  return rows;
}
function dividendAggregates(){
  let rows=dividendRows();
  let annual=sumBy(rows,['year'],['dividend_received_eur','dividend_net_eur','dividend_tax_eur','dividend_count']);
  let monthly=sumBy(rows,['year','month','year_month'],['dividend_received_eur','dividend_net_eur','dividend_tax_eur','dividend_count']).sort((a,b)=>String(a.year_month).localeCompare(String(b.year_month)));
  let running=0; monthly.forEach(r=>{running+=Number(r.dividend_received_eur||0);r.cumulative_dividend_received_eur=running;});
  let quarterly=sumBy(rows,['year','quarter','year_quarter'],['dividend_received_eur','dividend_net_eur','dividend_tax_eur','dividend_count']);
  let symbol=sumBy(rows,['ticker','ticker_display','symbol_raw','true_ticker','display_name','asset_type','sector','industry','country','is_etf','is_leveraged','risk_bucket','custom_portfolio'],['dividend_received_eur','dividend_net_eur','dividend_tax_eur','dividend_count']).sort((a,b)=>Number(b.dividend_received_eur||0)-Number(a.dividend_received_eur||0));
  return {rows,annual,monthly,quarterly,symbol};
}
function activityRows(){
  let rows=[...raw('silver_orders')];
  let terms=e('act_symbol')?splitTerms(e('act_symbol').value):[];
  let brs=selectedValues('act_broker');
  rows=rows.filter(r=>inDateRange(r,'act_from','act_to'));
  if(terms.length)rows=rows.filter(r=>matchesTerms(r,terms));
  if(brs.length) rows=rows.filter(r=>brs.includes(String(r.broker||'trading212')));
  return rows;
}
function activityAggregates(){
  let rows=activityRows();
  let sums=['buy_value_reported','sell_value_reported','net_buy_value_reported','realized_pnl_reported','realised_pnl_reported','order_count','sell_order_count','realized_pnl_available_count','sell_order_without_reported_realized_pnl_count'];
  let splitByBroker=!selectedValues('act_broker').length;
  let annualKeys=splitByBroker?['broker','year']:['year'];
  let monthlyKeys=splitByBroker?['broker','year','month','year_month']:['year','month','year_month'];
  return {
    rows,
    splitByBroker,
    annual:sumBy(rows,annualKeys,sums),
    monthly:sumBy(rows,monthlyKeys,sums).sort((a,b)=>String(a.year_month).localeCompare(String(b.year_month)))
  };
}
function activityPnlNote(annual){
  let missing=annual.reduce((s,r)=>s+Number(r.sell_order_without_reported_realized_pnl_count||0),0);
  let sells=annual.reduce((s,r)=>s+Number(r.sell_order_count||0),0);
  let reported=annual.reduce((s,r)=>s+Number(r.realized_pnl_available_count||0),0);
  let rows=annual.filter(r=>Number(r.sell_value_reported||0)>0 || Number(r.realized_pnl_reported||0)!==0).map(r=>({year:r.year,sell_value_reported:r.sell_value_reported,reported_realized_pnl_eur:r.realized_pnl_reported,orders_with_reported_realized_pnl:reported?Number(r.realized_pnl_available_count||0):Number(r.realized_pnl_reported||0)!==0?1:0,sell_orders_without_reported_realized_pnl:Number(r.sell_order_without_reported_realized_pnl_count||0)}));
  let msg = '<h3>Realized PnL data coverage</h3><p class="note">This chart uses reported/manual realized PnL fields only. If older sell rows do not include that broker/API/manual field, the dashboard does not recalculate tax-grade realized PnL yet, so those years can show zero even though sells happened.</p>';
  if(sells>0) msg += '<p class="note">Within the current Activity filter: '+reported+' order rows have reported realized PnL, while '+missing+' sell rows do not have reported realized PnL.</p>';
  return msg + table(rows,50);
}

function overviewTimelineRows(){
  let bm = tbl('gold_benchmark_timeline');
  if(bm && bm.length){
    return bm.slice().sort((a,b)=>chronoCompare(a.year_month,b.year_month));
  }
  let monthly = sumBy(raw('silver_orders'), ['year_month'], ['buy_value_reported','sell_value_reported','net_buy_value_reported']);
  let divMonthly = sumBy(raw('silver_dividends'), ['year_month'], ['dividend_received_eur']);
  let months = [...new Set(monthly.map(r=>r.year_month).concat(divMonthly.map(r=>r.year_month)).filter(Boolean))].sort(chronoCompare);
  let om = Object.fromEntries(monthly.map(r=>[String(r.year_month), r]));
  let dm = Object.fromEntries(divMonthly.map(r=>[String(r.year_month), r]));
  let k = tbl('gold_overview_kpis')[0]||{};
  let currentValue = Number(k.calculated_positions_market_value_eur||0);
  let cumNet=0, cumDiv=0;
  let rows=[];
  months.forEach(m=>{
    let o=om[String(m)]||{}, d=dm[String(m)]||{};
    cumNet += Number(o.net_buy_value_reported||0);
    cumDiv += Number(d.dividend_received_eur||0);
    rows.push({year_month:m,cumulative_net_buy_value_eur:round2(cumNet),cumulative_dividends_received_eur:round2(cumDiv),current_portfolio_value_reference_eur:round2(currentValue)});
  });
  return rows;
}
function updateBenchmarkStartOptions(rows){
  let el=e('benchmark_start_month'); if(!el)return;
  let months=[...new Set((rows||[]).map(r=>String(r.year_month||'')).filter(Boolean))].sort(chronoCompare);
  let old=el.value;
  el.innerHTML='<option value="">First available activity month</option>'+months.map(m=>'<option value="'+esc(m)+'">'+esc(m)+'</option>').join('');
  if(months.includes(old)) el.value=old;
}
function ensureBenchmarkControls(){
  let monthEl=e('benchmark_start_month'); if(!monthEl || e('benchmark_period'))return;
  let currentWrap=monthEl.closest('div');
  if(currentWrap){
    currentWrap.querySelector('label').textContent='Custom benchmark start month';
    currentWrap.querySelector('.smallnote').textContent='Use this list when Benchmark period is set to Custom month. Return lines are re-based to 0% at the selected start.';
    currentWrap.insertAdjacentHTML('beforebegin','<div><label>Benchmark period</label><select id="benchmark_period" onchange="render()"><option value="all" selected>First available activity month</option><option value="ytd">YTD</option><option value="1y">1 year</option><option value="2y">2 years</option><option value="3y">3 years</option><option value="custom">Custom month</option></select><div class="smallnote">Both return lines start at 0% for the selected period.</div></div>');
  }
  let card=monthEl.closest('.card');
  if(card && !e('benchmark_method_detail')){
    card.insertAdjacentHTML('beforeend','<div id="benchmark_method_detail" class="explain" style="margin-top:12px"><b>Benchmark calculation detail:</b><br>The S&amp;P 500 line virtually buys SP500 units each month using the same monthly net buy cash flow as your portfolio, priced at the cached month-end S&amp;P 500 close. The return plot is indexed from the selected period start, so the first visible month is 0%. The cash-flow timeline shows selected-period cumulative cash invested, an available-data portfolio value proxy, dividends, and S&amp;P virtual portfolio value. The S&amp;P source shown below can be FRED SP500, Stooq ^SPX, Yahoo ^GSPC, or the latest local cache.<br><br><b>Actual portfolio line:</b> Trading212 history gives orders, dividends, cash transactions and current positions, but not an exact month-end portfolio value for every past month. Because of that, the actual return line is a practical proxy built from available dashboard data, not true historical valuation or broker MWRR.<br><br><b>Cash invested caveat:</b> cumulative cash invested means BUY order value minus SELL order value. It is not the same as Trading212 net deposits. If Trading212 reinvests dividends by creating BUY orders, those reinvested-dividend buys are counted here as invested cash, even though the original dividend cash came from the portfolio.</div>');
  }
  let lineSel=e('overview_timeline_lines');
  if(lineSel){
    if(![...lineSel.options].some(o=>o.value==='portfolio_proxy')){
      lineSel.insertAdjacentHTML('beforeend','<option value="portfolio_proxy" selected>Portfolio value proxy</option>');
    }
    let labels={net_invested:'Cumulative cash invested',dividends:'Cumulative dividends received',sp500:'S&P 500 virtual portfolio value',portfolio_proxy:'Portfolio value proxy'};
    [...lineSel.options].forEach(o=>{if(labels[o.value])o.text=labels[o.value];});
    let note=lineSel.parentElement?lineSel.parentElement.querySelector('.smallnote'):null;
    if(note) note.textContent='Ctrl/Shift-click to select multiple lines. Cash invested is buys minus sells and may include dividend reinvestment buys if Trading212 reports them as BUY orders.';
  }
  let timelineCard=e('chart_overview_timeline')?e('chart_overview_timeline').closest('.card'):null;
  let timelineNote=timelineCard?timelineCard.querySelector('.plotnote'):null;
  if(timelineNote) timelineNote.textContent='Use this chart to compare cumulative cash invested, an available-data portfolio value proxy, dividends received, and the simulated S&P 500 virtual portfolio value using the same cash-flow timing. Cash invested is based on order buys minus sells, so dividend reinvestment buys can be included. The portfolio value line is still approximate because historical month-end valuations are not fully available yet.';
  let returnCard=e('chart_benchmark_comparison')?e('chart_benchmark_comparison').closest('.card'):null;
  let returnNote=returnCard?returnCard.querySelector('.plotnote'):null;
  if(returnNote) returnNote.textContent='This chart compares an available-data portfolio return proxy with an S&P 500 virtual return from the selected start period. It is not the same as Trading212 total return or MWRR. Trading212 history gives orders, dividends, cash transactions and current positions, but not exact historical month-end portfolio values. This proxy uses observed dividends, reported realized PnL where available, and current open PnL only where the app has it.';
}
function ymDate(ym){
  let m=String(ym||'').match(/^(\d{4})-(\d{1,2})$/);
  return m?new Date(Number(m[1]),Number(m[2])-1,1):null;
}
function dateYm(d){
  if(!d)return '';
  return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0');
}
function shiftMonths(ym, count){
  let d=ymDate(ym); if(!d)return '';
  d.setMonth(d.getMonth()+count);
  return dateYm(d);
}
function benchmarkStartFromPeriod(rows){
  rows=[...(rows||[])].sort((a,b)=>chronoCompare(a.year_month,b.year_month));
  if(!rows.length)return '';
  let latest=String(rows[rows.length-1].year_month||'');
  let period=e('benchmark_period')?e('benchmark_period').value:'all';
  if(period==='custom') return e('benchmark_start_month')?e('benchmark_start_month').value:'';
  if(period==='ytd') return latest.slice(0,4)+'-01';
  if(period==='1y') return shiftMonths(latest,-11);
  if(period==='2y') return shiftMonths(latest,-23);
  if(period==='3y') return shiftMonths(latest,-35);
  return '';
}
function selectedBenchmarkRows(rows){
  rows=[...(rows||[])].sort((a,b)=>chronoCompare(a.year_month,b.year_month));
  let start=benchmarkStartFromPeriod(rows);
  if(start) rows=rows.filter(r=>chronoCompare(r.year_month,start)>=0);
  return recalcBenchmarkWindow(rows);
}
function recalcBenchmarkWindow(rows){
  let d=[...(rows||[])].sort((a,b)=>chronoCompare(a.year_month,b.year_month));
  if(!d.length)return d;
  let shares=0, cumNet=0, cumDiv=0, cumRealized=0, prevCumNet=null, prevCumDiv=null, prevCumRealized=null;
  let outRows = d.map((r,idx)=>{
    let out={...r};
    let net=isFinite(Number(r.monthly_net_buy_value_eur))?Number(r.monthly_net_buy_value_eur):(prevCumNet===null?Number(r.cumulative_net_buy_value_eur||0):Number(r.cumulative_net_buy_value_eur||0)-prevCumNet);
    let div=isFinite(Number(r.monthly_dividends_received_eur))?Number(r.monthly_dividends_received_eur):(prevCumDiv===null?Number(r.cumulative_dividends_received_eur||0):Number(r.cumulative_dividends_received_eur||0)-prevCumDiv);
    let realized=isFinite(Number(r.monthly_reported_realized_pnl_eur))?Number(r.monthly_reported_realized_pnl_eur):(prevCumRealized===null?Number(r.cumulative_reported_realized_pnl_eur||0):Number(r.cumulative_reported_realized_pnl_eur||0)-prevCumRealized);
    let price=Number(r.sp500_close);
    if(isFinite(price) && price!==0) shares += net/price;
    cumNet += net; cumDiv += div; cumRealized += realized;
    let spValue=(isFinite(price)?shares*price:null);
    let openAdd=(idx===d.length-1)?Number(r.current_open_pnl_added_to_latest_month_eur||0):0;
    let actualResult=cumDiv+cumRealized+openAdd;
    out.monthly_net_buy_value_eur=net;
    out.monthly_dividends_received_eur=div;
    out.monthly_reported_realized_pnl_eur=realized;
    out.cumulative_net_buy_value_eur=cumNet;
    out.cumulative_dividends_received_eur=cumDiv;
    out.cumulative_reported_realized_pnl_eur=cumRealized;
    out.sp500_virtual_shares=shares;
    out.sp500_virtual_portfolio_value_eur=spValue;
    out.sp500_virtual_result_eur=(spValue===null?null:spValue-cumNet);
    out.sp500_virtual_return_pct=(cumNet&&spValue!==null?((spValue-cumNet)/cumNet):null);
    out.actual_available_result_eur=actualResult;
    out.actual_available_return_pct=(cumNet?actualResult/cumNet:null);
    out.benchmark_note='Recalculated in dashboard from selected start month. Approximate: S&P 500 price index only, no dividends, no EUR/USD FX, no tax/fees. Actual line remains an available-data proxy.';
    prevCumNet=Number(r.cumulative_net_buy_value_eur||0);
    prevCumDiv=Number(r.cumulative_dividends_received_eur||0);
    prevCumRealized=Number(r.cumulative_reported_realized_pnl_eur||0);
    return out;
  });
  let first=outRows[0]||{};
  let baseActual=Number(first.actual_available_return_pct);
  let baseSp=Number(first.sp500_virtual_return_pct);
  outRows.forEach(r=>{
    let actual=Number(r.actual_available_return_pct);
    let sp=Number(r.sp500_virtual_return_pct);
    r.actual_available_return_since_start_pct=(isFinite(actual)&&isFinite(baseActual)&&(1+baseActual)!==0)?((1+actual)/(1+baseActual)-1):null;
    r.sp500_virtual_return_since_start_pct=(isFinite(sp)&&isFinite(baseSp)&&(1+baseSp)!==0)?((1+sp)/(1+baseSp)-1):null;
  });
  outRows.forEach(r=>{
    r.portfolio_value_proxy_eur=Number(r.cumulative_net_buy_value_eur||0)+Number(r.actual_available_result_eur||0);
  });
  return outRows;
}
function drawOverviewTimeline(rows){
  if(!rows||!rows.length){let l=layout('Portfolio Cash Flow & Benchmark Timeline','EUR');l.annotations=[{text:'No order/dividend history available',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot('chart_overview_timeline',[],l,PLOT_CONFIG);return;}
  let selected=selectedValues('overview_timeline_lines');
  if(!selected.length) selected=['net_invested','portfolio_proxy','dividends','sp500'];
  let traces=[];
  let d=[...rows].sort((a,b)=>chronoCompare(a.year_month,b.year_month));
  let x=d.map(r=>r.year_month);
  if(selected.includes('net_invested')) traces.push({type:'scatter',mode:'lines+markers',name:'Cumulative cash invested',x:x,y:d.map(r=>Number(r.cumulative_net_buy_value_eur??0)),hovertemplate:'%{x}<br>%{y:,.2f} EUR<extra></extra>'});
  if(selected.includes('portfolio_proxy')) traces.push({type:'scatter',mode:'lines+markers',name:'Portfolio value proxy',x:x,y:d.map(r=>Number(r.portfolio_value_proxy_eur??r.cumulative_net_buy_value_eur??0)),hovertemplate:'%{x}<br>%{y:,.2f} EUR<extra></extra>'});
  if(selected.includes('dividends')) traces.push({type:'scatter',mode:'lines+markers',name:'Cumulative dividends received',x:x,y:d.map(r=>Number(r.cumulative_dividends_received_eur??0)),hovertemplate:'%{x}<br>%{y:,.2f} EUR<extra></extra>'});
  if(selected.includes('sp500') && d.some(r=>isFinite(Number(r.sp500_virtual_portfolio_value_eur)))) traces.push({type:'scatter',mode:'lines+markers',name:'S&P 500 virtual portfolio value',x:x,y:d.map(r=>Number(r.sp500_virtual_portfolio_value_eur??0)),hovertemplate:'%{x}<br>%{y:,.2f} EUR<extra></extra>'});
  let l=layout('Portfolio Cash Flow & Benchmark Timeline','EUR');
  applyCategoryTicks(l,x);
  l.margin.b=180; l.legend={orientation:'h',y:-0.36,x:0}; l.xaxis.automargin=true;
  drawPlot('chart_overview_timeline', traces, l, PLOT_CONFIG);
}
function drawBenchmarkComparison(rows){
  if(!rows||!rows.length){let l=layout('Portfolio Return Proxy vs S&P 500','Return %');l.annotations=[{text:'No benchmark return timeline available',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot('chart_benchmark_comparison',[],l,PLOT_CONFIG);return;}
  let d=[...rows].sort((a,b)=>chronoCompare(a.year_month,b.year_month)).filter(r=>isFinite(Number(r.cumulative_net_buy_value_eur)) && Number(r.cumulative_net_buy_value_eur)!==0);
  if(!d.length){let l=layout('Portfolio Return Proxy vs S&P 500','Return %');l.annotations=[{text:'No positive/valid net invested base yet',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot('chart_benchmark_comparison',[],l,PLOT_CONFIG);return;}
  let x=d.map(r=>r.year_month);
  let traces=[];
  traces.push({type:'scatter',mode:'lines+markers',name:'Actual available-data return proxy',x:x,y:d.map(r=>Number(r.actual_available_return_since_start_pct??r.actual_available_return_pct??0)),hovertemplate:'%{x}<br>%{y:.2%}<extra></extra>'});
  if(d.some(r=>isFinite(Number(r.sp500_virtual_return_since_start_pct??r.sp500_virtual_return_pct)))) traces.push({type:'scatter',mode:'lines+markers',name:'S&P 500 virtual return',x:x,y:d.map(r=>Number(r.sp500_virtual_return_since_start_pct??r.sp500_virtual_return_pct??0)),hovertemplate:'%{x}<br>%{y:.2%}<extra></extra>'});
  let l=layout('Return Since Selected Benchmark Start','Return %');
  l.yaxis.tickformat='.1%'; applyCategoryTicks(l,x);
  l.margin.b=180; l.legend={orientation:'h',y:-0.36,x:0}; l.xaxis.automargin=true;
  drawPlot('chart_benchmark_comparison',traces,l,PLOT_CONFIG);
}
function renderBenchmarkStatus(){
  let box=e('benchmark_status_box'); if(!box)return;
  let st=tbl('gold_benchmark_status')[0]||{};
  if(!st||!Object.keys(st).length){box.innerHTML='<div class="err"><b>S&amp;P benchmark status:</b> unknown<br>No benchmark status table found yet. Run or load dashboard data first.</div>';return;}
  let status=String(st.benchmark_status||'unknown');
  let cls=status.toLowerCase()==='available'?'explain':'err';
  let source=st.source?'<br><b>Source/cache:</b> '+esc(st.source):'';
  let dates=(st.min_benchmark_date||st.max_benchmark_date)?'<br><b>Benchmark date range:</b> '+esc(st.min_benchmark_date||'?')+' to '+esc(st.max_benchmark_date||'?'):'';
  let rows=st.rows_daily?'<br><b>Daily benchmark rows:</b> '+esc(st.rows_daily):'';
  let message=esc(st.note||st.message||'');
  let expectation=status.toLowerCase()==='available'
    ? 'Expected view: the S&amp;P 500 line should appear in the charts above, using your monthly net buy cash flows as virtual monthly investments.'
    : 'Expected view: benchmark charts remain usable, but the S&amp;P 500 line is hidden until the app can fetch or find a cached benchmark file.';
  box.innerHTML='<div class="'+cls+'" style="margin-top:10px"><b>S&amp;P benchmark status:</b> '+esc(status)+'<br><b>Tracked benchmark:</b> SP500 / S&amp;P 500 price index'+source+dates+rows+'<br>'+message+'<br><br><b>How to read this:</b> '+expectation+' The return comparison is re-based to 0% at the selected start month.<br><b>Cash-flow caveat:</b> cash invested is order BUY value minus SELL value. Reinvested dividends can appear as BUY orders and therefore increase this line, even though Trading212 may separate them as dividends reinvested.<br><b>Return caveat:</b> Trading212/API history does not provide exact month-end portfolio values for every historical month, so this dashboard return line is a practical proxy, not Trading212 MWRR/total return or true historical valuation.<br><b>S&amp;P caveat:</b> price-index benchmark only. It excludes S&amp;P dividends, EUR/USD FX, taxes and fees.</div>';
}

function layout(title,y=''){
  return{title:{text:title,font:{color:cssVar('--text')}},paper_bgcolor:cssVar('--panel'),plot_bgcolor:cssVar('--panel'),font:{color:cssVar('--text')},colorway:currentPalette(),xaxis:{gridcolor:cssVar('--line'),tickangle:-45,automargin:true},yaxis:{gridcolor:cssVar('--line'),title:y,automargin:true},margin:{l:70,r:30,t:70,b:170},legend:{orientation:'h',y:-0.28,x:0}};
}
function applyCategoryTicks(l,cats,maxLabels=16){
  cats=[...(cats||[])];
  l.xaxis=l.xaxis||{};
  l.xaxis.type='category';
  l.xaxis.categoryorder='array';
  l.xaxis.categoryarray=cats;
  l.xaxis.automargin=true;
  if(cats.length<=8){
    l.xaxis.tickangle=0;
    l.margin=l.margin||{};
    l.margin.b=Math.max(120,l.margin.b||0);
    return l;
  }
  let step=Math.max(1,Math.ceil(cats.length/maxLabels));
  let vals=cats.filter((_,i)=>i%step===0 || i===cats.length-1);
  l.xaxis.tickmode='array';
  l.xaxis.tickvals=vals;
  l.xaxis.ticktext=vals;
  l.xaxis.tickangle=-45;
  l.margin=l.margin||{};
  l.margin.b=Math.max(cats.length>24?225:190,l.margin.b||0);
  l.legend=l.legend||{};
  if(l.legend.orientation==='h' || !l.legend.orientation) l.legend.y=Math.min(Number(l.legend.y??-0.28),-0.36);
  return l;
}

function bar(id,rows,x,y,title,ytop,top=30,desc=true,sortMode='value'){
  if(!rows||!rows.length){let l=layout(title);l.annotations=[{text:'No data available',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot(id,[],l,PLOT_CONFIG);return;}
  let d=rows.filter(r=>isFinite(Number(r[y])));
  if(!d.length){let l=layout(title);l.annotations=[{text:'No numeric data available',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot(id,[],l,PLOT_CONFIG);return;}
  if(sortMode==='x') d.sort((a,b)=>chronoCompare(a[x],b[x]));
  else d.sort((a,b)=>desc?Number(b[y])-Number(a[y]):Number(a[y])-Number(b[y]));
  if(top)d=d.slice(0,top);
  let cats=d.map(r=>cleanTickerLabel(r[x]));
  let l=applyCategoryTicks(layout(title,ytop),cats);
  drawPlot(id,[{type:'bar',x:cats,y:d.map(r=>Number(r[y]||0)),customdata:d.map(r=>String(r[x])),hovertemplate:'%{customdata}<br>%{y:,.2f}<extra></extra>'}],l,PLOT_CONFIG);
}

function line(id,rows,x,y,title,ytop,color=null){
  if(!rows||!rows.length){let l=layout(title);l.annotations=[{text:'No data available',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot(id,[],l,PLOT_CONFIG);return;}
  let traces=[];
  if(color){
    [...new Set(rows.map(r=>r[color]))].sort().forEach(g=>{
      let d=rows.filter(r=>r[color]==g).sort((a,b)=>chronoCompare(a[x],b[x]));
      traces.push({type:'scatter',mode:'lines+markers',name:String(g),x:d.map(r=>cleanTickerLabel(r[x])),y:d.map(r=>Number(r[y]||0))});
    });
  }else{
    let d=[...rows].sort((a,b)=>chronoCompare(a[x],b[x]));
    traces.push({type:'scatter',mode:'lines+markers',x:d.map(r=>cleanTickerLabel(r[x])),y:d.map(r=>Number(r[y]||0))});
  }
  let cats=[...new Set(rows.map(r=>cleanTickerLabel(r[x])))].sort((a,b)=>chronoCompare(a,b));
  let l=applyCategoryTicks(layout(title,ytop),cats);
  drawPlot(id,traces,l,PLOT_CONFIG);
}

function grouped(id,rows,x,y,g,title,ytop){
  if(!rows||!rows.length){let l=layout(title);l.annotations=[{text:'No data available',xref:'paper',yref:'paper',x:0.5,y:0.5,showarrow:false,font:{size:18,color:'#9ca3af'}}];drawPlot(id,[],l,PLOT_CONFIG);return;}
  let traces=[];
  [...new Set(rows.map(r=>r[g]))].sort().forEach(gr=>{
    let d=rows.filter(r=>r[g]==gr).sort((a,b)=>chronoCompare(a[x],b[x]));
    traces.push({type:'bar',name:String(gr),x:d.map(r=>cleanTickerLabel(r[x])),y:d.map(r=>Number(r[y]||0))});
  });
  let cats=[...new Set(rows.map(r=>cleanTickerLabel(r[x])))].sort((a,b)=>chronoCompare(a,b));
  let l=applyCategoryTicks(layout(title,ytop),cats); l.barmode='group';
  drawPlot(id,traces,l,PLOT_CONFIG);
}



function getForecastBaselineDividend(){
  let f=tbl('gold_forecast_scenarios');
  let first=f.length?f[0]:{};
  let k=tbl('gold_overview_kpis')[0]||{};
  return Number(first.starting_annual_dividend_eur||k.lifetime_dividends_received_eur||0);
}
function manualForecastRows(){
  let k=tbl('gold_overview_kpis')[0]||{};
  let start=Number(k.calculated_positions_market_value_eur||0);
  let div=Number(getForecastBaselineDividend()||0);
  let ret=Number(e('m_ret')?e('m_ret').value:7)/100;
  let dg=Number(e('m_dg')?e('m_dg').value:4)/100;
  let con=Number(e('m_contrib')?e('m_contrib').value:0);
  let yrs=Math.max(1,Math.min(60,Number(e('m_years')?e('m_years').value:20)||20));
  let rows=[], value=start, annualDiv=div, cumdiv=0;
  for(let y=0;y<=yrs;y++){
    rows.push({
      source:'manual',broker:'manual/user-defined',forecast_mode:'User-defined in dashboard',scenario:'Manual user-defined',year_number:y,
      portfolio_value_eur:value,annual_dividend_eur:annualDiv,cumulative_dividends_eur:cumdiv,
      annual_return_assumption:ret,dividend_growth_assumption:dg,annual_contribution_assumption_eur:con,
      starting_portfolio_value_eur:start,starting_annual_dividend_eur:div,starting_dividend_basis:'Same baseline as default forecast table',
      portfolio_previous_value_eur:y===0?null:rows[rows.length-1].portfolio_value_eur,
      previous_annual_dividend_eur:y===0?null:rows[rows.length-1].annual_dividend_eur,
      portfolio_calculation_step:y===0?'Year 0 = current calculated portfolio value':'('+Number(rows[rows.length-1].portfolio_value_eur).toFixed(2)+' + '+Number(rows[rows.length-1].annual_dividend_eur).toFixed(2)+' + '+con.toFixed(2)+') * (1 + '+ret.toFixed(4)+')',
      dividend_calculation_step:y===0?'Year 0 = baseline annual dividend':'previous annual dividend * (1 + '+dg.toFixed(4)+')',
      calculation_basis:'Manual inputs from this dashboard screen',
      note:'Temporary manual scenario. It is visible in the dashboard but is not saved to the scenario table.'
    });
    if(y<yrs){cumdiv+=annualDiv; value=(value+annualDiv+con)*(1+ret); annualDiv=annualDiv*(1+dg);}
  }
  return rows;
}
function allForecastRows(){return [...tbl('gold_forecast_scenarios'),...manualForecastRows()];}
function clamp(n,lo,hi){return Math.max(lo,Math.min(hi,n));}
function forecastActivityYears(monthly, annual){
  let dates=(monthly||[]).map(r=>String(r.year_month||'')).filter(Boolean).sort((a,b)=>chronoCompare(a,b));
  if(dates.length){
    let start=new Date(dates[0]+'-01'), end=new Date(dates[dates.length-1]+'-01');
    if(!isNaN(start)&&!isNaN(end))return Math.max((end-start)/(365.25*24*3600*1000),1/12);
  }
  let years=(annual||[]).map(r=>Number(r.year)).filter(Number.isFinite).sort((a,b)=>a-b);
  return years.length?Math.max(years[years.length-1]-years[0]+1,1):1;
}
function forecastDividendSeries(annual){
  let currentYear=new Date().getFullYear();
  let by={};
  (annual||[]).forEach(r=>{let y=Number(r.year); if(Number.isFinite(y)&&y<currentYear)by[y]=(by[y]||0)+Number(r.dividend_received_eur||0);});
  let ys=Object.keys(by).map(Number).sort((a,b)=>a-b);
  let vals=ys.map(y=>({year:y,value:by[y]})).filter(r=>r.value>0);
  if(vals.length>=3 && vals[0].value<0.25*vals[1].value)vals=vals.slice(1);
  return vals;
}
function historicalForecastInputs(){
  let k=tbl('gold_overview_kpis')[0]||{}, annual=tbl('gold_annual_summary'), monthly=tbl('gold_monthly_summary');
  let startValue=Number(k.calculated_positions_market_value_eur||0);
  let startDividend=Number(getForecastBaselineDividend()||0);
  let realized=(annual||[]).reduce((s,r)=>s+Number(r.realized_pnl_reported||0),0);
  let observed=Number(k.current_total_unrealized_pnl_eur||0)+Number(k.lifetime_dividends_received_eur||0)+realized;
  let invested=Number(k.current_cost_basis_eur||0);
  let years=forecastActivityYears(monthly,annual);
  let totalReturn=invested>0?observed/invested:0;
  let annualized=totalReturn>-0.99?Math.pow(1+totalReturn,1/years)-1:-0.99;
  let divSeries=forecastDividendSeries(annual), yoy=[];
  for(let i=1;i<divSeries.length;i++)if(divSeries[i-1].value>0)yoy.push((divSeries[i].value/divSeries[i-1].value)-1);
  let avgYoy=yoy.length?yoy.reduce((a,b)=>a+b,0)/yoy.length:0;
  let divCagr=0;
  if(divSeries.length>=2 && divSeries[0].value>0 && divSeries[divSeries.length-1].value>0){
    divCagr=Math.pow(divSeries[divSeries.length-1].value/divSeries[0].value,1/(divSeries.length-1))-1;
  }
  return {startValue,startDividend,observed,invested,years,totalReturn,annualized,divSeries,yoy,avgYoy,divCagr};
}
function portfolioMethodConfig(method, inp){
  if(method==='p_uncapped')return {label:'History annualized, uncapped',ret:inp.annualized,cap:'No cap',basis:'Observed result / current cost basis, annualized across '+inp.years.toFixed(1)+' years. Uses current unrealized PnL, received dividends and reported realized PnL where available.'};
  if(method==='p_simple')return {label:'History simple average per year',ret:clamp(inp.totalReturn/inp.years,-0.15,0.15),cap:'+/-15%',basis:'Observed result / current cost basis, divided by '+inp.years.toFixed(1)+' years. This is a simple annual average, not a compounded annualized rate.'};
  return {label:'History annualized, tempered',ret:clamp(inp.annualized,-0.15,0.15),cap:'+/-15%',basis:'Observed result / current cost basis, annualized across '+inp.years.toFixed(1)+' years. Uses current unrealized PnL, received dividends and reported realized PnL where available.'};
}
function dividendMethodConfig(method, inp){
  if(method==='d_uncapped')return {label:'Average complete-year growth, uncapped',dg:inp.avgYoy,cap:'No cap',basis:'Average year-over-year growth from complete dividend years: '+(inp.divSeries.map(r=>r.year).join(', ')||'not enough years')+'.'};
  if(method==='d_cagr'){
    let range=inp.divSeries.length?String(inp.divSeries[0].year)+' to '+String(inp.divSeries[inp.divSeries.length-1].year):'not enough years';
    return {label:'Complete-year CAGR, tempered',dg:clamp(inp.divCagr,-0.12,0.12),cap:'+/-12%',basis:'Compound annual growth rate from first to latest complete dividend year: '+range+'.'};
  }
  return {label:'Average complete-year growth, tempered',dg:clamp(inp.avgYoy,-0.08,0.08),cap:'+/-8%',basis:'Average year-over-year growth from complete dividend years: '+(inp.divSeries.map(r=>r.year).join(', ')||'not enough years')+'.'};
}
function buildHistoricalForecastRows(kind, portfolioMethod, dividendMethod){
  let inp=historicalForecastInputs();
  let effectivePortfolioMethod=portfolioMethod==='hide'?'p_tempered':portfolioMethod;
  let effectiveDividendMethod=dividendMethod==='hide'?'d_tempered':dividendMethod;
  let pc=portfolioMethodConfig(effectivePortfolioMethod,inp), dc=dividendMethodConfig(effectiveDividendMethod,inp);
  let yrs=Math.max(1,Math.min(60,Number(e('m_years')?e('m_years').value:20)||20));
  let rows=[], value=inp.startValue, div=inp.startDividend, cumdiv=0;
  let scenario=kind==='portfolio'?pc.label:dc.label;
  for(let y=0;y<=yrs;y++){
    let prev=rows[rows.length-1]||{};
    rows.push({
      source:'combined',
      broker:'all brokers',
      forecast_mode:'Historical method selected in dashboard',
      scenario,
      year_number:y,
      portfolio_value_eur:value,
      annual_dividend_eur:div,
      cumulative_dividends_eur:cumdiv,
      annual_return_assumption:pc.ret,
      dividend_growth_assumption:dc.dg,
      annual_contribution_assumption_eur:0,
      portfolio_previous_value_eur:y===0?null:prev.portfolio_value_eur,
      previous_annual_dividend_eur:y===0?null:prev.annual_dividend_eur,
      starting_portfolio_value_eur:inp.startValue,
      starting_annual_dividend_eur:inp.startDividend,
      starting_dividend_basis:'Trailing 12 available months where available, otherwise latest annual amount',
      portfolio_formula:'Year 0 = current portfolio value. Next year = (previous portfolio value + previous annual dividend) * (1 + selected historical return).',
      dividend_formula:'Year 0 = baseline annual dividend. Next year = previous annual dividend * (1 + selected historical dividend growth).',
      portfolio_calculation_step:y===0?'Year 0 = current calculated portfolio value':'('+Number(prev.portfolio_value_eur).toFixed(2)+' + '+Number(prev.annual_dividend_eur).toFixed(2)+') * (1 + '+pc.ret.toFixed(4)+')',
      dividend_calculation_step:y===0?'Year 0 = baseline annual dividend':'previous annual dividend * (1 + '+dc.dg.toFixed(4)+')',
      cumulative_dividend_calculation_step:y===0?'Year 0 = 0 cumulative projected dividends':'previous cumulative dividends + previous annual dividend',
      calculation_basis:(kind==='portfolio'?pc.basis:dc.basis)+' Portfolio cap: '+pc.cap+'. Dividend cap: '+dc.cap+'.',
      note:'Historical scenario model based on available dashboard data, not a prediction.'
    });
    if(y<yrs){cumdiv+=div; value=(value+div)*(1+pc.ret); div=div*(1+dc.dg);}
  }
  return rows;
}
function forecastMethodDetailBlock(title, methodConfig, inp, kind){
  let bits=[
    '<b>Method:</b> '+esc(methodConfig.label),
    '<b>Source data used:</b> '+esc(methodConfig.basis),
    '<b>Cap/filter:</b> '+esc(methodConfig.cap)
  ];
  if(kind==='portfolio'){
    bits.push('<b>Historical observed result:</b> '+money(inp.observed)+' over '+inp.years.toFixed(1)+' years');
    bits.push('<b>Return used:</b> '+pct(methodConfig.ret)+' per year');
    bits.push('<b>Formula:</b> next portfolio value = (previous portfolio value + previous annual dividend) x (1 + selected historical return).');
  }else{
    bits.push('<b>Dividend growth used:</b> '+pct(methodConfig.dg)+' per year');
    bits.push('<b>Starting dividend:</b> '+money(inp.startDividend));
    bits.push('<b>Formula:</b> next annual dividend = previous annual dividend x (1 + selected historical dividend growth).');
  }
  return '<div class="explain"><b>'+esc(title)+'</b><br>'+bits.join('<br>')+'</div>';
}
function renderForecastMethodDetails(portfolioMethod, dividendMethod){
  if(!e('forecast_method_details'))return;
  let inp=historicalForecastInputs();
  let pc=portfolioMethod==='hide'?null:portfolioMethodConfig(portfolioMethod,inp);
  let dc=dividendMethod==='hide'?null:dividendMethodConfig(dividendMethod,inp);
  let portfolioBlock=pc?forecastMethodDetailBlock('Portfolio method details',pc,inp,'portfolio'):'<div class="explain"><b>Portfolio method details</b><br>Portfolio history line is hidden from the plot and forecast tables. Choose a history method to show it again.</div>';
  let dividendBlock=dc?forecastMethodDetailBlock('Dividend method details',dc,inp,'dividend'):'<div class="explain"><b>Dividend method details</b><br>Dividend history line is hidden from the plot and forecast tables. Choose a history method to show it again.</div>';
  e('forecast_method_details').innerHTML='<div class="two">'
    +'<div>'+portfolioBlock+'</div>'
    +'<div>'+dividendBlock+'</div>'
    +'</div>';
}
function selectedExtraForecastRows(allRows){
  let el=e('forecast_extra_scenarios');
  let extras=el?[...el.selectedOptions].map(o=>String(o.value||o.text)):[];
  if(!extras.length)return [];
  if(extras.includes('All'))extras=['Base','Conservative','Optimistic','Manual user-defined'];
  return (allRows||[]).filter(r=>extras.includes(String(r.scenario||'')));
}
function uniqueForecastRows(rows){
  let seen=new Set(), out=[];
  (rows||[]).forEach(r=>{
    let key=String(r.scenario||'')+'|'+String(r.year_number||'')+'|'+String(r.forecast_mode||'');
    if(!seen.has(key)){seen.add(key);out.push(r);}
  });
  return out;
}
function decisionRows(){
  let rows=[...tbl('gold_decision_quality')];
  let terms=e('dq_symbol')?splitTerms(e('dq_symbol').value):[];
  let nt=e('dq_note')?e('dq_note').value.toLowerCase():'';
  let brs=selectedValues('dq_broker');
  let assets=selectedValues('dq_asset');
  let sectors=selectedValues('dq_sector');
  let result=e('dq_result_type')?e('dq_result_type').value:'all';
  let sort=e('dq_sort')?e('dq_sort').value:'total_observed_result_eur';
  if(terms.length)rows=rows.filter(r=>matchesTerms(r,terms));
  if(nt)rows=rows.filter(r=>String(r.decision_quality_note||'').toLowerCase().includes(nt));
  if(brs.length)rows=rows.filter(r=>brs.includes(String(r.broker||'trading212')));
  if(assets.length)rows=rows.filter(r=>assets.includes(String(r.asset_type||'Unknown')));
  if(sectors.length)rows=rows.filter(r=>sectors.includes(String(r.sector||'Unknown')));
  if(result==='positive')rows=rows.filter(r=>Number(r.total_observed_result_eur||0)>=0);
  if(result==='negative')rows=rows.filter(r=>Number(r.total_observed_result_eur||0)<0);
  rows.sort((a,b)=>Number(b[sort]||0)-Number(a[sort]||0));
  return rows;
}
function ensureDecisionPromptBox(){
  if(e('dq_llm_prompt'))return;
  let target=e('tbl_decision'); if(!target)return;
  target.insertAdjacentHTML('beforebegin','<div class="card"><h2>LLM decision review prompts</h2><p class="note">Copy one of these prompts to your preferred LLM. The first reviews past decision quality. The second focuses on what to review now using your current holdings, PnL, dividends, strategy and market context.</p><h3>Decision-quality review</h3><textarea id="dq_llm_prompt"></textarea><br><br><button class="btn2" onclick="copyEl(&quot;dq_llm_prompt&quot;)">Copy review prompt</button><h3>Current holding decision check</h3><p class="note">Use this when you want an LLM to help you think through whether to hold, trim, add, or investigate positions. If the LLM cannot browse current markets, it should tell you exactly what market facts to verify.</p><textarea id="dq_action_prompt"></textarea><br><br><button class="btn2" onclick="copyEl(&quot;dq_action_prompt&quot;)">Copy current-decision prompt</button></div>');
}
function compactDecisionRows(rows, limit=12){
  let cols=['display_name','true_ticker','asset_type','sector','country','current_allocation_pct','market_value_eur','net_buy_value_reported','realized_pnl_reported','total_unrealized_pnl_eur','dividend_received_eur','total_observed_result_eur','open_position_return_pct','decision_quality_note'];
  return (rows||[]).slice(0,limit).map(r=>{
    let o={}; cols.forEach(c=>{if(r[c]!==undefined)o[c]=r[c];}); return o;
  });
}
function compactPositionRows(rows, limit=15){
  let cols=['display_name','true_ticker','broker','asset_type','sector','country','market_value_eur','current_allocation_pct','cost_basis_eur','unrealized_pnl_eur','fx_pnl_eur','total_unrealized_pnl_eur','dividend_received_eur','open_position_return_pct'];
  return (rows||[]).slice(0,limit).map(r=>{
    let o={}; cols.forEach(c=>{if(r[c]!==undefined)o[c]=r[c];}); return o;
  });
}
function decisionPrompt(rows){
  rows=rows||[];
  let best=[...rows].sort((a,b)=>Number(b.total_observed_result_eur||0)-Number(a.total_observed_result_eur||0));
  let worst=[...rows].sort((a,b)=>Number(a.total_observed_result_eur||0)-Number(b.total_observed_result_eur||0));
  let currentFilter=[
    e('dq_symbol')&&e('dq_symbol').value?('Symbol filter: '+e('dq_symbol').value):'Symbol filter: all',
    e('dq_result_type')?('Observed result filter: '+e('dq_result_type').value):'Observed result filter: all',
    e('dq_sort')?('Sort: '+e('dq_sort').value):'Sort: total observed result'
  ].join('\n');
  return `Act as a careful portfolio decision-quality coach. Use the data below as educational analysis, not guaranteed financial advice.

Goal:
Review my investing decisions using the Decision Quality table from my local portfolio dashboard. Focus on patterns, mistakes, good decisions, risk concentration, and practical next questions. Do not give generic market commentary unless it is directly connected to the table.

Context and caveats:
- This is Trading212/local dashboard data, plus any manual source inputs I added.
- Realized PnL is only where the broker API reports it; older sells may be incomplete.
- Open return is based on current open positions, not full historical month-end valuations.
- Dividends are received-dividend rows available to the app.
- "Observed result" combines available realized PnL, current unrealized PnL including FX, and received dividends.

Current dashboard filters:
${currentFilter}

Please provide:
1. Good decisions or patterns I should keep.
2. Bad decisions, repeated mistakes, or avoidable risk patterns.
3. Positions that deserve a deeper review and why.
4. Concentration, sector, country, ETF/leverage, dividend, and currency risks visible in the data.
5. Questions I should ask before adding to losers, trimming winners, or selling positions.
6. A practical review checklist for my next portfolio decision.
7. What extra data would make your analysis more reliable.

Compact best observed-result rows:
${JSON.stringify(compactDecisionRows(best,10),null,2)}

Compact worst observed-result rows:
${JSON.stringify(compactDecisionRows(worst,10),null,2)}

Full filtered Decision Quality table:
HERE COPY THE DECISION QUALITY TABLE BELOW THIS PROMPT IF NEEDED`;
}
function decisionActionPrompt(rows){
  rows=rows||[];
  let k=tbl('gold_overview_kpis')[0]||{};
  let positions=[...tbl('gold_current_positions_by_symbol')].sort((a,b)=>Number(b.market_value_eur||0)-Number(a.market_value_eur||0));
  let decisionWorst=[...rows].sort((a,b)=>Number(a.total_observed_result_eur||0)-Number(b.total_observed_result_eur||0));
  let decisionBest=[...rows].sort((a,b)=>Number(b.total_observed_result_eur||0)-Number(a.total_observed_result_eur||0));
  return `Act as a careful portfolio decision coach. This is educational analysis, not guaranteed financial advice.

Goal:
Help me decide what to review now in my current holdings. Do not just say buy/sell/hold. Give a structured decision process, risks, and questions I should answer before acting.

My strategy / constraints:
HERE I WILL WRITE MY STRATEGY, TIME HORIZON, RISK TOLERANCE, TARGET ALLOCATION, INCOME NEEDS, AND ANY CASH PLANS.

Current market context:
Use current market data if you have browsing/tools. If you do not have live data, clearly say that and list the exact market facts I should verify, e.g. valuation, earnings trend, rates, sector news, dividend safety, ETF exposure, currency risk.

Portfolio snapshot:
- Calculated current portfolio value: ${money(k.calculated_positions_market_value_eur)}
- Price PnL excluding FX: ${money(k.current_price_pnl_eur)}
- FX impact: ${money(k.current_fx_impact_eur)}
- Total unrealized PnL including FX: ${money(k.current_total_unrealized_pnl_eur)}
- Dividends received from available data: ${money(k.lifetime_dividends_received_eur)}
- Largest position weight: ${pct(k.largest_position_pct)}
- Top 5 concentration: ${pct(k.top5_concentration_pct)}

Please provide:
1. Which current holdings should I review first and why.
2. For each review candidate, what evidence would support holding, adding, trimming, or selling.
3. Where I may be adding to losers, over-concentrated, chasing dividends, or ignoring FX/country/sector risk.
4. Which winners might deserve trimming only because of concentration, not because they are bad investments.
5. How the dividend profile changes the decision, without treating dividends as free money.
6. What current market facts I must verify before taking action.
7. A simple action checklist: review now, monitor, no action, or needs more data.

Top current holdings by value:
${JSON.stringify(compactPositionRows(positions,15),null,2)}

Worst observed decision-quality rows:
${JSON.stringify(compactDecisionRows(decisionWorst,10),null,2)}

Best observed decision-quality rows:
${JSON.stringify(compactDecisionRows(decisionBest,8),null,2)}

If you need more detail, I will paste the full current holdings table and Decision Quality table below:
HERE COPY THE TABLES FROM THE DASHBOARD IF NEEDED`;
}
function render(){
  if(!D)return;
  normalizeMultiSelects();
  let k=tbl('gold_overview_kpis')[0]||{}, h=holdings(), pnlRows=pnlHoldings(), ha=tbl('gold_current_positions_by_symbol'), divAgg=dividendAggregates(), actAgg=activityAggregates(), a=divAgg.annual, m=divAgg.monthly, q=divAgg.quarterly, ds=divAgg.symbol, f=allForecastRows(), dq=decisionRows();
  let dr=trailingDividendYieldKpi();
  e('metrics').innerHTML=[
    ['Portfolio value',money(k.calculated_positions_market_value_eur),'Calculated from positions'],
    ['Price PnL',money(k.current_price_pnl_eur),'Excluding FX'],
    ['FX impact',money(k.current_fx_impact_eur),'Currency effect'],
    ['Total PnL incl. FX',money(k.current_total_unrealized_pnl_eur),'Price + FX'],
    ['Dividends',money(k.lifetime_dividends_received_eur),'Available broker/manual history'],
    ['TTM dividend yield',dr.rate===null?'-':pct(dr.rate),dr.label],
    ['Largest position',pct(k.largest_position_pct),'Concentration'],
    ['Top 5 concentration',pct(k.top5_concentration_pct),'Concentration']
  ].map(x=>'<div class="metric"><div class="label">'+x[0]+'</div><div class="value">'+x[1]+'</div><div class="note">'+x[2]+'</div></div>').join('');

  let baseBenchmarkRows=chartRows('overview_timeline',overviewTimelineRows());
  ensureBenchmarkControls();
  updateBenchmarkStartOptions(baseBenchmarkRows);
  let benchmarkWindowRows=selectedBenchmarkRows(baseBenchmarkRows);
  if(e('chart_overview_timeline')){drawOverviewTimeline(benchmarkWindowRows); renderBenchmarkStatus(); plotSourceTable('src_overview_timeline','overview_timeline',benchmarkWindowRows,'Chart source data: overview cash-flow and benchmark timeline');}
  if(e('chart_benchmark_comparison')){let bc=chartRows('benchmark_comparison',benchmarkWindowRows); drawBenchmarkComparison(bc); plotSourceTable('src_benchmark_comparison','benchmark_comparison',bc,'Chart source data: benchmark return timeline'); if(e('tbl_benchmark_comparison')) e('tbl_benchmark_comparison').innerHTML=showChartSources()?dataTableBlock('benchmark_comparison_table','Benchmark return timeline table',bc,'benchmark_return_timeline.csv',100,'Approximate return timeline rows. Changing the benchmark start month recalculates this visible window. Actual monthly mark-to-market is not available yet, so the actual line is an available-data proxy.'):'';}
  let overviewRows=chartRows('overview_current_position_values',prepareBarRows(ha,'market_value_eur',30,true)); bar('chart_overview',overviewRows,'display_name','market_value_eur','Top 30 Current Position Values','EUR',30); plotSourceTable('src_overview_plot','overview_current_position_values',overviewRows,'Chart source data: overview positions');
  let holdingsTop=topN('h_top_n',30); let hPlot=chartRows('holdings_market_value',prepareBarRows(h,'market_value_eur',holdingsTop,true)); bar('chart_holdings',hPlot,'display_name','market_value_eur',(holdingsTop?('Filtered Top '+holdingsTop+' Holdings by Market Value'):'Filtered Holdings by Market Value - All Symbols'),'EUR',holdingsTop); plotSourceTable('src_holdings_plot','holdings_market_value',hPlot,'Chart source data: holdings market value');
  e('tbl_holdings').innerHTML=dataTableBlock('holdings_table','Current holdings table',h,'current_holdings_filtered.csv',1000,'Filtered rows currently shown by Holdings filters.');
  let divYearPlot=chartRows('dividends_by_year',sortChronoRows(a,'year')); bar('chart_div_year',divYearPlot,'year','dividend_received_eur','Dividends Received by Year','EUR',null,true,'x'); plotSourceTable('src_div_year','dividends_by_year',divYearPlot,'Chart source data: dividends by year');
  let divTop=topN('div_top_n',30); let divSymPlot=chartRows('dividends_by_stock',prepareBarRows(ds,'dividend_received_eur',divTop,true)); bar('chart_div_symbol',divSymPlot,'display_name','dividend_received_eur',(divTop?('Dividends Received by Stock - Top '+divTop):('Dividends Received by Stock - All '+ds.length+' Stocks')),'EUR',divTop); plotSourceTable('src_div_symbol','dividends_by_stock',divSymPlot,'Chart source data: dividends by stock');
  let divMonthPlot=chartRows('dividends_by_month',sortChronoRows(m,'year_month')); line('chart_div_month',divMonthPlot,'year_month','dividend_received_eur','Dividends Received by Month Over Time','EUR'); plotSourceTable('src_div_month','dividends_by_month',[...divMonthPlot].sort((a,b)=>chronoCompare(a.year_month,b.year_month)),'Chart source data: dividends by month');
  let divCumPlot=chartRows('dividends_cumulative',sortChronoRows(m,'year_month')); line('chart_div_cumulative',divCumPlot,'year_month','cumulative_dividend_received_eur','Cumulative Dividends Received Over Time','EUR'); plotSourceTable('src_div_cumulative','dividends_cumulative',[...divCumPlot].sort((a,b)=>chronoCompare(a.year_month,b.year_month)),'Chart source data: cumulative dividends');
  let divQuarterPlot=chartRows('dividends_by_quarter_group',q); grouped('chart_div_quarter_group',divQuarterPlot,'quarter','dividend_received_eur','year','Dividends by Quarter, Grouped by Year','EUR'); plotSourceTable('src_div_quarter_group','dividends_by_quarter_group',divQuarterPlot,'Chart source data: quarterly dividends');
  let divMonthGroupPlot=chartRows('dividends_by_month_group',m); grouped('chart_div_month_group',divMonthGroupPlot,'month','dividend_received_eur','year','Dividends by Month, Grouped by Year','EUR'); plotSourceTable('src_div_month_group','dividends_by_month_group',divMonthGroupPlot,'Chart source data: monthly grouped dividends');
  e('tbl_dividends').innerHTML=dataTableBlock('dividends_by_stock_table','Dividend by stock table',ds,'dividends_by_stock_filtered.csv',1000,'Aggregated rows currently shown by Dividend filters.');
  let pnlTop=topN('pnl_top_n',30); let pricePnlPlot=chartRows('price_pnl',prepareBarRows(pnlRows,'unrealized_pnl_eur',pnlTop,true)); bar('chart_price_pnl',pricePnlPlot,'display_name','unrealized_pnl_eur',(pnlTop?('Filtered Price PnL Excluding FX - Top '+pnlTop):'Filtered Price PnL Excluding FX - All Symbols'),'EUR',pnlTop); plotSourceTable('src_price_pnl','price_pnl',pricePnlPlot,'Chart source data: price PnL');
  let fxPnlPlot=chartRows('fx_pnl',prepareBarRows(pnlRows,'fx_pnl_eur',pnlTop,false)); bar('chart_fx_pnl',fxPnlPlot,'display_name','fx_pnl_eur',(pnlTop?('Filtered FX Impact - Top '+pnlTop):'Filtered FX Impact - All Symbols'),'EUR',pnlTop,false); plotSourceTable('src_fx_pnl','fx_pnl',fxPnlPlot,'Chart source data: FX impact');
  let totalPnlPlot=chartRows('total_pnl',prepareBarRows(pnlRows,'total_unrealized_pnl_eur',pnlTop,true)); bar('chart_total_pnl',totalPnlPlot,'display_name','total_unrealized_pnl_eur',(pnlTop?('Filtered Total PnL Including FX - Top '+pnlTop):'Filtered Total PnL Including FX - All Symbols'),'EUR',pnlTop); plotSourceTable('src_total_pnl','total_pnl',totalPnlPlot,'Chart source data: total PnL');
  if(e('chart_net_buys_year')){let nbYearPlot=chartRows('net_buys_year',sortChronoRows(actAgg.annual,'year')); if(actAgg.splitByBroker) grouped('chart_net_buys_year',nbYearPlot,'year','net_buy_value_reported','broker','Net Buy Value by Year and Broker','EUR'); else bar('chart_net_buys_year',nbYearPlot,'year','net_buy_value_reported','Net Buy Value by Year','EUR',null,true,'x'); plotSourceTable('src_net_buys_year','net_buys_year',nbYearPlot,'Chart source data: net buys by year');}
  if(e('chart_realized_pnl_year')){let rpYearPlot=chartRows('realized_pnl_year',sortChronoRows(actAgg.annual,'year')); if(actAgg.splitByBroker) grouped('chart_realized_pnl_year',rpYearPlot,'year','realized_pnl_reported','broker','Reported Realized PnL by Year and Broker','EUR'); else bar('chart_realized_pnl_year',rpYearPlot,'year','realized_pnl_reported','Reported Realized PnL by Year','EUR',null,true,'x'); plotSourceTable('src_realized_pnl_year','realized_pnl_year',rpYearPlot,'Chart source data: reported realized PnL by year');}
  if(e('activity_pnl_note')) e('activity_pnl_note').innerHTML=activityPnlNote(actAgg.annual);
  if(e('chart_orders_month')){let ordersMonthPlot=chartRows('orders_month',sortChronoRows(actAgg.monthly,'year_month')); line('chart_orders_month',ordersMonthPlot,'year_month','net_buy_value_reported',actAgg.splitByBroker?'Net Buy Value by Month and Broker':'Net Buy Value by Month','EUR',actAgg.splitByBroker?'broker':null); plotSourceTable('src_orders_month','orders_month',[...ordersMonthPlot].sort((a,b)=>chronoCompare(a.year_month,b.year_month)),'Chart source data: monthly orders');}
  if(e('tbl_activity')) e('tbl_activity').innerHTML=dataTableBlock('activity_orders_table','Orders / activity table',actAgg.rows,'activity_orders_filtered.csv',1000,'Filtered activity rows used by Activity charts.');
  let dqTop=topN('dq_top_n',30);
  if(e('chart_dq_result')){let dqBest=chartRows('dq_best_result',prepareBarRows(dq,'total_observed_result_eur',dqTop,true)); bar('chart_dq_result',dqBest,'display_name','total_observed_result_eur',(dqTop?('Decision Quality: Best '+dqTop+' Observed Result by Symbol'):'Decision Quality: Observed Result by Symbol - All Filtered'),'EUR',dqTop); plotSourceTable('src_dq_result','dq_best_result',dqBest,'Chart source data: best observed result');}
  if(e('chart_dq_open_return')){let dqBestRet=chartRows('dq_best_open_return',prepareBarRows(dq,'open_position_return_pct',dqTop,true)); bar('chart_dq_open_return',dqBestRet,'display_name','open_position_return_pct',(dqTop?('Decision Quality: Best '+dqTop+' Current Open Return % by Symbol'):'Decision Quality: Current Open Return % by Symbol - All Filtered'),'ratio',dqTop); plotSourceTable('src_dq_open_return','dq_best_open_return',dqBestRet,'Chart source data: best open return');}
  if(e('chart_dq_result_worst')){let dqWorst=chartRows('dq_worst_result',prepareBarRows(dq,'total_observed_result_eur',dqTop,false)); bar('chart_dq_result_worst',dqWorst,'display_name','total_observed_result_eur',(dqTop?('Decision Quality: Worst '+dqTop+' Observed Result by Symbol'):'Decision Quality: Worst Observed Result - All Filtered'),'EUR',dqTop,false); plotSourceTable('src_dq_result_worst','dq_worst_result',dqWorst,'Chart source data: worst observed result');}
  if(e('chart_dq_open_return_worst')){let dqWorstRet=chartRows('dq_worst_open_return',prepareBarRows(dq,'open_position_return_pct',dqTop,false)); bar('chart_dq_open_return_worst',dqWorstRet,'display_name','open_position_return_pct',(dqTop?('Decision Quality: Worst '+dqTop+' Current Open Return % by Symbol'):'Decision Quality: Worst Current Open Return - All Filtered'),'ratio',dqTop,false); plotSourceTable('src_dq_open_return_worst','dq_worst_open_return',dqWorstRet,'Chart source data: worst open return');}
  ensureDecisionPromptBox();
  if(e('dq_llm_prompt')) e('dq_llm_prompt').value=decisionPrompt(dq);
  if(e('dq_action_prompt')) e('dq_action_prompt').value=decisionActionPrompt(dq);
  if(e('tbl_decision')) e('tbl_decision').innerHTML=dataTableBlock('decision_quality_table','Decision quality table',dq,'decision_quality_filtered.csv',1000,'Filtered decision-quality rows used by Decision Quality charts.');

  let portfolioMethod=e('portfolio_forecast_method')?e('portfolio_forecast_method').value:'p_tempered';
  let dividendMethod=e('dividend_forecast_method')?e('dividend_forecast_method').value:'d_tempered';
  let extraForecastRows=selectedExtraForecastRows(f);
  let portfolioHistoryRows=portfolioMethod==='hide'?[]:buildHistoricalForecastRows('portfolio',portfolioMethod,dividendMethod);
  let dividendHistoryRows=dividendMethod==='hide'?[]:buildHistoricalForecastRows('dividend',portfolioMethod,dividendMethod);
  let portfolioForecastRows=[...portfolioHistoryRows,...extraForecastRows];
  let dividendForecastRows=[...dividendHistoryRows,...extraForecastRows];
  renderForecastMethodDetails(portfolioMethod,dividendMethod);
  let forecastValuePlot=chartRows('forecast_value',portfolioForecastRows); line('chart_forecast_value',forecastValuePlot,'year_number','portfolio_value_eur','Portfolio Value Forecast Scenario','EUR','scenario'); plotSourceTable('src_forecast_value','forecast_value',forecastValuePlot,'Chart source data: portfolio forecast');
  let forecastDivPlot=chartRows('forecast_dividend',dividendForecastRows); line('chart_forecast_div',forecastDivPlot,'year_number','annual_dividend_eur','Annual Dividend Forecast Scenario','EUR','scenario'); plotSourceTable('src_forecast_div','forecast_dividend',forecastDivPlot,'Chart source data: dividend forecast');
  let detailRows=uniqueForecastRows([...portfolioHistoryRows,...dividendHistoryRows,...extraForecastRows]);
  let assumptions={};detailRows.forEach(r=>{if(!assumptions[r.scenario]) assumptions[r.scenario]={scenario:r.scenario,forecast_mode:r.forecast_mode,annual_return_assumption:r.annual_return_assumption,dividend_growth_assumption:r.dividend_growth_assumption,annual_contribution_assumption_eur:r.annual_contribution_assumption_eur,starting_portfolio_value_eur:r.starting_portfolio_value_eur,starting_annual_dividend_eur:r.starting_annual_dividend_eur,starting_dividend_basis:r.starting_dividend_basis,calculation_basis:r.calculation_basis,note:r.note};});
  if(e('tbl_forecast_assumptions')) e('tbl_forecast_assumptions').innerHTML=dataTableBlock('forecast_assumptions_table','Forecast assumptions table',Object.values(assumptions),'forecast_assumptions.csv',20,'Scenario assumptions currently selected by the history methods and extra comparison lines.');
  if(e('tbl_forecast_detail')) e('tbl_forecast_detail').innerHTML=dataTableBlock('forecast_detail_table','Forecast calculation detail table',detailRows,'forecast_calculation_detail.csv',500,'Rows used by the selected history methods and extra comparison lines.');
  renderMappingEditor();
  renderDataManager();
  csvLinks();
  setTimeout(resizePlots,120);setTimeout(resizePlots,500);
}

function ssDatasets(){
  let el=e('ss_dataset'); el.innerHTML='';
  Object.keys(D.tables||{}).forEach(n=>el.innerHTML+='<option value="'+esc(n)+'">'+esc(friendlyDatasetName(n))+'</option>');
  ssCols();
}
function ssCols(){
  let rows=tbl(e('ss_dataset').value), cols=rows.length?Object.keys(rows[0]):[];
  let x=e('ss_x'), y=e('ss_y'), oldX=x.value, oldY=y.value;
  let xCols=orderedSelfColumns(rows,cols,'x'), yCols=orderedSelfColumns(rows,cols,'y'), groupCols=orderedSelfColumns(rows,cols,'group');
  x.innerHTML=''; xCols.forEach(c=>x.innerHTML+='<option>'+esc(c)+'</option>');
  y.innerHTML=''; yCols.forEach(c=>y.innerHTML+='<option>'+esc(c)+'</option>');
  let g=e('ss_group'); if(g){let oldGroup=g.value; g.innerHTML='<option value="">No group</option>'+groupCols.map(c=>'<option>'+esc(c)+'</option>').join(''); if(cols.includes(oldGroup)) g.value=oldGroup;}
  let fc=e('ss_filter_col'); if(fc){let oldFilter=fc.value; fc.innerHTML='<option value="">No filter</option>'+cols.map(c=>'<option>'+esc(c)+'</option>').join(''); if(cols.includes(oldFilter)) fc.value=oldFilter;}
  if(cols.includes(oldX)) x.value=oldX;
  if(cols.includes(oldY)) y.value=oldY;
  if(!oldX || !oldY || !cols.includes(oldX) || !cols.includes(oldY)) applyRecommendedSelfServiceSetup(false);
  renderSelfColumnInfo(rows, cols);
  ssChartAdvice();
}
function inferColType(rows,c){
  let vals=rows.slice(0,50).map(r=>r[c]).filter(v=>v!==null&&v!==undefined&&String(v)!=='');
  if(!vals.length)return 'empty/unknown';
  let nums=vals.filter(v=>isFinite(Number(String(v).replace(/,/g,'')))).length;
  if(nums/vals.length>0.7)return 'numeric';
  if(vals.some(v=>String(v).match(/^\d{4}-\d{2}/)))return 'date/time text';
  return 'category/text';
}
function friendlyDatasetName(n){return String(n||'').replace(/^gold_/,'').replace(/^silver_/,'').replace(/^raw_/,'').replace(/_/g,' ');}
function datasetDescription(name){
  const map={
    gold_current_positions_by_symbol:'Current open positions by symbol. Used by Overview, Holdings, PnL, Forecasting starting value and part of Decision Quality.',
    gold_dividend_summary_by_symbol:'Dividend totals by symbol from received dividend rows. No external dividend history is used.',
    gold_annual_summary:'Year-level summary for dividends, activity and reported PnL where the API provides the relevant fields.',
    gold_quarterly_summary:'Quarter-level summary, mainly useful for comparing dividends by quarter across years.',
    gold_monthly_summary:'Month-level summary, useful for dividend and activity timelines.',
    gold_forecast_scenarios:'Scenario rows used by the Forecasting tab. These are assumptions, not predictions.',
    gold_decision_quality:'Experimental symbol-level view combining buys/sells, open PnL, dividends and reported realized PnL.',
    gold_symbol_summary:'Combined symbol-level summary for exploratory self-service charts.',
    gold_benchmark_timeline:'Approximate monthly cash-flow, S&P 500 benchmark and return-proxy timeline built from your net buy cash flows and cached S&P 500 closes.',
    gold_benchmark_comparison:'Latest-row summary of approximate actual available-data return versus S&P 500 virtual benchmark return.',
    gold_benchmark_status:'Benchmark cache/status metadata and caveats.',
    raw_sp500_benchmark_daily:'Cached daily S&P 500 index close values from the external benchmark source.',
    silver_dividends:'Cleaned dividend event rows from the API/cache, before final aggregation.',
    silver_orders:'Cleaned order/activity rows from the API/cache, before final aggregation.'
  };
  return map[name]||'Selected dashboard dataset. Use the column helper below to understand which fields are numeric measures versus categories.';
}
function describeColumn(c){
  let l=String(c||'').toLowerCase();
  if(l==='source')return 'Data source layer, such as broker API, manual input, or external benchmark.';
  if(l==='broker')return 'Broker/source label, such as trading212, freedom24, manual, or external_fx.';
  if(l.includes('ticker')||l.includes('symbol'))return 'Ticker or symbol identifier. Use as an X axis or filter/category.';
  if(l==='display_name')return 'Clean user-facing symbol/name shown in charts after mapping cleanup.';
  if(l.includes('market_value'))return 'Current calculated position value in EUR. Good Y-axis measure.';
  if(l.includes('dividend'))return 'Dividend-related amount/count from received dividend data. No external forecast history is included.';
  if(l.includes('pnl')||l.includes('profit')||l.includes('loss'))return 'Profit/loss field. Some realized PnL fields are API-reported only.';
  if(l.includes('fx'))return 'Currency / foreign-exchange effect field where available.';
  if(l.includes('year_month'))return 'Month key, usually best as a timeline X axis.';
  if(l==='year')return 'Calendar year. Good X axis for yearly views.';
  if(l==='quarter'||l.includes('year_quarter'))return 'Quarter field. Useful for grouped quarterly dividend/activity charts.';
  if(l.includes('asset_type'))return 'Mapped asset classification such as Stock, ETF, REIT, Crypto or Unknown.';
  if(l.includes('sector')||l.includes('industry')||l.includes('country'))return 'Mapped classification field. Useful as filters or chart grouping dimension.';
  if(l.includes('weight')||l.includes('allocation')||l.includes('pct')||l.includes('rate')||l.includes('yield'))return 'Percentage/rate field. Good Y-axis measure, interpreted as ratio where applicable.';
  if(l.includes('count'))return 'Count of events/rows. Good Y-axis measure.';
  if(l.includes('date')||l.includes('time'))return 'Date/time field. Good X axis for timelines.';
  return 'General field. Use as category/group if text, or as Y-axis if detected numeric.';
}
function renderSelfColumnInfo(rows, cols){
  if(e('ss_dataset_desc')) e('ss_dataset_desc').innerHTML='<h3>Selected table: '+esc(friendlyDatasetName(e('ss_dataset').value))+'</h3><p class="note">'+esc(datasetDescription(e('ss_dataset').value))+'</p><p class="smallnote">Rows loaded in this dataset: '+(rows?rows.length:0).toLocaleString()+'</p>';
  if(!e('ss_column_info'))return;
  let info=cols.map(c=>({column:c,description:describeColumn(c),detected_type:inferColType(rows,c),example:rows.length?fmtCell(rows[0][c]):'',how_to_use:inferColType(rows,c)==='numeric'?'Good candidate for Y axis / measure.':'Good candidate for X axis, filters, or third dimension/group.'}));
  e('ss_column_info').innerHTML='<h3>Available columns in selected dataset</h3><p class="note">This helper now includes a short description for each column. It is still heuristic, but it should make self-service charting easier.</p>'+table(info,300);
}
function uniqueNonEmptyCount(rows,c){
  return new Set((rows||[]).map(r=>r[c]).filter(v=>v!==null&&v!==undefined&&String(v)!=='')).size;
}
function firstSelfCol(cols,patterns){
  for(const p of patterns){let found=cols.find(c=>p.test(c)); if(found)return found;}
  return '';
}
function orderedSelfColumns(rows,cols,role){
  let score=c=>{
    let l=String(c||'').toLowerCase(), typ=inferColType(rows,c), s=1000;
    if(role==='y'){
      if(typ==='numeric')s-=500; else s+=500;
      if(/market_value|portfolio_value|total_observed_result|total_unrealized|price_pnl|fx_pnl|pnl|net_buy|buy_value|sell_value|cash|dividend_received|amount|value|return|yield|pct|rate|count/.test(l))s-=300;
      if(/id|source_row|enabled|year$|month$|quarter$/.test(l))s+=250;
    }else{
      if(typ!=='numeric')s-=400;
      if(/year_month|date|year_quarter|year_number|year$|display_name|symbol|ticker|broker|sector|asset_type|country|scenario/.test(l))s-=300;
      if(role==='group'){
        if(typ==='numeric')s+=700;
        if(/broker|year$|sector|asset_type|country|scenario|risk_bucket|custom_portfolio|source/.test(l))s-=450;
        let u=uniqueNonEmptyCount(rows,c); if(u>30)s+=200; if(u<=1)s+=350;
      }
    }
    return s;
  };
  return [...cols].sort((a,b)=>score(a)-score(b)||String(a).localeCompare(String(b)));
}
function recommendedSelfServiceSetup(){
  let ds=e('ss_dataset').value, rows=tbl(ds), cols=rows.length?Object.keys(rows[0]):[];
  let x='', y='', g='', t='bar';
  if(!cols.length)return {x,y,g,t};
  if(ds==='gold_decision_quality'){
    x=firstSelfCol(cols,[/^display_name$/i,/^ticker$/i,/^symbol$/i]);
    y=firstSelfCol(cols,[/^total_observed_result_eur$/i,/^open_position_return_pct$/i,/^market_value_eur$/i,/realized?_pnl|realised?_pnl/i,/dividend_received/i]);
    g=firstSelfCol(cols,[/^broker$/i,/^asset_type$/i,/^sector$/i]);
  }else if(ds==='gold_current_positions_by_symbol'||ds==='gold_symbol_summary'){
    x=firstSelfCol(cols,[/^display_name$/i,/^ticker$/i,/^symbol$/i,/^sector$/i,/^asset_type$/i]);
    y=firstSelfCol(cols,[/^market_value_eur$/i,/^current_allocation_pct$/i,/total_unrealized_pnl|price_pnl|fx_pnl|dividend_received/i]);
    g=firstSelfCol(cols,[/^broker$/i,/^asset_type$/i,/^sector$/i,/^country$/i]);
  }else if(ds==='gold_monthly_summary'||ds==='gold_benchmark_timeline'){
    x=firstSelfCol(cols,[/^year_month$/i,/date/i]);
    y=firstSelfCol(cols,[/portfolio_value|sp500_virtual_value|net_buy|cumulative_cash|dividend_received|amount/i]);
    g=firstSelfCol(cols,[/^broker$/i,/^source$/i]);
    t='line';
  }else if(ds==='gold_annual_summary'){
    x=firstSelfCol(cols,[/^year$/i]);
    y=firstSelfCol(cols,[/dividend_received|net_buy|realized?_pnl|realised?_pnl|amount|count/i]);
    g=firstSelfCol(cols,[/^broker$/i,/^source$/i]);
  }else if(ds==='gold_quarterly_summary'){
    x=firstSelfCol(cols,[/^year_quarter$/i,/^quarter$/i]);
    y=firstSelfCol(cols,[/dividend_received|net_buy|realized?_pnl|realised?_pnl|amount|count/i]);
    g=firstSelfCol(cols,[/^year$/i,/^broker$/i]);
  }else if(ds==='gold_forecast_scenarios'){
    x=firstSelfCol(cols,[/^year_number$/i,/^year$/i]);
    y=firstSelfCol(cols,[/^portfolio_value_eur$/i,/annual_dividend|cumulative_dividend/i]);
    g=firstSelfCol(cols,[/^scenario$/i]);
    t='line';
  }
  if(!x)x=orderedSelfColumns(rows,cols,'x')[0]||cols[0];
  if(!y)y=orderedSelfColumns(rows,cols,'y')[0]||cols.find(c=>c!==x)||cols[0];
  if(!g)g=orderedSelfColumns(rows,cols,'group').find(c=>c!==x&&c!==y&&uniqueNonEmptyCount(rows,c)>1&&uniqueNonEmptyCount(rows,c)<=12)||'';
  if(/year_month|date|year_number/i.test(x))t='line';
  return {x,y,g,t};
}
function applyRecommendedSelfServiceSetup(draw=true){
  let r=recommendedSelfServiceSetup();
  if(r.x&&e('ss_x'))e('ss_x').value=r.x;
  if(r.y&&e('ss_y'))e('ss_y').value=r.y;
  if(e('ss_group'))e('ss_group').value=r.g||'';
  if(r.t&&e('ss_type'))e('ss_type').value=r.t;
  ssChartAdvice();
  if(draw)selfChart();
}
function selfServiceRows(){
  let rows=[...tbl(e('ss_dataset').value)];
  let fc=e('ss_filter_col')?e('ss_filter_col').value:'';
  let fv=e('ss_filter_value')?String(e('ss_filter_value').value||'').toLowerCase().trim():'';
  if(fc && fv) rows=rows.filter(r=>String(r[fc]??'').toLowerCase().includes(fv));
  let max=Number(e('ss_max_rows')?e('ss_max_rows').value:500);
  if(isFinite(max)&&max>0) rows=rows.slice(0,max);
  return rows;
}
function aggregateSelfServiceRows(rows,x,y,splitCol,type){
  if(type==='scatter')return rows.filter(r=>isFinite(Number(r[y])));
  let map=new Map();
  rows.forEach(r=>{
    if(!isFinite(Number(r[y])))return;
    let xv=r[x]??'', gv=splitCol?(r[splitCol]??''):'';
    let key=String(xv)+'||'+String(gv);
    if(!map.has(key)){
      let base={}; base[x]=xv; if(splitCol)base[splitCol]=gv; base[y]=0; base._self_rows=0; map.set(key,base);
    }
    let z=map.get(key); z[y]+=Number(r[y]||0); z._self_rows+=1;
  });
  return [...map.values()].sort((a,b)=>chronoCompare(a[x],b[x]));
}
function ssChartAdvice(){
  if(!e('ss_chart_advice'))return;
  let rows=selfServiceRows(), x=e('ss_x')?e('ss_x').value:'', y=e('ss_y')?e('ss_y').value:'', g=e('ss_group')?e('ss_group').value:'', t=e('ss_type')?e('ss_type').value:'bar';
  let msg=[];
  if(!rows.length){e('ss_chart_advice').textContent='No rows match the current dataset/filter.';return;}
  if(y && inferColType(rows,y)!=='numeric')msg.push('Y axis does not look numeric; choose an amount, value, PnL, return, percentage or count column.');
  let xCount=x?uniqueNonEmptyCount(rows,x):0, splitCol=g, groupCount=splitCol?uniqueNonEmptyCount(rows,splitCol):0;
  if(t==='line' && x && !/year_month|date|year|quarter/i.test(x))msg.push('Line charts work best with date/month/year fields. For symbols or sectors, bar is usually clearer.');
  if(t!=='scatter' && x){
    let plotted=aggregateSelfServiceRows(rows,x,y,splitCol,t);
    if(plotted.length<rows.filter(r=>isFinite(Number(r[y]))).length)msg.push('Duplicate X values are summed before plotting, so the chart shows one value per selected X/group.');
  }
  if(xCount>30)msg.push('This has many X labels, so the chart will show a reduced tick set to keep dates/names readable.');
  if(groupCount>12)msg.push('The selected split has many groups; filtering first or choosing broker/year/asset type may be easier to read.');
  if(!msg.length)msg.push('Good setup. Use Split/group by for separate bars/lines, or leave it empty for one aggregated view.');
  e('ss_chart_advice').textContent=msg.join(' ');
}
function selfSeriesTraces(rows,x,y,colorCol,type){
  let traces=[];
  let groups=[...new Set(rows.map(r=>r[colorCol]).filter(v=>v!==null&&v!==undefined&&String(v)!==''))].sort().slice(0,30);
  groups.forEach(gr=>{
    let d=rows.filter(r=>String(r[colorCol])===String(gr) && isFinite(Number(r[y]))).sort((a,b)=>chronoCompare(a[x],b[x]));
    traces.push({type:type==='bar'?'bar':'scatter',mode:type==='bar'?undefined:(type==='line'?'lines+markers':'markers'),name:String(gr),x:d.map(z=>cleanTickerLabel(z[x])),y:d.map(z=>Number(z[y]||0)),customdata:d.map(z=>String(z[x])),hovertemplate:'%{customdata}<br>%{y:,.2f}<extra></extra>'});
  });
  return traces;
}

function selfChart(){
  let chartKey='self_service_chart_'+e('ss_dataset').value;
  let rows=selfServiceRows(), x=e('ss_x').value, y=e('ss_y').value, g=e('ss_group')?e('ss_group').value:'', t=e('ss_type').value;
  if(!rows.length||!x||!y){e('src_self_chart').innerHTML='<p class="note">No rows after applying the self-service filter.</p>';return;}
  let splitCol=g;
  ssChartAdvice();
  let plotRows=aggregateSelfServiceRows(rows,x,y,splitCol,t);
  if(splitCol){
    if(t==='scatter'){
      let traces=selfSeriesTraces(plotRows,x,y,splitCol,'scatter');
      drawPlot('chart_self',traces,layout(friendlyDatasetName(e('ss_dataset').value)+': '+y+' by '+x+' split by '+splitCol,y),PLOT_CONFIG);
    } else if(t==='line') {
      line('chart_self',plotRows,x,y,friendlyDatasetName(e('ss_dataset').value)+': '+y+' by '+x+' split by '+splitCol,y,splitCol);
    } else {
      grouped('chart_self',plotRows,x,y,splitCol,friendlyDatasetName(e('ss_dataset').value)+': '+y+' by '+x+' split by '+splitCol,y);
    }
  } else {
    if(t=='line')line('chart_self',plotRows,x,y,friendlyDatasetName(e('ss_dataset').value)+': '+y+' by '+x,y);
    else if(t=='scatter'){
      let d=plotRows.filter(z=>isFinite(Number(z[y])));
      drawPlot('chart_self',[{type:'scatter',mode:'markers',x:d.map(z=>cleanTickerLabel(z[x])),y:d.map(z=>Number(z[y]||0))}],layout(friendlyDatasetName(e('ss_dataset').value)+': custom scatter',y),PLOT_CONFIG);
    } else bar('chart_self',plotRows,x,y,friendlyDatasetName(e('ss_dataset').value)+': '+y+' by '+x,y,50);
  }
  plotSourceTable('src_self_chart',chartKey,plotRows.slice(0,200),'Chart source data: self-service chart');
}

function randomSelfChart(){
  let datasets=Object.keys(D.tables||{}).filter(n=>(tbl(n)||[]).length);
  if(!datasets.length)return;
  let usable=[];
  datasets.forEach(name=>{let rows=tbl(name), cols=rows.length?Object.keys(rows[0]):[], nums=cols.filter(c=>inferColType(rows,c)==='numeric'), cats=cols.filter(c=>inferColType(rows,c)!=='numeric'); if(nums.length && cats.length) usable.push(name);});
  if(!usable.length)return;
  let dsName=usable[Math.floor(Math.random()*usable.length)];
  e('ss_dataset').value=dsName; ssCols();
  if(e('ss_filter_col')) e('ss_filter_col').value='';
  if(e('ss_filter_value')) e('ss_filter_value').value='';
  let rows=tbl(dsName), cols=rows.length?Object.keys(rows[0]):[];
  let numeric=cols.filter(c=>inferColType(rows,c)==='numeric');
  let category=cols.filter(c=>inferColType(rows,c)!=='numeric');
  let preferredX=category.filter(c=>/year_month|date|year|quarter|display_name|ticker|symbol|sector|asset_type|broker/i.test(c));
  let preferredY=numeric.filter(c=>/value|dividend|pnl|market|amount|count|return|yield|pct|rate/i.test(c));
  let y=(preferredY.length?preferredY:numeric)[Math.floor(Math.random()*(preferredY.length?preferredY.length:numeric.length))];
  let x=(preferredX.length?preferredX:category)[Math.floor(Math.random()*(preferredX.length?preferredX.length:category.length))] || cols.find(c=>c!==y);
  let groupCandidates=category.filter(c=>c!==x && /year|sector|asset_type|broker|country|custom_portfolio|risk_bucket/i.test(c));
  let g=groupCandidates.length && Math.random()>0.45 ? groupCandidates[Math.floor(Math.random()*groupCandidates.length)] : '';
  let type=(/year_month|date|year_number/i.test(x))?'line':(Math.random()>0.75?'scatter':'bar');
  e('ss_x').value=x; e('ss_y').value=y; e('ss_type').value=type; if(e('ss_group')) e('ss_group').value=g;
  selfChart();
}


function manualRows(kind){
  if(D && D.manual_sources && D.manual_sources[kind]) return D.manual_sources[kind];
  if(kind==='manual_transactions') return raw('source_manual_transactions');
  if(kind==='manual_positions') return raw('source_manual_positions');
  return [];
}
const MANUAL_TX_COLS=['enabled','date','broker','event_type','symbol','quantity','price','gross_amount','net_amount','currency','fx_rate_to_account_currency','tax_amount','fee_amount','realized_pnl','notes','source_row_id'];
const MANUAL_POS_COLS=['enabled','broker','symbol','quantity','average_price','current_price','market_value_eur','cost_basis_eur','unrealized_pnl_eur','fx_pnl_eur','currency','fx_rate_to_account_currency','notes','source_row_id'];
function manualDefaultRow(type){
  if(type==='tx') return {enabled:'TRUE',date:new Date().toISOString().slice(0,10),broker:'manual',event_type:'Dividend',symbol:'',quantity:'',price:'',gross_amount:'',net_amount:'',currency:'EUR',fx_rate_to_account_currency:'1',tax_amount:'',fee_amount:'',realized_pnl:'',notes:'',source_row_id:'manual_'+Date.now()};
  return {enabled:'TRUE',broker:'manual',symbol:'',quantity:'',average_price:'',current_price:'',market_value_eur:'',cost_basis_eur:'',unrealized_pnl_eur:'',fx_pnl_eur:'0',currency:'EUR',fx_rate_to_account_currency:'1',notes:'',source_row_id:'manual_pos_'+Date.now()};
}

function manualColumnHint(col,type){
  const tx={enabled:'TRUE or FALSE',date:'YYYY-MM-DD',broker:'manual_broker',event_type:'Select value',symbol:'AAPL / O / VWCE',quantity:'Number',price:'Number in row currency',gross_amount:'Number in row currency',net_amount:'Number in row currency',currency:'EUR / USD / GBP',fx_rate_to_account_currency:'Rate from row currency to account currency, e.g. USD->EUR 0.92',tax_amount:'Number in row currency',fee_amount:'Number in row currency',realized_pnl:'Number in row currency',notes:'Free text',source_row_id:'Auto or unique id'};
  const pos={enabled:'TRUE or FALSE',broker:'manual_broker',symbol:'AAPL / O / VWCE',quantity:'Number',average_price:'Number in row currency',current_price:'Number in row currency',market_value_eur:'Value in row currency if currency is not account currency',cost_basis_eur:'Cost in row currency if currency is not account currency',unrealized_pnl_eur:'PnL in row currency if currency is not account currency',fx_pnl_eur:'FX PnL in row currency if provided',currency:'EUR / USD / GBP',fx_rate_to_account_currency:'Rate from row currency to account currency, e.g. USD->EUR 0.92',notes:'Free text',source_row_id:'Auto or unique id'};
  return (type==='tx'?tx:pos)[col]||'';
}

function manualEditorHtml(rows, cols, tableId, type){
  rows = rows && rows.length ? rows : [];
  let h='<div class="plot-data-tools"><button class="mini-btn" onclick="'+(type==='tx'?'addManualTransactionRow':'addManualPositionRow')+'()">Add row</button><button class="mini-btn btn2" onclick="collectAndSaveManualInputs(false)">Save only</button><button class="mini-btn green" onclick="saveManualInputs()">Save and rebuild</button></div>';
  h+='<div class="tablewrap"><table id="'+tableId+'"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'<th>delete</th></tr></thead><tbody>';
  rows.forEach((r,i)=>{
    h+='<tr>';
    cols.forEach(c=>{
      let val=r[c]??'';
      if(c==='event_type'){
        let opts=['Buy','Sell','Dividend','Deposit','Withdrawal','Fee','Tax','FX adjustment'];
        h+='<td><select data-col="'+c+'" style="min-width:140px">'+opts.map(o=>'<option '+(String(val).toLowerCase()===o.toLowerCase()?'selected':'')+'>'+o+'</option>').join('')+'</select></td>';
      } else if(c==='enabled'){
        h+='<td><select data-col="'+c+'" style="min-width:90px"><option '+(String(val).toLowerCase()!=='false'?'selected':'')+'>TRUE</option><option '+(String(val).toLowerCase()==='false'?'selected':'')+'>FALSE</option></select></td>';
      } else if(c==='date'){
        h+='<td><input type="date" data-col="'+c+'" value="'+esc(String(val).slice(0,10))+'" style="min-width:145px"></td>';
      } else {
        let hint=manualColumnHint(c,type); h+='<td><input data-col="'+c+'" value="'+esc(val)+'" placeholder="'+esc(hint)+'" title="'+esc(hint)+'" style="min-width:'+(c==='notes'?220:120)+'px"></td>';
      }
    });
    h+='<td><button class="mini-btn btn2" onclick="this.closest(\'tr\').remove()">Remove</button></td></tr>';
  });
  h+='</tbody></table></div>';
  return h;
}

function ensureManualUploadBox(){
  if(e('manual_upload_box'))return;
  let target=e('manual_input_guide')||e('manual_transactions_editor');
  if(!target)return;
  target.insertAdjacentHTML('afterend',`<div class="card" id="manual_upload_box"><h3>Upload manual source file</h3><p class="note">Upload a CSV or Excel file using the same columns as the manual tables below. This writes to the Data Manager source-input files, then you can rebuild the dashboard from cache.</p><div class="filters"><div><label>Manual table</label><select id="manual_upload_target"><option value="manual_transactions">Manual transactions</option><option value="manual_positions">Manual positions</option></select></div><div><label>Upload mode</label><select id="manual_upload_mode"><option value="replace">Replace existing manual rows</option><option value="append">Append to existing manual rows</option></select></div><div><label>CSV / Excel file</label><input id="manual_upload_file" type="file" accept=".csv,.xlsx,.xls"></div><div><label>After upload</label><select id="manual_upload_rebuild"><option value="true" selected>Save and rebuild</option><option value="false">Save only</option></select></div></div><div class="plot-data-tools"><button class="green mini-btn" onclick="uploadManualSourceFile()">Upload manual file</button><button class="btn2 mini-btn" onclick="downloadRowsCsv([Object.fromEntries(MANUAL_TX_COLS.map(c=>[c,'']))], 'manual_transactions_template.csv')">Download transactions template</button><button class="btn2 mini-btn" onclick="downloadRowsCsv([Object.fromEntries(MANUAL_POS_COLS.map(c=>[c,'']))], 'manual_positions_template.csv')">Download positions template</button></div><p id="manual_upload_status" class="smallnote"></p><div id="manual_validation_summary" class="smallnote"></div></div><div class="card" id="manual_llm_prompt_box"><h3>LLM prompt: convert another broker or crypto file</h3><p class="note">Copy this prompt to an LLM together with your broker/crypto export and the column guide above. Ask it to produce CSV-ready rows for the two Data Manager manual tables.</p><textarea id="manual_broker_prompt"></textarea><br><br><button class="btn2" onclick="copyEl('manual_broker_prompt')">Copy prompt</button></div>`);
}

function ensureBackupManagerBox(){
  if(e('backup_manager_box'))return;
  let target=e('manual_llm_prompt_box')||e('manual_upload_box')||e('manual_input_guide');
  if(!target)return;
  target.insertAdjacentHTML('afterend',`<div class="card" id="backup_manager_box"><h3>Local backup manager</h3><p class="note">Backups are local snapshots of your data/output/mapping folders. You can restore one if you accidentally load demo data or delete backups you no longer want to keep.</p><div class="plot-data-tools"><button class="btn2 mini-btn" onclick="refreshBackupOptions()">Refresh backup list</button></div><div id="backup_manager_table"></div><p id="backup_delete_status" class="smallnote"></p></div>`);
}

function manualBrokerPrompt(){
  return `I use a local portfolio dashboard that accepts two manual source-input tables: Manual transactions and Manual positions.

Your task:
Convert my other broker, crypto wallet or crypto exchange export into CSV-ready rows that match the dashboard columns exactly. Do not invent values. If a value is unknown, leave it blank or write Unknown only for classification-style text fields.

Important rules:
- Keep broker raw meaning intact; this is a source-input conversion, not final dashboard editing.
- Currency handling is important. My dashboard/account currency is usually EUR unless I tell you otherwise.
- For every row, set currency to the original broker row currency, e.g. EUR, USD, GBP.
- If currency is the same as my dashboard/account currency, set fx_rate_to_account_currency to 1.
- If currency is different from my dashboard/account currency, do not leave fx_rate_to_account_currency blank. Use the broker-provided FX rate if available. If not available, use a reasonable historical FX rate for the row date and mark it as approximate in notes.
- Example: if the row is USD and my account currency is EUR, and 1 USD = 0.92 EUR on that date, set currency=USD and fx_rate_to_account_currency=0.92.
- If you truly cannot infer an FX rate, keep the original currency, leave fx_rate_to_account_currency blank, and clearly warn me that the dashboard may treat the amount as already account-currency.
- Do not silently convert USD/GBP numbers into EUR without either filling fx_rate_to_account_currency or explaining the conversion.
- For transactions, use event_type values only from: Buy, Sell, Dividend, Deposit, Withdrawal, Fee, Tax, FX adjustment.
- For crypto, keep symbols simple, e.g. BTC or ETH. Use manual_positions for current crypto holdings and manual_transactions for buys/sells/transfers/fees. I can classify the symbol as asset_type Crypto in the Mapping tab.
- For enabled use TRUE unless a row should be ignored.
- For source_row_id create a stable id from broker name + original row id/date/symbol if available.
- Return two separate CSV tables: one for manual_transactions and one for manual_positions.

Manual transactions columns, in exact order:
${MANUAL_TX_COLS.join(',')}

Manual positions columns, in exact order:
${MANUAL_POS_COLS.join(',')}

Column guide:
HERE I WILL COPY THE DATA MANAGER COLUMN GUIDE FROM THE DASHBOARD

Other broker export:
HERE I WILL PASTE OR ATTACH THE BROKER CSV/EXCEL CONTENT

Output format:
1. Briefly list any assumptions or fields you could not map.
2. Then provide a CSV block for manual_transactions.
3. Then provide a CSV block for manual_positions.
4. Do not add extra commentary inside the CSV blocks.`;
}

function manualInputGuide(){
  const txGuide=[
    {table:'Manual transactions',column:'enabled',allowed_or_format:'TRUE / FALSE',required:'Optional',how_used:'FALSE disables the row without deleting it. Values like false, 0, no, disabled, deleted are ignored.'},
    {table:'Manual transactions',column:'date',allowed_or_format:'YYYY-MM-DD',required:'Required',how_used:'Used for year/month/quarter grouping in Activity, Dividends and Forecasting baselines.'},
    {table:'Manual transactions',column:'broker',allowed_or_format:'Free text, e.g. trading212, ibkr, manual_broker',required:'Optional',how_used:'Used for broker/source filtering. Empty defaults to manual.'},
    {table:'Manual transactions',column:'event_type',allowed_or_format:'Buy, Sell, Dividend, Deposit, Withdrawal, Fee, Tax, FX adjustment',required:'Required',how_used:'Controls how the row is transformed into orders, dividends or cash/activity transactions.'},
    {table:'Manual transactions',column:'symbol',allowed_or_format:'Ticker text, e.g. AAPL, O, VWCE, BTC',required:'Required for Buy/Sell/Dividend',how_used:'Mapped to ticker/display_name and connected to symbol-level charts. Crypto symbols can be added here when mapped as asset_type Crypto.'},
    {table:'Manual transactions',column:'quantity',allowed_or_format:'Number. Positive is recommended.',required:'Required only if amount is blank and price exists',how_used:'Fallback amount = quantity x price. Also used as filled quantity for Buy/Sell rows.'},
    {table:'Manual transactions',column:'price',allowed_or_format:'Number',required:'Required only if amount is blank and quantity exists',how_used:'Fallback amount = quantity x price.'},
    {table:'Manual transactions',column:'gross_amount',allowed_or_format:'Number',required:'Optional',how_used:'Used as amount when net_amount is blank.'},
    {table:'Manual transactions',column:'net_amount',allowed_or_format:'Number',required:'Recommended',how_used:'Primary amount used by manual dividends, buys/sells and cash events.'},
    {table:'Manual transactions',column:'currency',allowed_or_format:'Currency code, e.g. EUR, USD, GBP',required:'Optional',how_used:'Stored and displayed. If different from account currency, fx_rate_to_account_currency converts amount/tax/realized PnL into account currency.'},
    {table:'Manual transactions',column:'fx_rate_to_account_currency',allowed_or_format:'Number, e.g. USD to EUR 0.92. Use 1 for account-currency rows.',required:'Required when currency is not account currency',how_used:'Converts manual transaction amounts, tax and realized PnL into account-currency values used by charts.'},
    {table:'Manual transactions',column:'tax_amount',allowed_or_format:'Number',required:'Optional',how_used:'Stored for dividends/tax transparency. Does not invent gross dividends.'},
    {table:'Manual transactions',column:'fee_amount',allowed_or_format:'Number',required:'Optional',how_used:'Stored for future fee analysis. Current core calculations mainly use net/gross amount.'},
    {table:'Manual transactions',column:'realized_pnl',allowed_or_format:'Number',required:'Optional',how_used:'Used as reported realized PnL for manual Sell rows if provided.'},
    {table:'Manual transactions',column:'notes',allowed_or_format:'Free text',required:'Optional',how_used:'For your own explanation/correction notes.'},
    {table:'Manual transactions',column:'source_row_id',allowed_or_format:'Unique text/id. Auto-filled for new rows.',required:'Optional but recommended',how_used:'Shown in validation warnings so you can find the row again.'}
  ];
  const posGuide=[
    {table:'Manual positions',column:'enabled',allowed_or_format:'TRUE / FALSE',required:'Optional',how_used:'FALSE disables the row without deleting it.'},
    {table:'Manual positions',column:'broker',allowed_or_format:'Free text, e.g. ibkr, degiro, manual_broker',required:'Optional',how_used:'Used for broker/source filtering. Empty defaults to manual.'},
    {table:'Manual positions',column:'symbol',allowed_or_format:'Ticker text, e.g. AAPL, O, VWCE, BTC',required:'Required',how_used:'Mapped to ticker/display_name and connected to Holdings, PnL, Forecasting and Decision Quality. Crypto positions can be added here when mapped as asset_type Crypto.'},
    {table:'Manual positions',column:'quantity',allowed_or_format:'Number',required:'Required if market_value_eur is blank',how_used:'Used with current_price to calculate market_value_eur and with average_price to calculate cost_basis_eur.'},
    {table:'Manual positions',column:'average_price',allowed_or_format:'Number',required:'Recommended',how_used:'Used with quantity to calculate cost_basis_eur if cost_basis_eur is blank.'},
    {table:'Manual positions',column:'current_price',allowed_or_format:'Number',required:'Required if market_value_eur is blank',how_used:'Used with quantity to calculate market_value_eur if market_value_eur is blank.'},
    {table:'Manual positions',column:'market_value_eur',allowed_or_format:'Number',required:'Required unless quantity + current_price exist',how_used:'Main value used in Holdings, Overview, PnL and Forecasting.'},
    {table:'Manual positions',column:'cost_basis_eur',allowed_or_format:'Number',required:'Optional but recommended',how_used:'Used for return % and unrealized PnL fallback.'},
    {table:'Manual positions',column:'unrealized_pnl_eur',allowed_or_format:'Number',required:'Optional',how_used:'If blank, calculated as market_value_eur - cost_basis_eur when possible.'},
    {table:'Manual positions',column:'fx_pnl_eur',allowed_or_format:'Number',required:'Optional',how_used:'Added to unrealized_pnl_eur for total unrealized PnL. Blank defaults to 0.'},
    {table:'Manual positions',column:'currency',allowed_or_format:'Currency code, e.g. EUR, USD, GBP',required:'Optional',how_used:'Stored for transparency. If different from account currency, fx_rate_to_account_currency converts value/cost/PnL into account currency.'},
    {table:'Manual positions',column:'fx_rate_to_account_currency',allowed_or_format:'Number, e.g. USD to EUR 0.92. Use 1 for account-currency rows.',required:'Required when currency is not account currency',how_used:'Converts manual position value/cost/PnL into account-currency values used by Holdings, PnL and Overview.'},
    {table:'Manual positions',column:'notes',allowed_or_format:'Free text',required:'Optional',how_used:'For your own explanation/correction notes.'},
    {table:'Manual positions',column:'source_row_id',allowed_or_format:'Unique text/id. Auto-filled for new rows.',required:'Optional but recommended',how_used:'Shown in validation warnings so you can find the row again.'}
  ];
  return '<h3>Manual input column guide</h3><p class="note">These are the values currently supported by the local testing app. If a manual row is not in your account currency, fill currency and fx_rate_to_account_currency so the dashboard can convert the row into account-currency values. Example: USD row into EUR account, currency=USD and fx_rate_to_account_currency=0.92. If the FX rate is missing, the row is treated as already account-currency and validation will warn you.</p><div class="two"><div>'+dataTableBlock('manual_tx_guide','Manual transactions allowed values',txGuide,'manual_transactions_column_guide.csv',200)+'</div><div>'+dataTableBlock('manual_pos_guide','Manual positions allowed values',posGuide,'manual_positions_column_guide.csv',200)+'</div></div>';
}

function renderDataManager(){
  if(!e('manual_transactions_editor')) return;
  if(e('manual_input_guide')) e('manual_input_guide').innerHTML=manualInputGuide();
  ensureManualUploadBox();
  ensureBackupManagerBox();
  if(e('manual_broker_prompt')) e('manual_broker_prompt').value=manualBrokerPrompt();
  renderBackupManagerTable();
  if(!BACKUPS_LOADED)refreshBackupOptions();
  let tx=manualRows('manual_transactions');
  let pos=manualRows('manual_positions');
  e('manual_transactions_editor').innerHTML=manualEditorHtml(tx,MANUAL_TX_COLS,'manual_tx_table','tx');
  e('manual_positions_editor').innerHTML=manualEditorHtml(pos,MANUAL_POS_COLS,'manual_pos_table','pos');
  let val=(D&&D.manual_validation)||raw('manual_input_validation')||[];
  renderManualValidationSummary(val);
  e('manual_validation').innerHTML=table(val,500);
}
function renderManualValidationSummary(rows){
  let target=e('manual_validation_summary')||e('manual_save_status'); if(!target)return;
  rows=rows||[];
  let errors=rows.filter(r=>String(r.severity||'').toLowerCase()==='error');
  let warnings=rows.filter(r=>String(r.severity||'').toLowerCase()==='warning');
  if(errors.length||warnings.length){
    let sample=errors.concat(warnings).slice(0,4).map(r=>esc((r.table||'Manual input')+' row '+(r.row||'-')+': '+(r.message||''))).join('<br>');
    target.innerHTML='<span style="color:var(--yellow);font-weight:800">Manual input checks: '+errors.length+' errors, '+warnings.length+' warnings.</span><br><span class="smallnote">'+sample+'<br>See the Validation warnings table below for details.</span>';
  }else if(rows.length){
    target.innerHTML='<span style="color:var(--green);font-weight:800">Manual input checks passed.</span>';
  }else{
    target.innerHTML='';
  }
}
function addManualTransactionRow(){
  if(!e('manual_tx_table')) renderDataManager();
  let rows=collectManualTable('manual_tx_table',MANUAL_TX_COLS); rows.push(manualDefaultRow('tx'));
  e('manual_transactions_editor').innerHTML=manualEditorHtml(rows,MANUAL_TX_COLS,'manual_tx_table','tx');
}
function addManualPositionRow(){
  if(!e('manual_pos_table')) renderDataManager();
  let rows=collectManualTable('manual_pos_table',MANUAL_POS_COLS); rows.push(manualDefaultRow('pos'));
  e('manual_positions_editor').innerHTML=manualEditorHtml(rows,MANUAL_POS_COLS,'manual_pos_table','pos');
}
function collectManualTable(tableId, cols){
  let table=e(tableId); if(!table) return [];
  return [...table.querySelectorAll('tbody tr')].map(tr=>{
    let row={}; cols.forEach(c=>{let inp=tr.querySelector('[data-col="'+c+'"]'); row[c]=inp?inp.value:'';}); return row;
  }).filter(r=>Object.values(r).some(v=>String(v||'').trim()!==''));
}
async function collectAndSaveManualInputs(rebuild=true){
  let payload={
    manual_transactions:collectManualTable('manual_tx_table',MANUAL_TX_COLS),
    manual_positions:collectManualTable('manual_pos_table',MANUAL_POS_COLS),
    rebuild:!!rebuild
  };
  if(e('manual_save_status')) e('manual_save_status').innerHTML=rebuild?'Saving and rebuilding dashboard from local cache...':'Saving manual inputs...';
  let res=await fetch('/save_manual_inputs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
  let out=await res.json();
  if(!out.ok){if(e('manual_save_status'))e('manual_save_status').innerHTML='<span style="color:var(--red)">'+esc(out.error||'Save failed')+'</span>';return false;}
  if(rebuild){await loadData(); if(e('manual_save_status')) e('manual_save_status').innerHTML='Saved and dashboard rebuilt. Rows: transactions '+out.manual_transaction_rows+', positions '+out.manual_position_rows+'.'; renderManualValidationSummary((D&&D.manual_validation)||out.manual_validation||[]);}
  else {D.manual_sources=out.manual_sources||D.manual_sources; D.manual_validation=out.manual_validation||D.manual_validation; renderDataManager(); if(e('manual_save_status')) e('manual_save_status').innerHTML='Manual inputs saved. Use Save and rebuild to update plots.'; renderManualValidationSummary(D.manual_validation||[]);}
  return true;
}
async function saveManualInputs(){return collectAndSaveManualInputs(true);}

async function uploadManualSourceFile(){
  let file=e('manual_upload_file')?e('manual_upload_file').files[0]:null;
  let target=e('manual_upload_target')?e('manual_upload_target').value:'';
  let mode=e('manual_upload_mode')?e('manual_upload_mode').value:'replace';
  let rebuild=(e('manual_upload_rebuild')?e('manual_upload_rebuild').value:'true')==='true';
  if(!file||!target){if(e('manual_upload_status'))e('manual_upload_status').innerHTML='Please choose a manual table and a CSV/Excel file.';return;}
  let fd=new FormData();
  fd.append('file',file); fd.append('target',target); fd.append('mode',mode); fd.append('rebuild',String(rebuild));
  if(e('manual_upload_status'))e('manual_upload_status').innerHTML='Uploading '+esc(file.name)+'...';
  let res=await fetch('/upload_manual_source',{method:'POST',body:fd});
  let out=await res.json();
  if(!out.ok){if(e('manual_upload_status'))e('manual_upload_status').innerHTML='<span style="color:var(--red)">'+esc(out.error||'Upload failed')+'</span>';return;}
  if(rebuild){await loadData();}
  else {D.manual_sources=out.manual_sources||D.manual_sources; D.manual_validation=out.manual_validation||D.manual_validation; renderDataManager();}
  let val=(D&&D.manual_validation)||out.manual_validation||[];
  let errors=val.filter(r=>String(r.severity||'').toLowerCase()==='error').length;
  let warnings=val.filter(r=>String(r.severity||'').toLowerCase()==='warning').length;
  if(e('manual_upload_status'))e('manual_upload_status').innerHTML='Uploaded '+esc(out.target_label||target)+' with '+out.rows+' rows saved. Manual input checks: '+errors+' errors, '+warnings+' warnings.';
  renderManualValidationSummary(val);
}

function mappingBaseRows(){
  let holdingsRows=tbl('gold_current_positions_by_symbol');
  let existing=D.mapping||[];
  let bySym={};
  existing.forEach(r=>{if(r.symbol_raw)bySym[r.symbol_raw]=r});
  let symbols=[...new Set(holdingsRows.map(r=>r.symbol_raw||r.ticker).filter(Boolean))];
  if(!symbols.length) symbols=[...new Set(existing.map(r=>r.symbol_raw).filter(Boolean))];
  return symbols.map(sym=>{
    let h=holdingsRows.find(r=>(r.symbol_raw||r.ticker)===sym)||{};
    let m=bySym[sym]||{};
    return {
      symbol_raw:sym,
      true_ticker:defaultCleanValue(sym,m.true_ticker||h.true_ticker||sym),
      display_name:defaultCleanValue(sym,m.display_name||h.display_name||h.ticker_display||h.ticker||sym),
      asset_type:m.asset_type||h.asset_type||'Unknown',
      sector:m.sector||h.sector||'Unknown',
      industry:m.industry||h.industry||'Unknown',
      country:m.country||h.country||'Unknown',
      is_etf:m.is_etf||h.is_etf||'Unknown',
      is_leveraged:m.is_leveraged||h.is_leveraged||'Unknown',
      risk_bucket:m.risk_bucket||h.risk_bucket||'Unknown',
      custom_portfolio:m.custom_portfolio||h.custom_portfolio||'Unknown',
      notes:m.notes||''
    }
  });
}
function renderMappingEditor(){
  if(!e('tbl_mapping'))return;
  let rows=mappingBaseRows();
  if(!rows.length){e('tbl_mapping').innerHTML='<p class="note">No symbols loaded yet. Run the dashboard first.</p>';return}
  let cols=['symbol_raw','true_ticker','display_name','asset_type','sector','industry','country','is_etf','is_leveraged','risk_bucket','custom_portfolio','notes'];
  const guide=[
    {column:'symbol_raw',allowed_or_format:'Read-only broker symbol',effect:'The key used to match Trading212/manual rows. Do not edit directly.'},
    {column:'true_ticker',allowed_or_format:'Clean ticker, e.g. AAPL, O, VWCE',effect:'Used for symbol filters, labels and cleaner exports.'},
    {column:'display_name',allowed_or_format:'Any user-facing name',effect:'Used in chart/table labels.'},
    {column:'asset_type',allowed_or_format:'Common values: Stock, ETF, REIT, Bond, Cash, Crypto, Fund, Unknown',effect:'Used by Holdings/Decision filters and future allocation analysis.'},
    {column:'sector',allowed_or_format:'Free text, e.g. Technology, Healthcare, Real Estate, Consumer Defensive',effect:'Used by sector filters and grouping.'},
    {column:'industry',allowed_or_format:'Free text, e.g. Semiconductors, Banks, REIT - Retail',effect:'Stored for deeper grouping/export analysis.'},
    {column:'country',allowed_or_format:'Country/region, e.g. US, UK, Germany, Global',effect:'Used for country exposure analysis and prompts.'},
    {column:'is_etf',allowed_or_format:'TRUE/FALSE, Yes/No, 1/0, or Unknown',effect:'Flags ETF positions. Affects filters/prompts and future allocation checks.'},
    {column:'is_leveraged',allowed_or_format:'TRUE/FALSE, Yes/No, 1/0, or Unknown',effect:'Flags leveraged products. Affects risk prompts and future risk checks.'},
    {column:'risk_bucket',allowed_or_format:'Free text, e.g. Core, Growth, Speculative, Income, Watchlist',effect:'Useful custom grouping for Self-Service/exports.'},
    {column:'custom_portfolio',allowed_or_format:'Free text, e.g. Dividend, Long-term, Trading, Experiment',effect:'Lets you create your own buckets across the dashboard.'},
    {column:'notes',allowed_or_format:'Free text',effect:'Stored in the mapping file for your own context.'}
  ];
  let html='<div class="tablewrap"><table id="mapping_editor"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
  rows.forEach((r,i)=>{
    html+='<tr>';
    cols.forEach(c=>{
      let ro=c==='symbol_raw'?'readonly':'';
      html+='<td><input data-row="'+i+'" data-col="'+c+'" '+ro+' value="'+esc(r[c]||'')+'" style="min-width:130px"></td>';
    });
    html+='</tr>';
  });
  html+='</tbody></table></div><p class="note">Asset type/sector/industry show Unknown until you enrich them here because broker metadata usually does not provide full sector/industry classification. After you click Save mapping and refresh, dashboard tables, filters, plots, exports and prompts are rebuilt from local cache using these values.</p>';
  e('tbl_mapping').innerHTML='<div class="explain"><b>Allowed mapping values and effect:</b><br>Mapping values are flexible text fields except the ETF/leverage flags, where TRUE/FALSE/Yes/No/1/0/Unknown are recommended. Changing a value does not alter broker source data; it enriches the cleaned dashboard layer when you save and refresh.</div>'+dataTableBlock('mapping_column_guide','Mapping column guide',guide,'mapping_column_guide.csv',50)+html;
}
async function saveMapping(){
  let inputs=[...document.querySelectorAll('#mapping_editor input')];
  if(!inputs.length){e('msg').innerHTML='<div class="err">No mapping rows to save.</div>';return}
  let rows={};
  inputs.forEach(inp=>{let i=inp.dataset.row,c=inp.dataset.col;if(!rows[i])rows[i]={};rows[i][c]=inp.value});
  e('msg').innerHTML='<div class="ok">Saving mapping and refreshing dashboard from local cache...</div>';
  let res=await fetch('/save_mapping',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rows:Object.values(rows)})});
  let out=await res.json();
  if(!out.ok){e('msg').innerHTML='<div class="err">'+esc(out.error||'Could not save mapping.')+'</div>';return}
  await loadData();
}

function renderGoldPlotMapping(){
  if(!e('gold_plot_mapping'))return;
  let rows=[
    {table_file:'gold_current_positions_by_symbol.csv',visible_areas:'Overview: Top 30 Current Position Values; Holdings: Filtered Holdings chart/table; PnL: price, FX and total unrealized PnL charts; Forecasting: starting portfolio value; Decision Quality: open position metrics',affected_by_user_adjustments:'Mapping tab changes display_name, true_ticker, asset_type, sector, country, ETF/leverage/risk/custom portfolio fields. Manual positions in Data Manager and Mapping enrichment affect Overview/Holdings/PnL/Forecasting/Decision visible views after save/rebuild.',notes:'Main current-holdings table.'},
    {table_file:'gold_dividend_summary_by_symbol.csv',visible_areas:'Dividends: Dividends Received by Stock/table when no detailed filter is needed; Decision Quality: dividend contribution by symbol; Self-Service; Exports',affected_by_user_adjustments:'Mapping tab changes symbol labels and classification fields. Dividend year/symbol filters in the UI use dividend detail rows and rebuild the visible stock chart dynamically.',notes:'Received dividends only. No invented gross dividend values.'},
    {table_file:'gold_monthly_summary.csv',visible_areas:'Dividends: monthly/cumulative/month grouped charts; Activity: monthly net buy chart; Forecasting: trailing-12-month dividend baseline',affected_by_user_adjustments:'Usually affected by data refresh/history. UI filters can create filtered views without rewriting the saved monthly summary.',notes:'Month-level broker/source summary.'},
    {table_file:'gold_annual_summary.csv',visible_areas:'Dividends: Dividends by Year; Activity: Net Buy Value by Year and Reported Realized PnL by Year; Forecasting: available-history assumptions',affected_by_user_adjustments:'Usually affected by data refresh/history and manual source inputs. UI filters can create filtered views without rewriting the saved annual summary.',notes:'Realized PnL is source-reported/manual only, not recalculated.'},
    {table_file:'gold_quarterly_summary.csv',visible_areas:'Dividends: Dividends by Quarter grouped by Year; Self-Service; Exports',affected_by_user_adjustments:'Usually affected by data refresh/history. UI filters can create filtered views without rewriting the saved quarterly summary.',notes:'Quarter-level broker/source summary.'},
    {table_file:'gold_forecast_scenarios.csv',visible_areas:'Forecasting: default scenario lines, assumptions table, calculation detail table',affected_by_user_adjustments:'Manual user-defined forecast inputs add a browser-only line and do not overwrite the saved CSV yet.',notes:'Scenario model, not prediction.'},
    {table_file:'gold_decision_quality.csv',visible_areas:'Decision Quality: total observed result chart, open return chart and table',affected_by_user_adjustments:'Mapping tab affects labels/classifications. Data refresh and manual source inputs affect orders, dividends and open PnL.',notes:'Experimental. Uses available broker/API/manual data only.'},
    {table_file:'gold_symbol_summary.csv',visible_areas:'Self-Service and Excel/CSV exports; candidate source for future symbol-level combined plots',affected_by_user_adjustments:'Mapping tab affects labels/classifications.',notes:'Combines current holdings with dividend totals by symbol.'},
    {table_file:'gold_data_quality.csv',visible_areas:'Exports / troubleshooting',affected_by_user_adjustments:'Data refresh and FX/mapping quality can change these checks.',notes:'Small QA table.'},
    {table_file:'gold_benchmark_timeline.csv',visible_areas:'Overview: Portfolio cash-flow & benchmark timeline',affected_by_user_adjustments:'Broker/manual order cash flows and dividends affect the virtual S&P 500 benchmark. Benchmark cache refresh can update S&P 500 close values.',notes:'Approximate benchmark. Excludes S&P dividends, EUR/USD FX, taxes and fees.'},
    {table_file:'gold_benchmark_comparison.csv',visible_areas:'Overview: Portfolio vs S&P 500 simple return comparison',affected_by_user_adjustments:'Cash-flow history, dividends, current portfolio value and S&P 500 benchmark cache.',notes:'Since-inception simple return comparison only. Not a true time-weighted return.'},
    {table_file:'raw_sp500_benchmark_daily.csv',visible_areas:'Overview benchmark support and Self-Service',affected_by_user_adjustments:'Auto-updated from benchmark source when the dashboard runs. Cached locally under data/external/benchmarks/.',notes:'Daily S&P 500 index close cache.'}
  ];
  e('gold_plot_mapping').innerHTML='<h2>Data table to visible plots mapping</h2>'+table(rows,50);
}
async function uploadCustomTable(){
  let file=e('custom_table_file')?e('custom_table_file').files[0]:null;
  let target=e('upload_target')?e('upload_target').value:'';
  if(!file||!target){if(e('upload_status'))e('upload_status').innerHTML='Please select a target table and a file.';return;}
  let fd=new FormData(); fd.append('file',file); fd.append('target',target);
  if(e('upload_status'))e('upload_status').innerHTML='Uploading '+esc(file.name)+'...';
  let res=await fetch('/upload_custom_table',{method:'POST',body:fd});
  let out=await res.json();
  if(!out.ok){if(e('upload_status'))e('upload_status').innerHTML='<span style="color:var(--red)">'+esc(out.error||'Upload failed')+'</span>';return;}
  if(e('upload_status'))e('upload_status').innerHTML='Uploaded '+esc(target)+' with '+out.rows+' rows. Visible plots refreshed.';
  await loadData();
}

function csvLinks(){
  renderGoldPlotMapping();
  let names=Object.keys(D.tables||{}).concat(Object.keys(D.raw_clean||{}));
  e('csv_links').innerHTML='<h2>CSV tables</h2><p class="note">Friendly labels are shown below. Downloaded filenames keep their internal table names so exports remain traceable.</p>'+names.map(n=>'<a class="btn btn2" style="margin:4px" href="/download_csv/'+n+'">'+friendlyDatasetName(n)+'</a>').join('');
  renderReportBuilder();
}

const REPORT_SECTIONS=[
  {id:'overview_kpis',label:'Portfolio snapshot',type:'html',defaultOrder:10},
  {id:'overview_timeline',label:'Portfolio cash-flow & benchmark timeline plot',type:'chart',chart:'chart_overview_timeline',defaultOrder:20},
  {id:'benchmark_return',label:'Portfolio return proxy vs S&P 500 plot',type:'chart',chart:'chart_benchmark_comparison',defaultOrder:30},
  {id:'top_positions_plot',label:'Top current positions plot',type:'chart',chart:'chart_overview',defaultOrder:40},
  {id:'holdings_plot',label:'Holdings filtered market value plot',type:'chart',chart:'chart_holdings',defaultOrder:50},
  {id:'holdings_table',label:'Current holdings table',type:'table',table:'gold_current_positions_by_symbol',limit:30,defaultOrder:60},
  {id:'dividends_year',label:'Dividends by year plot',type:'chart',chart:'chart_div_year',defaultOrder:70},
  {id:'dividends_symbol',label:'Dividends by stock plot',type:'chart',chart:'chart_div_symbol',defaultOrder:80},
  {id:'dividends_month',label:'Dividends by month plot',type:'chart',chart:'chart_div_month',defaultOrder:90},
  {id:'dividends_cumulative',label:'Cumulative dividends plot',type:'chart',chart:'chart_div_cumulative',defaultOrder:100},
  {id:'dividends_quarter',label:'Dividends by quarter grouped by year plot',type:'chart',chart:'chart_div_quarter_group',defaultOrder:110},
  {id:'dividends_month_group',label:'Dividends by month grouped by year plot',type:'chart',chart:'chart_div_month_group',defaultOrder:120},
  {id:'dividends_table',label:'Dividend by stock table',type:'table',table:'gold_dividend_summary_by_symbol',limit:30,defaultOrder:130},
  {id:'pnl_price',label:'Price PnL plot',type:'chart',chart:'chart_price_pnl',defaultOrder:140},
  {id:'pnl_fx',label:'FX impact plot',type:'chart',chart:'chart_fx_pnl',defaultOrder:150},
  {id:'pnl_total',label:'Total PnL including FX plot',type:'chart',chart:'chart_total_pnl',defaultOrder:160},
  {id:'forecast_value',label:'Forecast portfolio value plot',type:'chart',chart:'chart_forecast_value',defaultOrder:170},
  {id:'forecast_dividend',label:'Forecast annual dividend plot',type:'chart',chart:'chart_forecast_div',defaultOrder:180},
  {id:'forecast_assumptions',label:'Forecast assumptions table',type:'table',table:'gold_forecast_scenarios',limit:30,defaultOrder:190},
  {id:'activity_net_buys_year',label:'Net buy value by year plot',type:'chart',chart:'chart_net_buys_year',defaultOrder:200},
  {id:'activity_realized_year',label:'Reported realized PnL by year plot',type:'chart',chart:'chart_realized_pnl_year',defaultOrder:210},
  {id:'activity_month',label:'Monthly net buy activity plot',type:'chart',chart:'chart_orders_month',defaultOrder:220},
  {id:'activity_table',label:'Activity/orders table',type:'table',table:'silver_orders',limit:30,defaultOrder:230},
  {id:'decision_best',label:'Decision Quality best observed result plot',type:'chart',chart:'chart_dq_result',defaultOrder:240},
  {id:'decision_best_return',label:'Decision Quality best open return plot',type:'chart',chart:'chart_dq_open_return',defaultOrder:250},
  {id:'decision_worst',label:'Decision Quality worst observed result plot',type:'chart',chart:'chart_dq_result_worst',defaultOrder:260},
  {id:'decision_worst_return',label:'Decision Quality worst open return plot',type:'chart',chart:'chart_dq_open_return_worst',defaultOrder:270},
  {id:'self_service_chart',label:'Current Self-Service custom plot',type:'chart',chart:'chart_self',defaultOrder:290},
  {id:'benchmark_caveats',label:'Benchmark caveats and status',type:'html',defaultOrder:300}
];

function renderReportBuilder(){
  let target=e('report_builder'); if(!target)return;
  let rows=REPORT_SECTIONS.map(s=>'<tr data-report-row data-report-type="'+esc(s.type)+'"><td style="width:110px;text-align:center"><input type="checkbox" data-report-section="'+esc(s.id)+'" '+(s.defaultOrder<=70?'checked':'')+'></td><td style="width:130px;text-align:left"><input type="number" data-report-order="'+esc(s.id)+'" value="'+s.defaultOrder+'" min="1" max="999" style="width:90px"></td><td style="min-width:520px;text-align:left">'+esc(s.label)+'</td><td style="width:120px;text-align:left">'+esc(s.type)+'</td></tr>').join('');
  target.innerHTML='<h2>Report Builder</h2><p class="note">Select dashboard sections, choose their order, then create a standalone HTML report. The report includes the Portfolio Analytics Lab icon/title at the top. For PDF, open the generated HTML and use your browser Print / Save as PDF.</p><p class="smallnote"><b>Order numbers:</b> smaller numbers appear earlier in the report. The default uses 10, 20, 30 so you can easily insert a section between them, but 1, 2, 3, 4 works the same.</p><div class="filters"><div><label>Report title</label><input id="report_title" value="Portfolio Analytics Lab report"></div><div><label>Report theme</label><select id="report_theme"><option value="balanced" selected>Balanced light</option><option value="light">Plain white</option><option value="blueprint">Blue light</option><option value="graphite">Graphite light</option><option value="dark">Dark navy</option><option value="midnight">Midnight dark</option><option value="charcoal">Charcoal dark</option></select></div><div><label>Table rows per section</label><input id="report_table_limit" type="number" min="5" max="200" value="30"></div><div><label>Chart image width</label><input id="report_chart_width" type="number" min="700" max="1800" value="1200"></div></div><div class="filters"><div><label>Section type filter</label><select id="report_type_filter" onchange="filterReportSectionRows()"><option value="all">All section types</option><option value="chart">Charts only</option><option value="table">Tables only</option><option value="html">Text/KPI sections only</option></select></div><div><label>Self-Service report title</label><input id="report_self_title" placeholder="Auto from Self-Service selections"></div><div></div><div></div></div><div class="tablewrap" style="height:420px"><table style="width:100%;table-layout:fixed"><thead><tr><th style="width:110px;text-align:center">Include</th><th style="width:130px;text-align:left">Order</th><th style="min-width:520px;text-align:left">Section</th><th style="width:120px;text-align:left">Type</th></tr></thead><tbody>'+rows+'</tbody></table></div><div class="plot-data-tools"><button class="green mini-btn" onclick="generateHtmlReport()">Generate HTML report</button><button class="btn2 mini-btn" onclick="selectDefaultReportSections()">Default sections</button><button class="btn2 mini-btn" onclick="selectAllReportSections()">Select all</button><button class="btn2 mini-btn" onclick="setVisibleReportSections(true)">Select visible</button><button class="btn2 mini-btn" onclick="setVisibleReportSections(false)">Unselect visible</button></div><p id="report_status" class="smallnote"></p>';
}

function selectDefaultReportSections(){
  REPORT_SECTIONS.forEach(s=>{let cb=document.querySelector('[data-report-section="'+s.id+'"]'); if(cb)cb.checked=s.defaultOrder<=100;});
}
function selectAllReportSections(){
  REPORT_SECTIONS.forEach(s=>{let cb=document.querySelector('[data-report-section="'+s.id+'"]'); if(cb)cb.checked=true;});
}
function filterReportSectionRows(){
  let type=e('report_type_filter')?.value||'all';
  document.querySelectorAll('[data-report-row]').forEach(row=>{row.style.display=(type==='all'||row.dataset.reportType===type)?'':'none';});
}
function setVisibleReportSections(checked){
  document.querySelectorAll('[data-report-row]').forEach(row=>{
    if(row.style.display==='none')return;
    let cb=row.querySelector('[data-report-section]'); if(cb)cb.checked=checked;
  });
}
function selectedReportSections(){
  return REPORT_SECTIONS.filter(s=>document.querySelector('[data-report-section="'+s.id+'"]')?.checked).map(s=>({...s,order:Number(document.querySelector('[data-report-order="'+s.id+'"]')?.value||s.defaultOrder)})).sort((a,b)=>a.order-b.order);
}
async function imageUrlToDataUrl(url){
  let res=await fetch(url), blob=await res.blob();
  return await new Promise((resolve,reject)=>{let reader=new FileReader(); reader.onload=()=>resolve(reader.result); reader.onerror=reject; reader.readAsDataURL(blob);});
}
function overviewKpiReportHtml(){
  let k=tbl('gold_overview_kpis')[0]||{};
  let dr=trailingDividendYieldKpi();
  let cards=[
    ['Portfolio value',money(k.calculated_positions_market_value_eur)],
    ['Price PnL',money(k.current_price_pnl_eur)],
    ['FX impact',money(k.current_fx_impact_eur)],
    ['Total PnL incl. FX',money(k.current_total_unrealized_pnl_eur)],
    ['Dividends',money(k.lifetime_dividends_received_eur)],
    ['TTM dividend yield',dr.rate===null?'-':pct(dr.rate)],
    ['Largest position',pct(k.largest_position_pct)],
    ['Top 5 concentration',pct(k.top5_concentration_pct)]
  ];
  return '<div class="kpis">'+cards.map(c=>'<div class="kpi"><div class="kpi-label">'+esc(c[0])+'</div><div class="kpi-value">'+esc(c[1])+'</div></div>').join('')+'</div>';
}
function reportValue(v,fallback='not available'){
  let s=fmtCell(v);
  return (s===null||s===undefined||s===''||s==='-'||String(s).toLowerCase()==='nan')?fallback:s;
}
function reportMoney(v){let s=money(v);return s==='-'?'not available':s;}
function reportPct(v){let s=pct(v);return s==='-'?'not available':s;}
function benchmarkFactGrid(items){
  return '<div class="facts">'+items.map(i=>'<div class="fact"><div class="fact-label">'+esc(i[0])+'</div><div class="fact-value">'+esc(i[1])+'</div></div>').join('')+'</div>';
}
function reportRange(a,b){
  a=reportValue(a,''); b=reportValue(b,'');
  return (a&&b)?(a+' to '+b):reportValue(a||b);
}
function benchmarkCaveatReportHtml(){
  let st=tbl('gold_benchmark_status')[0]||{};
  let cmp=tbl('gold_benchmark_comparison')[0]||{};
  let status=reportValue(st.benchmark_status||st.status);
  let statusFacts=[
    ['Benchmark', reportValue(st.benchmark_name||st.tracked_benchmark||'SP500 / S&P 500 price index')],
    ['Status', status],
    ['Source', reportValue(st.source)],
    ['Benchmark date range', reportRange(st.min_benchmark_date||st.start_date||st.benchmark_start_date,st.max_benchmark_date||st.end_date||st.benchmark_end_date)],
    ['Daily benchmark rows', reportValue(st.rows_daily||st.daily_benchmark_rows||st.row_count)],
    ['Monthly comparison rows', reportValue(st.rows_timeline)]
  ];
  let actualReturn=cmp.actual_available_return_pct??cmp.actual_return_pct;
  let actualResult=cmp.actual_available_result_eur??cmp.actual_result_eur;
  let comparisonFacts=[
    ['Period', reportRange(cmp.period_start||cmp.start_month,cmp.period_end||cmp.end_month)],
    ['Actual return proxy', reportPct(actualReturn)],
    ['S&P virtual return', reportPct(cmp.sp500_virtual_return_pct)],
    ['Actual available result', reportMoney(actualResult)],
    ['S&P virtual result', reportMoney(cmp.sp500_virtual_result_eur)],
    ['Method', reportValue(cmp.method,'same monthly net-buy cash-flow timing')]
  ];
  return '<p>This section summarizes the report benchmark in plain language. The S&P 500 line is a virtual portfolio: it invests the same monthly net-buy cash flows into the S&P 500 price index, then compares that result with the dashboard\'s available-data portfolio return proxy.</p><p class="muted"><b>Caveats:</b> excludes S&P dividends, EUR/USD FX conversion, taxes and fees. Trading212/API history does not provide exact month-end portfolio value for every past month, so the actual portfolio line is not true historical valuation, broker MWRR or time-weighted return. <b>not available</b> means the required source field was not produced in this dashboard run; usually the chart still remains useful if the return comparison values are available.</p><h3>Benchmark status</h3>'+benchmarkFactGrid(statusFacts)+'<h3>Return comparison</h3>'+benchmarkFactGrid(comparisonFacts);
}
async function sectionReportHtml(section,chartWidth,tableLimit){
  let sectionTitle=section.id==='self_service_chart'?selfServiceReportTitle():section.label;
  if(section.type==='html'){
    let body=section.id==='overview_kpis'?overviewKpiReportHtml():benchmarkCaveatReportHtml();
    return '<section><h2>'+esc(sectionTitle)+'</h2>'+body+'</section>';
  }
  if(section.type==='table'){
    let rows=tbl(section.table);
    return '<section><h2>'+esc(sectionTitle)+'</h2>'+plainReportTable(rows,Math.min(Number(section.limit||tableLimit)||30,Number(tableLimit)||30))+'</section>';
  }
  let el=e(section.chart);
  if(!el || !el.data || !el.data.length)return '<section><h2>'+esc(sectionTitle)+'</h2><p class="muted">Chart is not currently available. Open/render that tab first if needed.</p></section>';
  try{
    let img=await captureReportChart(el,chartWidth,650,e('report_theme')?.value||'light',section.id==='self_service_chart'?sectionTitle:'');
    return '<section><img class="chart-img" src="'+img+'" alt="'+esc(sectionTitle)+'"></section>';
  }catch(err){
    return '<section><h2>'+esc(sectionTitle)+'</h2><p class="muted">Could not capture chart image: '+esc(err.message||err)+'</p></section>';
  }
}
function selfServiceReportTitle(){
  let custom=String(e('report_self_title')?.value||'').trim();
  if(custom)return custom;
  let ds=friendlyDatasetName(e('ss_dataset')?.value||'selected dataset');
  let x=e('ss_x')?.value||'x';
  let y=e('ss_y')?.value||'y';
  let group=e('ss_group')?.value||'';
  return 'Self-Service: '+y+' by '+x+(group?' colored/grouped by '+group:'')+' ('+ds+')';
}
async function captureReportChart(el,width,height,theme,titleOverride=''){
  let holder=document.createElement('div');
  holder.style.position='fixed'; holder.style.left='-99999px'; holder.style.top='0'; holder.style.width=width+'px'; holder.style.height=height+'px';
  document.body.appendChild(holder);
  let data=(el.data||[]).map(trace=>JSON.parse(JSON.stringify(trace)));
  let sourceLayout=el.layout||{};
  let palette=reportThemeColors(theme);
  let axisBase={gridcolor:palette.chartGrid,linecolor:palette.line,zerolinecolor:palette.line,tickfont:{color:palette.text},title:{font:{color:palette.text}}};
  let cleanLayout=JSON.parse(JSON.stringify(sourceLayout));
  cleanLayout.paper_bgcolor=palette.chartBg; cleanLayout.plot_bgcolor=palette.chartBg; cleanLayout.font={...(cleanLayout.font||{}),color:palette.text};
  cleanLayout.title={...(cleanLayout.title||{}),font:{...((cleanLayout.title||{}).font||{}),color:palette.text}};
  if(titleOverride)cleanLayout.title={...(cleanLayout.title||{}),text:titleOverride,font:{...((cleanLayout.title||{}).font||{}),color:palette.text}};
  cleanLayout.xaxis={...(cleanLayout.xaxis||{}),...axisBase};
  cleanLayout.yaxis={...(cleanLayout.yaxis||{}),...axisBase};
  cleanLayout.legend={...(cleanLayout.legend||{}),font:{color:palette.text}};
  try{
    await Plotly.newPlot(holder,data,cleanLayout,{displayModeBar:false,responsive:false});
    let img=await Plotly.toImage(holder,{format:'png',width:width,height:height,scale:1});
    Plotly.purge(holder); holder.remove();
    return img;
  }catch(err){
    try{Plotly.purge(holder);}catch(e){}
    holder.remove();
    throw err;
  }
}
function reportCss(theme){
  let c=reportThemeColors(theme);
  return '<link rel="stylesheet" href="/static/css/app.css">';
}
function reportThemeColors(theme){
  if(theme==='dark')return {bg:'#07111f',card:'#0f172a',chartBg:'#0f172a',text:'#e5e7eb',muted:'#9ca3af',line:'#263244',grid:'#263244',chartGrid:'#263244',kpi:'#111827',kpiLine:'#263244',thead:'#111827'};
  if(theme==='midnight')return {bg:'#030712',card:'#0b1220',chartBg:'#08111f',text:'#f8fafc',muted:'#a8b3c7',line:'#334155',grid:'#263244',chartGrid:'#3b4a60',kpi:'#111827',kpiLine:'#3b4a60',thead:'#172033'};
  if(theme==='charcoal')return {bg:'#121212',card:'#1d1f23',chartBg:'#181a1f',text:'#f3f4f6',muted:'#b8bec8',line:'#3b3f46',grid:'#343840',chartGrid:'#4b5563',kpi:'#24272d',kpiLine:'#4b5563',thead:'#292d34'};
  if(theme==='light')return {bg:'#f8fafc',card:'#ffffff',chartBg:'#f8fafc',text:'#0f172a',muted:'#64748b',line:'#94a3b8',grid:'#cbd5e1',chartGrid:'#b6c2d1',kpi:'#eff6ff',kpiLine:'#bfdbfe',thead:'#dbeafe'};
  if(theme==='blueprint')return {bg:'#e8f1fb',card:'#f8fbff',chartBg:'#edf5ff',text:'#0b1f3a',muted:'#35506d',line:'#7fa1c7',grid:'#b7cbe2',chartGrid:'#8fb0d1',kpi:'#deedff',kpiLine:'#9ec5ee',thead:'#cfe3fa'};
  if(theme==='graphite')return {bg:'#e9edf2',card:'#f8fafc',chartBg:'#f1f5f9',text:'#111827',muted:'#4b5563',line:'#94a3b8',grid:'#c5ced9',chartGrid:'#8fa0b3',kpi:'#e2e8f0',kpiLine:'#aab7c7',thead:'#d7dee8'};
  return {bg:'#edf4fb',card:'#fbfdff',chartBg:'#f1f7fd',text:'#0f172a',muted:'#475569',line:'#8ea9c3',grid:'#c1d1e2',chartGrid:'#96abc1',kpi:'#e1effb',kpiLine:'#a9c9e8',thead:'#d4e5f6'};
}
async function generateHtmlReport(){
  let status=e('report_status'); if(status)status.innerHTML='Generating report...';
  let sections=selectedReportSections();
  if(!sections.length){if(status)status.innerHTML='Select at least one section.';return;}
  let chartWidth=Math.max(700,Math.min(1800,Number(e('report_chart_width')?.value||1200)));
  let tableLimit=Math.max(5,Math.min(200,Number(e('report_table_limit')?.value||30)));
  let title=e('report_title')?.value||'Portfolio Analytics Lab report';
  let theme=e('report_theme')?.value||'light';
  let icon='';
  try{icon=await imageUrlToDataUrl('/static/brand/portfolio_analytics_lab_icon.png');}catch(err){}
  let bodyParts=[];
  for(let s of sections){
    if(status)status.innerHTML='Adding '+esc(s.label)+'...';
    bodyParts.push(await sectionReportHtml(s,chartWidth,tableLimit));
  }
  let html='<!doctype html><html><head><meta charset="utf-8"><title>'+esc(title)+'</title>'+reportCss(theme)+'</head><body><main class="report"><header class="brand">'+(icon?'<img src="'+icon+'" alt="Portfolio Analytics Lab icon">':'')+'<div><h1>'+esc(title)+'</h1><p>Portfolio Analytics Lab - generated '+esc(new Date().toLocaleString())+'</p></div></header>'+bodyParts.join('')+'<p class="footer">Educational dashboard report. Not financial advice. Review source data and caveats before sharing.</p><p class="footer no-print">Tip: use your browser Print / Save as PDF to create a PDF copy.</p></main></body></html>';
  let blob=new Blob([html],{type:'text/html;charset=utf-8'}), url=URL.createObjectURL(blob), a=document.createElement('a');
  a.href=url; a.download='portfolio_analytics_lab_report_'+new Date().toISOString().slice(0,10)+'.html'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
  if(status)status.innerHTML='Report generated. Open the downloaded HTML file, or print it to PDF from your browser.';
}

window.onload=async()=>{applyUiSettings();renderDonationDetails();refreshBackupOptions();let s=await(await fetch('/status')).json();if(s.ready)await loadData();setTimeout(resizePlots,300)}
