VH Care Group

Enter the password to access this dashboard

Sales by consultant — YTD
Monthly store total
RAG targets — individual consultants
Benefit sales — running totals
Store KPI targets
RAG monthly targets

Edit each consultant's monthly Red / Amber / Green thresholds. RAG status across the dashboard updates from these values.

Add new team member
Remove team member
Consultant performance
Weekly budget vs actual
2025-26 Budget Our 5% Budget Actual Sales
Weekly budget data
Edit weekly budgets

Showing 2025/26 — credits and remakes reset for each new financial year.

Consultant remakes & credits

Monthly remake rate — % of sales
Green <1.5% Amber 1.5–3% Red ≥3%
Remakes by consultant
Error rate by consultant
Add a remake or credit
Credits & remakes log

Optometrist remakes

Remakes and non-tolerances logged against optometrists, kept separate from consultant figures above.

Optometrist log
Import till balance report

Paste the raw till report text below. It'll be parsed, totalled per staff member, and matched to names on this dashboard — review the matches, then apply them straight into the form below.

Add daily sales
Entered days
Compare financial years
Monthly sales — overlaid
Consultant totals — side by side
function toggleBudgetEdit(){ const panel = document.getElementById('budget-edit-panel'); const btn = document.getElementById('budget-edit-toggle'); const isHidden = panel.style.display === 'none'; panel.style.display = isHidden ? 'block' : 'none'; btn.textContent = isHidden ? 'Hide ▾' : 'Show ▸'; } async function saveBudgetEdits(){ const newWeekly = [], newBudget2526 = []; for(let i=0;i<52;i++){ const w = document.getElementById('bgt-w-'+i); const b = document.getElementById('bgt-b-'+i); newWeekly.push(w ? parseFloat(w.value)||0 : budgets.weekly[i]); newBudget2526.push(b ? parseFloat(b.value)||0 : (budgets.budget2526[i]||0)); } budgets.weekly = newWeekly; budgets.budget2526 = newBudget2526; document.getElementById('budget-edit-status').textContent = 'Saving…'; try{ await saveBudgets(); document.getElementById('budget-edit-status').textContent = '✓ Budgets saved'; rendered['weekly'] = false; showTab('weekly'); }catch(e){ document.getElementById('budget-edit-status').textContent = 'Save failed — try again'; } } // ── REMAKES ─────────────────────────────────────────────────────────────────── let manualRemakes = {}; let manualRemakesLoadedFor = null; let nextRemakeNum = 1; async function loadManualRemakes(){ await waitForCloudStorage(); try{ // Load remakes from year-namespaced Firebase path const records = await window.cloudStorage.readRemakes(activeYear); manualRemakes = {}; records.forEach(r=>{ manualRemakes[r.id] = r; }); const nums = records.map(r=>parseInt((r.id.match(/\d+/)||['0'])[0])||0); nextRemakeNum = nums.length ? Math.max(1, Math.max(...nums)+1) : 1; }catch(e){ manualRemakes = {}; nextRemakeNum = 1; } manualRemakesLoadedFor = activeYear; } function getCombinedRemakes(){ // All remakes come from Firebase for the active year — no seed data mixing return Object.values(manualRemakes).map(r=>({...r, role: r.role || 'Consultant'})); } async function persistManualRemakes(){ try{ const records = Object.values(manualRemakes); await window.cloudStorage.writeRemakes(activeYear, records); }catch(e){} } function resetRemakeForm(){ document.getElementById('remake-edit-id').value = ''; document.getElementById('rm-date').value = ''; document.getElementById('rm-surname').value = ''; document.getElementById('rm-role').value = 'Consultant'; document.getElementById('rm-type').value = 'Re-Make'; document.getElementById('rm-consultant').value = ''; document.getElementById('rm-value').value = ''; document.getElementById('rm-received').checked = false; document.getElementById('remake-form-title').textContent = 'Add a remake or credit'; document.getElementById('rm-cancel-btn').style.display = 'none'; document.getElementById('remake-form-status').textContent = ''; } function editRemake(id){ const all = getCombinedRemakes(); const r = all.find(x=>x.id===id); if(!r) return; document.getElementById('remake-edit-id').value = id; document.getElementById('rm-date').value = /^\d{4}-\d{2}-\d{2}$/.test(r.date) ? r.date : ''; document.getElementById('rm-surname').value = r.surname||''; document.getElementById('rm-role').value = r.role || 'Consultant'; document.getElementById('rm-type').value = r.type; document.getElementById('rm-consultant').value = r.consultant||''; document.getElementById('rm-value').value = r.value||0; document.getElementById('rm-received').checked = !!r.received; document.getElementById('remake-form-title').textContent = 'Edit entry'; document.getElementById('rm-cancel-btn').style.display = 'inline-block'; window.scrollTo({top:0,behavior:'smooth'}); } async function saveRemake(){ const editId = document.getElementById('remake-edit-id').value; const date = document.getElementById('rm-date').value; const surname = document.getElementById('rm-surname').value.trim(); const role = document.getElementById('rm-role').value; const type = document.getElementById('rm-type').value; const consultant = document.getElementById('rm-consultant').value.trim(); const value = parseFloat(document.getElementById('rm-value').value)||0; const received = document.getElementById('rm-received').checked; if(!date || !surname || !consultant){ document.getElementById('remake-form-status').textContent = 'Please fill in date, surname and consultant'; return; } const id = editId || ('r'+String(nextRemakeNum++).padStart(3,'0')); manualRemakes[id] = {id, date, surname, role, type, consultant, value, received}; document.getElementById('remake-form-status').textContent = 'Saving…'; await persistManualRemakes(); document.getElementById('remake-form-status').textContent = `Saved ${surname} (${type})`; resetRemakeForm(); renderRemakes(); } async function deleteRemake(id){ const isSeedEntry = activeYear === BASE_YEAR_LABEL && remakeData.find(r=>r.id===id); if(isSeedEntry && !manualRemakes[id]){ manualRemakes[id] = {...remakeData.find(r=>r.id===id), deleted:true}; } else { delete manualRemakes[id]; } await persistManualRemakes(); renderRemakes(); } async function toggleReceived(id){ const all = getCombinedRemakes(); const r = all.find(x=>x.id===id); if(!r) return; manualRemakes[id] = {...r, received:!r.received}; await persistManualRemakes(); renderRemakes(); } async function renderRemakes(){ if(manualRemakesLoadedFor!==activeYear) await loadManualRemakes(); document.getElementById('remakes-year-label').textContent = activeYear; const allRemakes = getCombinedRemakes().filter(r=>!r.deleted && r.role!=='Optometrist'); const remakeKpis = document.getElementById('remake-kpis'); const total = allRemakes.length; const remakes = allRemakes.filter(r=>r.type==='Re-Make').length; const nonTols = allRemakes.filter(r=>r.type==='Non-Tol').length; const totalVal = allRemakes.reduce((s,r)=>s+(r.value||0),0); const receivedVal = allRemakes.filter(r=>r.received).reduce((s,r)=>s+(r.value||0),0); const pendingVal = totalVal - receivedVal; const pctReceived = totalVal ? (receivedVal/totalVal)*100 : 0; const pctDiff = totalVal ? (pendingVal/totalVal)*100 : 0; remakeKpis.innerHTML = `
Total events
${total}
Remakes
${remakes}
Non-tolerances
${nonTols}
Total credit value
${fmtK(totalVal)}
Credit received
${pctReceived.toFixed(1)}%
${fmt(receivedVal)} of ${fmt(totalVal)}
% difference (pending)
${pctDiff.toFixed(1)}%
${fmt(pendingVal)} outstanding
`; // Consultant breakdown const byPerson = {}; allRemakes.forEach(r=>{ if(!r.consultant||r.consultant==='?') return; if(!byPerson[r.consultant]) byPerson[r.consultant]={remake:0,nonTol:0}; if(r.type==='Re-Make') byPerson[r.consultant].remake++; else if(r.type==='Non-Tol') byPerson[r.consultant].nonTol++; }); const names = Object.keys(byPerson).sort((a,b)=>(byPerson[b].remake+byPerson[b].nonTol)-(byPerson[a].remake+byPerson[a].nonTol)); const isDark = matchMedia('(prefers-color-scheme:dark)').matches; if(window.remakesChart) window.remakesChart.destroy(); // ── Monthly remake rate chart ──────────────────────────────────────────── const MONTH_ORDER_ABBR = ['Sep 25','Oct 25','Nov 25','Dec 25','Jan 26','Feb 26','Mar 26','Apr 26','May 26','Jun 26','Jul 26','Aug 26']; const MONTH_LABELS_SHORT = ['Sep','Oct','Nov','Dec','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug']; const MONTH_KEYS_CHART = ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug']; // Sum remake values per month const remakeByMonth = {}; MONTH_ORDER_ABBR.forEach(m=>{ remakeByMonth[m] = 0; }); const allRemakesForRate = [...remakeData, ...Object.values(manualRemakes||{}).filter(r=>!r.deleted)]; allRemakesForRate.forEach(r=>{ if(remakeByMonth.hasOwnProperty(r.date)) remakeByMonth[r.date] += (r.value||0); }); // Monthly store sales (from yearStoreMonthly) const yrSM = getYearStoreMonthly(); // Compute rate per month and colour per RAG threshold const greenT = storeRagTargets.remakeGreen ?? 1.5; const amberT = storeRagTargets.remakeAmber ?? 3; const rateData = MONTH_ORDER_ABBR.map((mAbbr, i)=>{ const sales = yrSM[MONTH_KEYS_CHART[i]] || 0; const remakes = remakeByMonth[mAbbr] || 0; return sales > 0 ? parseFloat((remakes/sales*100).toFixed(3)) : 0; }); const barColors = rateData.map(v=>{ if(v === 0) return 'rgba(0,0,0,0.1)'; if(v < greenT) return 'rgba(15,110,86,0.8)'; if(v < amberT) return 'rgba(217,119,6,0.8)'; return 'rgba(163,45,45,0.8)'; }); if(window.remakeRateChart) window.remakeRateChart.destroy(); window.remakeRateChart = new Chart(document.getElementById('chart-remake-rate'),{ type:'bar', data:{ labels: MONTH_LABELS_SHORT, datasets:[ {label:'Remake rate %',data:rateData,backgroundColor:barColors,borderRadius:4}, {label:`Green threshold`,data:Array(10).fill(greenT),type:'line',borderColor:'rgba(15,110,86,0.6)',borderDash:[4,4],borderWidth:1.5,pointRadius:0,fill:false}, {label:`Amber threshold`,data:Array(10).fill(amberT),type:'line',borderColor:'rgba(217,119,6,0.6)',borderDash:[4,4],borderWidth:1.5,pointRadius:0,fill:false} ] }, options:{ responsive:true,maintainAspectRatio:false, plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>ctx.dataset.label+': '+ctx.parsed.y.toFixed(2)+'%'}}}, scales:{ y:{ticks:{callback:v=>v.toFixed(1)+'%',color:isDark?'#a8a6a2':'#5a5855'},grid:{color:isDark?'#38383e':'#e2e0d8'},suggestedMin:0,suggestedMax:Math.max(amberT*1.5,Math.max(...rateData)*1.2||4)}, x:{ticks:{color:isDark?'#a8a6a2':'#5a5855'},grid:{display:false}} } } }); window.remakesChart = new Chart(document.getElementById('chart-remakes'),{ type:'bar', data:{labels:names,datasets:[ {label:'Remakes',data:names.map(n=>byPerson[n].remake),backgroundColor:'#A32D2D',borderRadius:3}, {label:'Non-tols',data:names.map(n=>byPerson[n].nonTol),backgroundColor:'#854F0B',borderRadius:3} ]}, options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{x:{stacked:true,ticks:{color:isDark?'#a8a6a2':'#5a5855'},grid:{display:false}},y:{stacked:true,ticks:{color:isDark?'#a8a6a2':'#5a5855'},grid:{color:isDark?'#38383e':'#e2e0d8'}}}} }); // Error rates — always computed from real remake/non-tolerance records for // the active year (counts per consultant). No synthetic percentage data. const errorRatesEl = document.getElementById('error-rates'); { const maxCount = Math.max(1, ...names.map(n=>byPerson[n].remake+byPerson[n].nonTol)); let rHtml = ''; names.forEach(n=>{ const count = byPerson[n].remake+byPerson[n].nonTol; const barW = (count/maxCount)*100; const col = count>5?'var(--red)':count>2?'var(--amber)':'var(--teal)'; rHtml += `
${n}${count}
`; }); errorRatesEl.innerHTML = rHtml || '

No entries yet for this year.

'; } renderRemakeTable(); renderOptRemakes(); } function renderRemakeTable(){ const filter = document.getElementById('remake-filter').value; const statusFilter = document.getElementById('remake-received-filter').value; let rows = getCombinedRemakes().filter(r=>!r.deleted && r.role!=='Optometrist'); if(filter!=='all') rows = rows.filter(r=>r.type===filter); if(statusFilter==='received') rows = rows.filter(r=>r.received); if(statusFilter==='pending') rows = rows.filter(r=>!r.received); let html = 'DateSurnameTypeConsultantValueReceivedActions'; rows.forEach(r=>{ const cls = r.type==='Non-Tol'?'remake-type-non':r.type==='Re-Make'?'remake-type-re':'remake-type-cancel'; html += `${r.date}${r.surname}${r.type}${r.consultant}${r.value?fmt(r.value):'—'}`; }); html += ''; document.getElementById('remake-table').innerHTML = html; } // ── OPTOMETRIST REMAKES (separate section, same year-scoped storage) ─────────── function renderOptRemakes(){ const allOpt = getCombinedRemakes().filter(r=>!r.deleted && r.role==='Optometrist'); const total = allOpt.length; const remakes = allOpt.filter(r=>r.type==='Re-Make').length; const nonTols = allOpt.filter(r=>r.type==='Non-Tol').length; const totalVal = allOpt.reduce((s,r)=>s+(r.value||0),0); const receivedVal = allOpt.filter(r=>r.received).reduce((s,r)=>s+(r.value||0),0); const pendingVal = totalVal - receivedVal; const pctReceived = totalVal ? (receivedVal/totalVal)*100 : 0; document.getElementById('opt-remake-kpis').innerHTML = `
Total events
${total}
Remakes
${remakes}
Non-tolerances
${nonTols}
Total credit value
${fmtK(totalVal)}
Credit received
${pctReceived.toFixed(1)}%
${fmt(receivedVal)} of ${fmt(totalVal)}
Outstanding
${fmt(pendingVal)}
`; renderOptRemakeTable(); } function renderOptRemakeTable(){ const filter = document.getElementById('opt-remake-filter').value; const statusFilter = document.getElementById('opt-remake-received-filter').value; let rows = getCombinedRemakes().filter(r=>!r.deleted && r.role==='Optometrist'); if(filter!=='all') rows = rows.filter(r=>r.type===filter); if(statusFilter==='received') rows = rows.filter(r=>r.received); if(statusFilter==='pending') rows = rows.filter(r=>!r.received); let html = 'DateSurnameTypeOptometristValueReceivedActions'; if(rows.length===0){ html += 'No optometrist remakes logged for this year yet — add one above and set Role to "Optometrist".'; } rows.forEach(r=>{ const cls = r.type==='Non-Tol'?'remake-type-non':r.type==='Re-Make'?'remake-type-re':'remake-type-cancel'; html += `${r.date}${r.surname}${r.type}${r.consultant}${r.value?fmt(r.value):'—'}`; }); html += ''; document.getElementById('opt-remake-table').innerHTML = html; } // ── DATA ENTRY ──────────────────────────────────────────────────────────────── // ── FIREBASE DATA LAYER ────────────────────────────────────────────────── // All data lives in Firebase. On load: // 1. Read consultants-meta → populates consultants[] and storeMonthly // 2. Read all daily-YYYY-MM-DD documents → populates allDailyRecords[] // On save: write the individual daily document directly to Firebase. let allDailyRecords = []; let dailyDataLoaded = false; let dailyDataLoadedForYear = null; async function loadAllData(){ // Reload if year has changed since last load if(dailyDataLoaded && dailyDataLoadedForYear === activeYear) return; dailyDataLoaded = false; await waitForCloudStorage(); try{ // For built-in years (base year), load from Firebase // For prior year (2024/25), use static data // For Firebase-stored additional years, load from their year-namespaced path const isFirebaseYear = activeYear !== PRIOR_YEAR_LABEL; if(isFirebaseYear){ // Load consultant metadata for this year const meta = await window.cloudStorage.readConsultantsMeta(activeYear); if(meta && meta.consultants){ const flatConsultants = meta.consultants.map(c => { const flat = { name: c.name, targets: c.targets || {red:null,amber:null,green:null}, rag: null }; const monthly = c.monthly || {}; ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug'].forEach(m => { flat[m] = monthly[m] || 0; }); return flat; }); if(activeYear === BASE_YEAR_LABEL){ // Base year: update global consultants array consultants = flatConsultants; const months = ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug']; storeMonthly = {}; months.forEach(m => { storeMonthly[m] = consultants.reduce((s,c) => s + (c[m]||0), 0); }); } else { // Additional year: store in yearRegistry for getYearConsultants() to use const months = ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug']; const sm = {}; months.forEach(m => { sm[m] = flatConsultants.reduce((s,c)=>s+(c[m]||0),0); }); yearRegistry[activeYear] = { ...yearRegistry[activeYear], consultants: flatConsultants, storeMonthly: sm }; } } // Load daily records for this year const {start, end} = yearDateRange(activeYear); allDailyRecords = await window.cloudStorage.readAllDailyRecords(start, end, activeYear); } else { // Prior year — no daily records to load (uses static consultant data) allDailyRecords = []; } dailyDataLoaded = true; dailyDataLoadedForYear = activeYear; }catch(e){ console.error('loadAllData failed:', e); const statusEl = document.getElementById('cloud-status'); if(statusEl) statusEl.textContent = '⚠ Failed to load data — check console'; dailyDataLoaded = true; } } // savedEntries holds any NEW manual entries added this session // (not yet reflected in allDailyRecords until page reload) let savedEntries = {}; async function loadSavedEntries(){ await loadAllData(); } function getCombinedRawData(){ // All historical data comes from Firebase (allDailyRecords). // Any new entries added this session overlay on top. const byDate = {}; allDailyRecords.forEach(r=>{ byDate[r.date] = r; }); Object.values(savedEntries).forEach(r=>{ byDate[r.date] = r; }); return Object.values(byDate).sort((a,b)=>a.date.localeCompare(b.date)); } // ── LIVE MONTHLY RECOMPUTATION ────────────────────────────────────────────── // Dashboard, Team, and RAG all read per-consultant and store-wide monthly totals. // Those totals must reflect whatever's been entered via Data Entry (manually typed // or pasted from a till report), not just the static spreadsheet-import snapshot. // This recomputes both from the combined raw daily data — seed history plus any // manually saved days — every time it's called. It's synchronous and cheap (a single // pass over the raw rows), so it's safe to call on every render rather than caching. const MONTH_KEYS_ORDER = ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug']; function computeLiveConsultants(baseConsultants){ const combinedRaw = getCombinedRawData(); // Only names that actually appear as a field in at least one raw daily row get // recomputed. Some historical consultants (e.g. people who left before daily // tracking began) only ever had monthly totals captured directly — for those, // the original static figures are the only record that exists and must be kept. const namesWithRawTracking = new Set(); combinedRaw.forEach(row=>{ Object.keys(row).forEach(field=>{ if(field==='date' || field==='total') return; namesWithRawTracking.add(field); }); }); const liveByName = {}; baseConsultants.forEach(c=>{ if(namesWithRawTracking.has(c.name)){ const blankMonths = {}; ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug'].forEach(m=>blankMonths[m]=0); liveByName[c.name] = {...c, ...blankMonths}; } else { liveByName[c.name] = {...c}; // preserve static historical figures untouched } }); combinedRaw.forEach(row=>{ const mIdx = monthIndexFromDateKey(row.date); if(mIdx===null) return; const monthKey = MONTH_KEYS_ORDER[mIdx]; Object.keys(row).forEach(field=>{ if(field==='date' || field==='total') return; if(!liveByName[field]) return; // skip non-consultant fields (Benefit, Balance Payment, Locum if untracked) liveByName[field][monthKey] += (row[field]||0); }); }); return baseConsultants.map(c=>liveByName[c.name]); } function computeLiveStoreMonthly(){ // Store total includes all columns: consultants, Locum, Balance Payment // and Benefit sales — matching the spreadsheet methodology. const exclude = []; const liveConsultants = computeLiveConsultants(consultants); const monthly = {}; MONTH_KEYS_ORDER.forEach(m=>monthly[m]=0); liveConsultants.forEach(c=>{ if(exclude.includes(c.name)) return; MONTH_KEYS_ORDER.forEach(m=>{ monthly[m] += (c[m]||0); }); }); return monthly; } // ── TILL REPORT IMPORT ────────────────────────────────────────────────────── const TILL_TIMESTAMP_RE = /^\d{2}\/\d{2}\/\d{4}/; const TILL_TYPE_KEYWORDS = new Set(['Shift Open','Sale','Return','Shift Close','Refund','Void']); // Matches amounts with OR without thousands separators (e.g. "150.00" and "1,250.00"), // since till reports format four-figure-plus sales with a comma. const TILL_MONEY_RE = /^-?\d{1,3}(,\d{3})*\.\d{2}$|^-?\d+\.\d{2}$/; const TILL_NICKNAMES = {melissa:'mel', katherine:'kate', robert:'rob', elizabeth:'liz', william:'will'}; function parseTillMoney(str){ return parseFloat(str.replace(/,/g, '')); } let lastTillResults = null; // [{tillName, matchedStaff, total, rowCount}] function parseTillReportText(text){ const lines = text.split('\n').map(l=>l.trim()).filter(l=>l.length>0); const records = []; let i = 0; const n = lines.length; while(i < n){ if(!TILL_TIMESTAMP_RE.test(lines[i])){ i++; continue; } i++; // consume timestamp while(i < n && !TILL_TYPE_KEYWORDS.has(lines[i])) i++; // skip optional txn id if(i >= n) break; const txnType = lines[i]; i++; if(i >= n) break; const staff = lines[i]; i++; let customer = null; if(i < n && !TILL_MONEY_RE.test(lines[i])){ customer = lines[i]; i++; } let gross = null, net = null; if(i < n && TILL_MONEY_RE.test(lines[i])){ gross = parseTillMoney(lines[i]); i++; } if(i < n && TILL_MONEY_RE.test(lines[i])){ net = parseTillMoney(lines[i]); i++; } records.push({type:txnType, staff, customer, gross, net}); } return records; } function firstNameOf(fullName){ return (fullName||'').trim().split(/\s+/)[0] || ''; } function matchTillNameToStaff(tillName){ const first = firstNameOf(tillName).toLowerCase(); if(!first) return null; const exact = staffList.find(s=>s.toLowerCase()===first); if(exact) return exact; if(TILL_NICKNAMES[first]){ const byNick = staffList.find(s=>s.toLowerCase()===TILL_NICKNAMES[first]); if(byNick) return byNick; } // reverse nickname check: staff list might hold the short form, till has the long form for(const s of staffList){ if(TILL_NICKNAMES[s.toLowerCase()]===first) return s; } return null; } function parseTillReport(){ const text = document.getElementById('till-paste').value; const statusEl = document.getElementById('till-parse-status'); if(!text.trim()){ statusEl.textContent = 'Paste a till report first'; return; } const records = parseTillReportText(text); const saleRecords = records.filter(r=>r.type!=='Shift Open' && r.type!=='Shift Close'); if(saleRecords.length===0){ statusEl.textContent = 'No transaction rows found — check the pasted text'; document.getElementById('till-results-wrap').style.display = 'none'; return; } const totals = {}; const counts = {}; saleRecords.forEach(r=>{ if(r.net===null) return; totals[r.staff] = (totals[r.staff]||0) + r.net; counts[r.staff] = (counts[r.staff]||0) + 1; }); const tillNames = Object.keys(totals).sort(); lastTillResults = tillNames.map(tillName=>({ tillName, matchedStaff: matchTillNameToStaff(tillName), total: Math.round(totals[tillName]*100)/100, rowCount: counts[tillName] })); renderTillResults(); const matchedCount = lastTillResults.filter(r=>r.matchedStaff).length; statusEl.textContent = `Parsed ${saleRecords.length} transactions across ${tillNames.length} staff — ${matchedCount} matched automatically`; } function renderTillResults(){ const wrap = document.getElementById('till-results-wrap'); if(!lastTillResults || lastTillResults.length===0){ wrap.style.display='none'; return; } wrap.style.display = 'block'; let html = 'Till nameTransactionsNet totalMatched to'; lastTillResults.forEach((r,idx)=>{ const options = staffList.map(s=>``).join(''); const rowStyle = r.matchedStaff ? '' : 'style="background:var(--amber-light)"'; html += `${r.tillName}${r.rowCount}${fmt(r.total)} `; }); html += ''; document.getElementById('till-results-table').innerHTML = html; } function updateTillMatch(idx, staffName){ lastTillResults[idx].matchedStaff = staffName || null; } function resetTillReport(){ document.getElementById('till-paste').value = ''; lastTillResults = null; document.getElementById('till-results-wrap').style.display = 'none'; document.getElementById('till-results-table').innerHTML = ''; document.getElementById('till-parse-status').textContent = ''; document.getElementById('till-apply-status').textContent = ''; } function applyTillResults(){ if(!lastTillResults){ return; } const date = document.getElementById('entry-date').value; const applyStatus = document.getElementById('till-apply-status'); if(!date){ applyStatus.textContent = 'Choose a date in the form below first'; return; } // Build a fresh form for this date if one isn't already showing, then merge in matched totals. const existing = getCombinedRawData().find(r=>r.date===date); buildEntryForm(existing||null); let appliedCount = 0; const skipped = []; lastTillResults.forEach(r=>{ if(!r.matchedStaff){ skipped.push(r.tillName); return; } const el = document.getElementById('entry-'+r.matchedStaff.replace(/\s+/g,'_')); if(el){ el.value = r.total; appliedCount++; } }); updateEntryTotal(); applyStatus.textContent = skipped.length ? `Applied ${appliedCount} — skipped (no match): ${skipped.join(', ')}` : `Applied ${appliedCount} staff totals to ${date}. Review below, then click "Save day".`; } function buildEntryForm(prefill){ const grid = document.getElementById('entry-form'); let html = ''; staffList.forEach(name=>{ const val = prefill && prefill[name]!==undefined ? prefill[name] : ''; html += `
`; }); html += `
Daily total£0.00
`; grid.innerHTML = html; updateEntryTotal(); } function updateEntryTotal(){ let total = 0; staffList.forEach(name=>{ const el = document.getElementById('entry-'+name.replace(/\s+/g,'_')); if(el && el.value) total += parseFloat(el.value)||0; }); document.getElementById('entry-total-val').textContent = fmt(total); } function loadEntryForDate(){ const date = document.getElementById('entry-date').value; if(!date){ buildEntryForm(null); return; } const combined = getCombinedRawData(); const existing = combined.find(r=>r.date===date); buildEntryForm(existing||null); document.getElementById('entry-status').textContent = existing ? 'Loaded existing entry for editing' : 'No existing entry — enter new figures'; } function resetEntryForm(){ document.getElementById('entry-date').value = ''; buildEntryForm(null); document.getElementById('entry-status').textContent = ''; } async function saveEntry(){ const date = document.getElementById('entry-date').value; if(!date){ document.getElementById('entry-status').textContent = 'Please choose a date first'; return; } const entry = {date}; let total = 0; staffList.forEach(name=>{ const el = document.getElementById('entry-'+name.replace(/\s+/g,'_')); const v = el && el.value ? parseFloat(el.value) : 0; entry[name] = v; total += v; }); entry.total = Math.round(total*100)/100; savedEntries[date] = entry; // Update allDailyRecords immediately so renders reflect the change without reload const existingIdx = allDailyRecords.findIndex(r=>r.date===date); if(existingIdx>=0) allDailyRecords[existingIdx] = entry; else { allDailyRecords.push(entry); allDailyRecords.sort((a,b)=>a.date.localeCompare(b.date)); } document.getElementById('entry-status').textContent = 'Saving…'; try{ const ok = await window.cloudStorage.saveDailyRecord(date, entry, activeYear); document.getElementById('entry-status').textContent = ok ? `Saved ${date} — total ${fmt(entry.total)}` : 'Save failed — try again'; }catch(e){ document.getElementById('entry-status').textContent = 'Save failed — try again'; } rendered['weekly'] = false; rendered['dashboard'] = false; rendered['team'] = false; renderEntryLog(); } async function deleteEntry(date){ delete savedEntries[date]; allDailyRecords = allDailyRecords.filter(r=>r.date!==date); try{ await window.cloudStorage.deleteDailyRecord(date, activeYear); }catch(e){} rendered['weekly'] = false; rendered['dashboard'] = false; rendered['team'] = false; renderEntryLog(); } function editEntry(date){ document.getElementById('entry-date').value = date; loadEntryForDate(); window.scrollTo({top:0,behavior:'smooth'}); } function renderEntryLog(){ const filter = document.getElementById('entry-log-filter').value; const combined = getCombinedRawData().slice().reverse(); let rows = combined; if(filter!=='all'){ const n = parseInt(filter); rows = combined.slice(0, n); } let html = 'DateTotalSourceActions'; rows.forEach(r=>{ const isManual = !!savedEntries[r.date]; html += `${r.date}${fmt(r.total)}${isManual?'Manual':'Imported'}${isManual?``:''}`; }); html += ''; document.getElementById('entry-log-table').innerHTML = html; } async function renderEntry(){ await loadSavedEntries(); const today = toLocalDateKey(new Date()); document.getElementById('entry-date').value = today; buildEntryForm(null); renderEntryLog(); } // ── FINANCIAL YEARS ────────────────────────────────────────────────────────── // The baked-in data (consultants, storeMonthly, seedRawData, remakeData, budgets) // always represents the FIRST year on record. Its label lives here: const BASE_YEAR_LABEL = '2025/26'; // A second built-in year, sourced from the original workbook's "Dashboard LY" sheet — // real prior-year figures, not generated test data. const PRIOR_YEAR_LABEL = '2024/25'; const BUILT_IN_YEARS = [PRIOR_YEAR_LABEL, BASE_YEAR_LABEL]; // Derives the Sep→Jun date range for any financial year label. // e.g. '2025/26' → start:'2025-09-01' end:'2026-06-30' // '2026/27' → start:'2026-09-01' end:'2027-06-30' function yearDateRange(label){ const startYear = parseInt((label || BASE_YEAR_LABEL).split('/')[0]); return { start: `${startYear}-09-01`, end: `${startYear+1}-08-31`, weekStartDate: new Date(`${startYear}-09-01T00:00:00`), week1End: new Date(`${startYear}-09-06T00:00:00`) }; } // Registry of all years: { "2025/26": {isBase:true}, "2026/27": {consultants:[...], ...}, ... } let yearRegistry = { [PRIOR_YEAR_LABEL]: { isBuiltIn: true }, [BASE_YEAR_LABEL]: { isBase: true } }; let activeYear = BASE_YEAR_LABEL; function monthKeysForYear(){ return ['sep','oct','nov','dec','jan','feb','mar','apr','may','jun','jul','aug']; } async function loadYearRegistry(){ await waitForCloudStorage(); try{ // Load list of years from Firebase year-index const years = await window.cloudStorage.listYears(); // Build registry — always include built-in years yearRegistry = { [PRIOR_YEAR_LABEL]: { isBuiltIn: true }, [BASE_YEAR_LABEL]: { isBase: true } }; // Add any additional years from Firebase years.forEach(y=>{ if(!yearRegistry[y]) yearRegistry[y] = { isFirebase: true }; }); // Also check legacy registry for backwards compat const result = await window.cloudStorage.get('year-registry'); if(result && result.value){ const stored = JSON.parse(result.value); Object.keys(stored).forEach(y=>{ if(!yearRegistry[y]) yearRegistry[y] = stored[y]; }); } const activeResult = await window.cloudStorage.get('active-year'); if(activeResult && activeResult.value){ const storedActive = JSON.parse(activeResult.value); if(yearRegistry[storedActive]) activeYear = storedActive; } }catch(e){ /* fall back to built-in years only */ } } async function persistYearRegistry(){ const toStore = {...yearRegistry}; BUILT_IN_YEARS.forEach(y=>delete toStore[y]); // built-in years are never stored, they're baked into the file try{ await window.cloudStorage.set('year-registry', JSON.stringify(toStore)); }catch(e){} } async function persistActiveYear(){ try{ await window.cloudStorage.set('active-year', JSON.stringify(activeYear)); }catch(e){} } /** Returns the consultants array for whichever year is requested (active year by default). */ function getYearConsultants(yearLabel){ yearLabel = yearLabel || activeYear; if(yearLabel === BASE_YEAR_LABEL) return computeLiveConsultants(consultants); if(yearLabel === PRIOR_YEAR_LABEL) return consultants202425; // For Firebase-stored years, data is loaded into yearRegistry on demand if(yearRegistry[yearLabel] && yearRegistry[yearLabel].consultants){ return yearRegistry[yearLabel].consultants; } return computeLiveConsultants(consultants); } function getYearStoreMonthly(yearLabel){ yearLabel = yearLabel || activeYear; if(yearLabel === BASE_YEAR_LABEL) return computeLiveStoreMonthly(); if(yearLabel === PRIOR_YEAR_LABEL) return storeMonthly202425; if(yearRegistry[yearLabel] && yearRegistry[yearLabel].storeMonthly){ return yearRegistry[yearLabel].storeMonthly; } return {sep:0,oct:0,nov:0,dec:0,jan:0,feb:0,mar:0,apr:0,may:0,jun:0}; } function listAvailableYears(){ return Object.keys(yearRegistry); } function buildYearNavSelector(){ const years = listAvailableYears(); if(years.length <= 1) return ''; // don't show a selector until a second year exists let html = ''; return html; } async function switchActiveYear(yearLabel){ if(!yearRegistry[yearLabel]) return; activeYear = yearLabel; await persistActiveYear(); // Force every tab to re-render against the newly active year Object.keys(rendered).forEach(k=>rendered[k]=false); refreshNavYearSelector(); showTab(currentTab()); } function currentTab(){ const activePage = document.querySelector('.page.active'); return activePage ? activePage.id.replace('page-','') : 'dashboard'; } function refreshNavYearSelector(){ const el = document.getElementById('year-nav-slot'); if(el) el.innerHTML = buildYearNavSelector(); } // ── NEW FINANCIAL YEAR WIZARD ──────────────────────────────────────────────── function openNewYearWizard(){ document.getElementById('nfy-modal').style.display = 'flex'; buildNfyConsultantList(); const years = listAvailableYears(); const lastLabel = years[years.length-1]; const suggestion = suggestNextYearLabel(lastLabel); document.getElementById('nfy-year-label').value = suggestion; } function closeNewYearWizard(){ document.getElementById('nfy-modal').style.display = 'none'; } function suggestNextYearLabel(label){ const m = label.match(/^(\d{4})\/(\d{2})$/); if(!m) return ''; const startYear = parseInt(m[1]) + 1; const endYY = String(startYear+1).slice(-2); return `${startYear}/${endYY}`; } function buildNfyConsultantList(){ const baseNames = getYearConsultants(activeYear).map(c=>c.name); const container = document.getElementById('nfy-consultant-list'); let html = ''; baseNames.forEach(name=>{ html += `
${name}
`; }); container.innerHTML = html; } function nfyAddConsultant(){ const input = document.getElementById('nfy-new-name'); const name = input.value.trim(); if(!name) return; const container = document.getElementById('nfy-consultant-list'); const row = document.createElement('div'); row.className = 'nfy-row'; row.dataset.name = name; row.innerHTML = `${name} (new)`; container.appendChild(row); input.value = ''; } async function confirmNewYear(){ const label = document.getElementById('nfy-year-label').value.trim(); if(!label){ alert('Please enter a year label, e.g. 2026/27'); return; } if(yearRegistry[label]){ alert('That year already exists.'); return; } const rows = document.querySelectorAll('#nfy-consultant-list .nfy-row'); const carriedNames = []; rows.forEach(row=>{ const checked = row.querySelector('input[type=checkbox]').checked; if(checked) carriedNames.push(row.dataset.name); }); document.getElementById('nfy-status').textContent = 'Creating year in Firebase…'; // Create the year in Firebase — writes consultants-meta and empty remakes const ok = await window.cloudStorage.createYear(label, carriedNames); if(!ok){ document.getElementById('nfy-status').textContent = '❌ Failed to create year in Firebase'; return; } // Add to local registry yearRegistry[label] = { isFirebase: true }; document.getElementById('nfy-status').textContent = 'Done'; activeYear = label; await persistActiveYear(); dailyDataLoaded = false; // force reload for new year dailyDataLoadedForYear = null; closeNewYearWizard(); Object.keys(rendered).forEach(k=>rendered[k]=false); refreshNavYearSelector(); showTab('dashboard'); } async function switchActiveYear(yearLabel){ if(!yearRegistry[yearLabel]) return; activeYear = yearLabel; await persistActiveYear(); // Force data reload for the new year dailyDataLoaded = false; dailyDataLoadedForYear = null; targetOverridesLoadedFor = null; budgetsLoaded = false; manualRemakesLoadedFor = null; storeRagLoaded = false; // Force every tab to re-render against the newly active year Object.keys(rendered).forEach(k=>rendered[k]=false); refreshNavYearSelector(); showTab(currentTab()); } // ── YEAR COMPARISON TAB ─────────────────────────────────────────────────────── async function renderComparison(){ await loadSavedEntries(); if(!window.yearRegistryLoaded) await loadYearRegistry(); window.yearRegistryLoaded = true; const years = listAvailableYears(); const selA = document.getElementById('cmp-year-a'); const selB = document.getElementById('cmp-year-b'); if(selA.options.length===0){ years.forEach(y=>{ selA.add(new Option(y,y)); selB.add(new Option(y,y)); }); selA.value = years[years.length>1?years.length-2:0]; selB.value = years[years.length-1]; } const yearA = selA.value; const yearB = selB.value; const consultantsA = getYearConsultants(yearA); const consultantsB = getYearConsultants(yearB); const monthlyA = getYearStoreMonthly(yearA); const monthlyB = getYearStoreMonthly(yearB); const ytdA = consultantsA.reduce((s,c)=>s+ytd(c),0); const ytdB = consultantsB.reduce((s,c)=>s+ytd(c),0); const diff = ytdB - ytdA; const pctDiff = ytdA ? (diff/ytdA)*100 : 0; document.getElementById('cmp-kpis').innerHTML = `
${yearA} total
${fmtK(ytdA)}
${yearB} total
${fmtK(ytdB)}
Difference
${diff>=0?'+':''}${fmtK(diff)}
% change
${pctDiff>=0?'+':''}${pctDiff.toFixed(1)}%
`; const months = ['Sep','Oct','Nov','Dec','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug']; const monthKeys = monthKeysForYear(); const valsA = monthKeys.map(m=>monthlyA[m]||0); const valsB = monthKeys.map(m=>monthlyB[m]||0); const isDark = matchMedia('(prefers-color-scheme:dark)').matches; if(window.cmpChart) window.cmpChart.destroy(); window.cmpChart = new Chart(document.getElementById('chart-comparison'),{ type:'line', data:{labels:months,datasets:[ {label:yearA,data:valsA,borderColor:'#AFA9EC',backgroundColor:'rgba(175,169,236,0.08)',fill:false,tension:0.3,pointRadius:3,borderDash:[5,3]}, {label:yearB,data:valsB,borderColor:'#534AB7',backgroundColor:'rgba(83,74,183,0.08)',fill:false,tension:0.3,pointRadius:3} ]}, options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:true,position:'top',labels:{color:isDark?'#a8a6a2':'#5a5855'}}},scales:{y:{ticks:{callback:v=>'£'+(v/1000).toFixed(0)+'k',color:isDark?'#a8a6a2':'#5a5855'},grid:{color:isDark?'#38383e':'#e2e0d8'}},x:{ticks:{color:isDark?'#a8a6a2':'#5a5855'},grid:{display:false}}}} }); // Side-by-side consultant table const allNames = Array.from(new Set([...consultantsA.map(c=>c.name), ...consultantsB.map(c=>c.name)])); let html = `Consultant${yearA}${yearB}Difference% change`; allNames.forEach(name=>{ const cA = consultantsA.find(c=>c.name===name); const cB = consultantsB.find(c=>c.name===name); const totalA = cA ? ytd(cA) : 0; const totalB = cB ? ytd(cB) : 0; if(totalA<0.01 && totalB<0.01) return; const d = totalB - totalA; const pct = totalA ? (d/totalA)*100 : (totalB>0?100:0); html += `${name}${fmt(totalA)}${fmt(totalB)}${d>=0?'+':''}${fmt(d)}${pct>=0?'+':''}${pct.toFixed(1)}%`; }); html += ''; document.getElementById('cmp-table').innerHTML = html; } // ── TAB ROUTING ─────────────────────────────────────────────────────────────── const rendered = {}; async function showTab(id){ document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); document.getElementById('page-'+id).classList.add('active'); document.querySelectorAll('.tab-btn').forEach(b=>{if(b.textContent.toLowerCase().includes(id.substring(0,4))) b.classList.add('active');}); if(!rendered[id]){ rendered[id]=true; if(id==='dashboard') await renderDashboard(); if(id==='team') await renderTeam(); if(id==='weekly') await renderWeekly(); if(id==='remakes') await renderRemakes(); if(id==='entry') await renderEntry(); if(id==='comparison') await renderComparison(); } } // Fix tab button matching document.querySelectorAll('.tab-btn').forEach(b=>{ b.addEventListener('click',function(){ document.querySelectorAll('.tab-btn').forEach(x=>x.classList.remove('active')); this.classList.add('active'); }); }); // ── STORE-LEVEL KPI RAG TARGETS ─────────────────────────────────────────────── let storeRagTargets = {red: null, amber: null, green: null, remakeGreen: 1.5, remakeAmber: 3, remakeRed: 3}; let storeRagLoaded = false; async function loadStoreRagTargets(){ if(storeRagLoaded) return; await waitForCloudStorage(); try{ const result = await window.cloudStorage.get('store-rag-targets'); if(result && result.value) storeRagTargets = JSON.parse(result.value); storeRagLoaded = true; }catch(e){ storeRagLoaded = true; } // Populate inputs if panel is visible populateStoreRagInputs(); } function populateStoreRagInputs(){ const r = document.getElementById('store-rag-red'); const a = document.getElementById('store-rag-amber'); const g = document.getElementById('store-rag-green'); if(r && storeRagTargets.red != null) r.value = storeRagTargets.red; if(a && storeRagTargets.amber != null) a.value = storeRagTargets.amber; if(g && storeRagTargets.green != null) g.value = storeRagTargets.green; const rg = document.getElementById('remake-rag-green'); const ra = document.getElementById('remake-rag-amber'); const rr = document.getElementById('remake-rag-red'); if(rg) rg.value = storeRagTargets.remakeGreen ?? 1.5; if(ra) ra.value = storeRagTargets.remakeAmber ?? 3; if(rr) rr.value = storeRagTargets.remakeRed ?? 3; } function toggleStoreRagEdit(){ const panel = document.getElementById('store-rag-panel'); const btn = document.getElementById('store-rag-toggle'); const isHidden = panel.style.display === 'none'; panel.style.display = isHidden ? 'block' : 'none'; btn.textContent = isHidden ? 'Close ▾' : 'Edit ▸'; if(isHidden) populateStoreRagInputs(); } async function saveStoreRagTargets(){ const red = parseFloat(document.getElementById('store-rag-red').value) || null; const amber = parseFloat(document.getElementById('store-rag-amber').value) || null; const green = parseFloat(document.getElementById('store-rag-green').value) || null; const remakeGreen = parseFloat(document.getElementById('remake-rag-green').value) ?? 1.5; const remakeAmber = parseFloat(document.getElementById('remake-rag-amber').value) ?? 3; const remakeRed = parseFloat(document.getElementById('remake-rag-red').value) ?? 3; storeRagTargets = {red, amber, green, remakeGreen, remakeAmber, remakeRed}; document.getElementById('store-rag-status').textContent = 'Saving…'; try{ await window.cloudStorage.set('store-rag-targets', JSON.stringify(storeRagTargets)); document.getElementById('store-rag-status').textContent = '✓ Saved'; rendered['dashboard'] = false; showTab('dashboard'); }catch(e){ document.getElementById('store-rag-status').textContent = 'Save failed'; } } function computeStoreRag(value, months=1){ const t = storeRagTargets; if(!t || t.red == null) return null; const red = t.red * months; const amber = t.amber * months; const green = t.green * months; if(green && value >= green) return 'Green'; if(amber && value >= amber) return 'Amber'; return 'Red'; } function computeRemakeRag(pct){ // pct is a decimal e.g. 0.0135 = 1.35% const pctVal = pct * 100; const greenThreshold = storeRagTargets.remakeGreen ?? 1.5; const amberThreshold = storeRagTargets.remakeAmber ?? 3; if(pctVal < greenThreshold) return 'Green'; if(pctVal < amberThreshold) return 'Amber'; return 'Red'; } function storeRagStyle(rag){ if(rag === 'Green') return 'border-left:3px solid var(--teal);'; if(rag === 'Amber') return 'border-left:3px solid #d97706;'; if(rag === 'Red') return 'border-left:3px solid var(--red,#A32D2D);'; return ''; } function storeRagValueStyle(rag){ if(rag === 'Green') return 'color:var(--teal)'; if(rag === 'Amber') return 'color:#d97706'; if(rag === 'Red') return 'color:var(--red,#A32D2D)'; return ''; } // ── MONTH / QUARTER SELECTOR ───────────────────────────────────────────────── const DASH_QMAP = { sep:{q:'Q1',months:['sep','oct','nov'],label:'Q1 (Sep–Nov)'}, oct:{q:'Q1',months:['sep','oct','nov'],label:'Q1 (Sep–Nov)'}, nov:{q:'Q1',months:['sep','oct','nov'],label:'Q1 (Sep–Nov)'}, dec:{q:'Q2',months:['dec','jan','feb'],label:'Q2 (Dec–Feb)'}, jan:{q:'Q2',months:['dec','jan','feb'],label:'Q2 (Dec–Feb)'}, feb:{q:'Q2',months:['dec','jan','feb'],label:'Q2 (Dec–Feb)'}, mar:{q:'Q3',months:['mar','apr','may'],label:'Q3 (Mar–May)'}, apr:{q:'Q3',months:['mar','apr','may'],label:'Q3 (Mar–May)'}, may:{q:'Q3',months:['mar','apr','may'],label:'Q3 (Mar–May)'}, jun:{q:'Q4',months:['jun','jul','aug'],label:'Q4 (Jun–Aug)'}, jul:{q:'Q4',months:['jun','jul','aug'],label:'Q4 (Jun–Aug)'}, aug:{q:'Q4',months:['jun','jul','aug'],label:'Q4 (Jun–Aug)'}, }; const MONTH_FULL_LABELS = {sep:'September',oct:'October',nov:'November',dec:'December', jan:'January',feb:'February',mar:'March',apr:'April',may:'May',jun:'June',jul:'July',aug:'August'}; function updateMonthQuarterKpis(monthKey){ const yrSM = getYearStoreMonthly ? getYearStoreMonthly() : storeMonthly; const qInfo = DASH_QMAP[monthKey] || DASH_QMAP.aug; const monthVal = yrSM[monthKey] || 0; const qVal = qInfo.months.reduce((s,m)=>s+(yrSM[m]||0), 0); const monthRag = computeStoreRag(monthVal, 1); const qRag = computeStoreRag(qVal, 3); const isCurrentMonth = monthKey === getCurrentMonthKey(); const pad = 'padding:16px 20px;height:100%;box-sizing:border-box;'; const monthCard = document.getElementById('kpi-month-card'); const qCard = document.getElementById('kpi-quarter-card'); const hint = document.getElementById('dash-month-hint'); if(monthCard) monthCard.innerHTML = `
${MONTH_FULL_LABELS[monthKey]}${isCurrentMonth?' ·current':''}
${fmtK(monthVal)}
Monthly sales
`; if(qCard) qCard.innerHTML = `
${qInfo.q} total
${fmtK(qVal)}
${qInfo.label}
`; if(hint) hint.textContent = isCurrentMonth ? '' : `Viewing ${MONTH_FULL_LABELS[monthKey]} — not the live month`; } (async function initialRender(){ await renderDashboard(); rendered['dashboard'] = true; })(); // Load year registry in the background; once ready, refresh the year selector // and active-year label without disrupting whatever the person is already viewing. (async function initYears(){ await loadYearRegistry(); window.yearRegistryLoaded = true; document.getElementById('active-year-label').textContent = activeYear; refreshNavYearSelector(); // If a non-base year is active on load, re-render the current tab against it. if(activeYear !== BASE_YEAR_LABEL){ Object.keys(rendered).forEach(k=>rendered[k]=false); showTab(currentTab()); } })();