({id:'demo-diary-'+i,user_id:_DEMO_UID,date:d,location:locs[i%locs.length],opening:i===0?{}:full(4),closing:i===0?{}:full(4),issues:i===2?'Fridge 1 slightly warm at open (9°C) — adjusted thermostat, back to 4°C within 20 min.':'',created_at:iso(d,8,15)}));
const temps=[];
D.forEach((d,di)=>{
if(di===0) return; // no temps logged today yet
const appl=[{unit:'Fridge 1',type:'cold',th:8,am:4,pm:5},{unit:'Freezer',type:'cold',th:-18,am:-19,pm:-18},{unit:'Hot Hold',type:'hot',th:63,am:71,pm:67}];
appl.forEach((r,ri)=>{
[{h:9,m:5,v:r.am},{h:15,m:30,v:r.pm}].forEach((slot,si)=>{
let temp=slot.v, action=null, corrective_action=null, followup_temp=null, followup_time=null, followup_pass=null;
if(di===3&&ri===2&&si===0) temp=58; // one instructive failed hot-hold reading + corrective action
const pass=r.type==='cold'?temp<=r.th:temp>=r.th;
if(!pass){ corrective_action='Adjusted equipment'; action='Turned hot-hold up and reheated food to 75°C.'; followup_temp=71; followup_time=p2(slot.h)+':'+p2(slot.m+20); followup_pass=true; }
temps.push({id:'demo-temp-'+di+'-'+ri+'-'+si,user_id:_DEMO_UID,date:d,time:p2(slot.h)+':'+p2(slot.m),unit:r.unit,type:r.type,threshold:r.th,temp,pass,action,corrective_action,followup_temp,followup_time,followup_pass,created_at:iso(d,slot.h,slot.m)});
});
});
});
const cleaning_logs=D.slice(1).map((d,i)=>({id:'demo-clean-'+i,user_id:_DEMO_UID,date:d,checks:full(8),saved_by:'Sam Rivera',created_at:iso(d,16,45)}));
const van_checks=D.slice(1).map((d,i)=>({id:'demo-van-'+i,user_id:_DEMO_UID,date:d,checks:full(8),signed_by:'Sam Rivera',created_at:iso(d,8,30)}));
const lpg_logs=D.slice(1,5).map((d,i)=>({id:'demo-lpg-'+i,user_id:_DEMO_UID,date:d,hose:true,reg:true,flame:true,leak:true,notes:i===0?'Leak-tested all joints with detergent spray — no bubbles.':'',created_at:iso(d,8,20)}));
const water_logs=D.slice(1,3).map((d,i)=>({id:'demo-water-'+i,user_id:_DEMO_UID,date:d,filled:true,cleaned:true,san:true,sanitised:true,created_at:iso(d,8,25)}));
const pNames=['Borough Market','Ropewalk','Kings Cross','Camden Lock','Brick Lane','Greenwich Market'];
const pAddrs=['8 Southwark St, London SE1','Ropewalk, Bristol BS1','Kings Blvd, London N1C','Camden Lock Pl, London NW1','91 Brick Ln, London E1','Greenwich, London SE10'];
const pitch_logs=D.map((d,i)=>({id:'demo-pitch-'+i,user_id:_DEMO_UID,date:d,name:pNames[i%6],address:pAddrs[i%6],created_at:iso(d,7,50)}));
const data={profiles:[_DEMO_PROFILE],diaries,temp_logs:temps,cleaning_logs,van_checks,lpg_logs,water_logs,pitch_logs,feedback:[]};
window.__vgDemoData=data;
return data;
};
class _DemoQ {
constructor(t){this.t=t;this.rows=(_demoBuild()[t]||[]).map(x=>x);this.cnt=false;this.hd=false;this.sg=false;this.mb=false;this._lim=null;}
select(c='*',o){o=o||{};if(o.count)this.cnt=true;if(o.head)this.hd=true;return this;}
eq(c,v){this.rows=this.rows.filter(r=>String(r[c])===String(v));return this;}
gte(c,v){this.rows=this.rows.filter(r=>String(r[c])>=String(v));return this;}
order(c,o){const asc=!(o&&o.ascending===false);this.rows=this.rows.slice().sort((a,b)=>a[c]===b[c]?0:(a[c]>b[c]?1:-1)*(asc?1:-1));return this;}
limit(n){this._lim=n;return this;}
single(){this.sg=true;return this;}
maybeSingle(){this.mb=true;return this;}
_run(){let rows=this.rows;if(this._lim!=null)rows=rows.slice(0,this._lim);if(this.cnt||this.hd)return Promise.resolve({count:rows.length,data:null,error:null});if(this.mb||this.sg)return Promise.resolve({data:rows[0]||null,error:null});return Promise.resolve({data:rows,error:null});}
then(res,rej){return this._run().then(res,rej);}
}
class _DemoU {
constructor(t){this.t=t;}
eq(){return this;} select(){return this;} single(){return this;}
_run(){return Promise.resolve({data:this.t==='profiles'?_DEMO_PROFILE:null,error:null});}
then(res,rej){return this._run().then(res,rej);}
}
// Demo writes mutate the in-memory store so the visitor's actions (ticking, logging temps)
// actually take effect — the ring fills, records populate, and confetti fires at 100%.
const _demoWrite=(table,d,upsert)=>{
const store=_demoBuild();
const arr=store[table]||(store[table]=[]);
(Array.isArray(d)?d:[d]).forEach(row=>{
const r=Object.assign({},row);
if(!r.id) r.id='demo-'+table+'-'+Date.now()+'-'+Math.floor(Math.random()*10000);
if(upsert){ const idx=arr.findIndex(x=>x.user_id===r.user_id&&x.date===r.date); if(idx>=0){arr[idx]=Object.assign({},arr[idx],r);return;} }
arr.unshift(r);
});
};
const _demoFrom=table=>({
select:(c,o)=>new _DemoQ(table).select(c,o),
insert:async d=>{_demoWrite(table,d,false);return{data:null,error:null};},
upsert:async d=>{_demoWrite(table,d,true);return{data:null,error:null};},
update:()=>new _DemoU(table),
});
const sb = {
auth: {
getSession: async () => ({data:{session:_getS()},error:null}),
signUp: async ({email,password,options}) => {
// Pass name/business/referral as user_metadata so the DB trigger populates the
// profile reliably — works whether or not email confirmation is on.
const r=await fetch(`${SUPA_URL}/auth/v1/signup`,{method:'POST',headers:{'apikey':SUPA_KEY,'Content-Type':'application/json'},body:JSON.stringify({email,password,data:options?.data||{}})});
const d=await r.json();
if(!r.ok || d.error || d.error_description || (d.msg && !d.id && !d.access_token)){
return {error:{message:d.error_description||d.msg||d.message||'Sign up failed. Please try again.'}};
}
const uid=d.user?.id||d.id;
// Email confirmation ON → no session yet. Profile is created by the trigger.
if(!d.access_token){
return {data:{session:null,user:d.user||d},error:null};
}
// Autoconfirm → we have a session. (Trigger also creates the profile; this is a safe fallback.)
await fetch(`${SUPA_URL}/rest/v1/profiles`,{method:'POST',headers:{..._h(d.access_token),'Prefer':'resolution=merge-duplicates,return=minimal'},body:JSON.stringify({id:uid,name:options?.data?.name,business_name:options?.data?.businessName})}).catch(()=>{});
fetch('/.netlify/functions/send-welcome',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({firstName:options?.data?.name,email:(d.user||d).email})}).catch(()=>{});
const s={access_token:d.access_token,refresh_token:d.refresh_token,user:d.user||d};
_saveS(s); _ls.forEach(fn=>fn('SIGNED_IN',s));
return {data:{session:s},error:null};
},
signInWithPassword: async ({email,password}) => {
const r=await fetch(`${SUPA_URL}/auth/v1/token?grant_type=password`,{method:'POST',headers:{'apikey':SUPA_KEY,'Content-Type':'application/json'},body:JSON.stringify({email,password})});
const d=await r.json();
if(!d.access_token) return {error:{message:d.error_description||d.msg||'Incorrect email or password'}};
const s={access_token:d.access_token,refresh_token:d.refresh_token,user:d.user};
_saveS(s); _ls.forEach(fn=>fn('SIGNED_IN',s));
return {data:{session:s},error:null};
},
signOut: async () => {
const s=_getS();
if(s?.access_token) await fetch(`${SUPA_URL}/auth/v1/logout`,{method:'POST',headers:{'apikey':SUPA_KEY,'Authorization':`Bearer ${s.access_token}`}}).catch(()=>{});
_saveS(null); _ls.forEach(fn=>fn('SIGNED_OUT',null));
return {error:null};
},
updatePassword: async (newPassword) => {
const s=_getS();
if(!s?.access_token) return {error:{message:'Not signed in'}};
const r=await fetch(`${SUPA_URL}/auth/v1/user`,{method:'PUT',headers:{..._h(s.access_token)},body:JSON.stringify({password:newPassword})});
const d=await r.json();
if(d.error||d.code>=400) return {error:{message:d.message||'Failed to update password'}};
return {data:d,error:null};
},
resetPassword: async (email) => {
const r=await fetch(`${SUPA_URL}/auth/v1/recover`,{method:'POST',headers:{'apikey':SUPA_KEY,'Content-Type':'application/json'},body:JSON.stringify({email})});
return r.ok?{error:null}:{error:{message:'Could not send reset email. Please check your email address.'}};
},
onAuthStateChange: cb => { _ls.push(cb); return {data:{subscription:{unsubscribe:()=>{const i=_ls.indexOf(cb);if(i>-1)_ls.splice(i,1);}}}}; }
},
from: table => {
if(typeof window!=='undefined'&&window.__vgDemo) return _demoFrom(table);
const tok=_getS()?.access_token;
return {
select:(c,o)=>new _Q(table,tok).select(c,o),
insert:async d=>{if(window.__vgDemo)return{data:null,error:null};try{const r=await fetch(`${SUPA_URL}/rest/v1/${table}`,{method:'POST',headers:{..._h(tok),'Prefer':'return=minimal'},body:JSON.stringify(d)});return r.ok?{data:null,error:null}:{data:null,error:await r.json()};}catch{if(!navigator.onLine){_addQ({table,data:d,tok,prefer:'return=minimal'});return{data:null,error:null,offline:true};}return{data:null,error:{message:'Network error'}};}},
upsert:async(d,opts)=>{if(window.__vgDemo)return{data:null,error:null};const oc=opts&&opts.onConflict?`?on_conflict=${encodeURIComponent(opts.onConflict)}`:'';try{const r=await fetch(`${SUPA_URL}/rest/v1/${table}${oc}`,{method:'POST',headers:{..._h(tok),'Prefer':'resolution=merge-duplicates,return=minimal'},body:JSON.stringify(d)});return r.ok?{data:null,error:null}:{data:null,error:await r.json()};}catch{if(!navigator.onLine){_addQ({table,data:d,tok,prefer:'resolution=merge-duplicates,return=minimal',oc});return{data:null,error:null,offline:true};}return{data:null,error:{message:'Network error'}};}},
update:d=>new _U(table,tok,d),
};
}
};
// ── OFFLINE QUEUE ─────────────────────────────────────────────────────────────
const _OQ_KEY='vg_offline_queue';
const _getQ=()=>{try{return JSON.parse(localStorage.getItem(_OQ_KEY)||'[]');}catch{return[];}};
const _saveQ=q=>localStorage.setItem(_OQ_KEY,JSON.stringify(q));
const _addQ=item=>{const q=_getQ();q.push({...item,qid:Date.now()+Math.random().toString(36).slice(2)});_saveQ(q);};
const replayOfflineQueue=async()=>{
const q=_getQ();
if(!q.length) return 0;
const remain=[];
for(const item of q){
try{
const tok=_getS()?.access_token||item.tok;
const heads={..._h(tok)};
if(item.prefer) heads['Prefer']=item.prefer;
const r=await fetch(`${SUPA_URL}/rest/v1/${item.table}${item.oc||''}`,{method:'POST',headers:heads,body:JSON.stringify(item.data)});
if(!r.ok) remain.push(item);
}catch{remain.push(item);}
}
_saveQ(remain);
return q.length-remain.length;
};
// ── HELPERS ───────────────────────────────────────────────────────────────────
const today = () => new Date().toISOString().slice(0,10);
const nowTime = () => new Date().toTimeString().slice(0,5);
const fmtDate = d => new Date(d+'T12:00:00').toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'});
const fmtDateShort = d => new Date(d+'T12:00:00').toLocaleDateString('en-GB',{day:'numeric',month:'short'});
// Relative timestamp for the activity feed: "10:42" if today, else "14 Jul".
const fmtWhen = ts => { try{ const d=new Date(ts); if(isNaN(d)) return ''; const isToday=d.toISOString().slice(0,10)===today(); return isToday?d.toTimeString().slice(0,5):d.toLocaleDateString('en-GB',{day:'numeric',month:'short'}); }catch{return '';} };
// Per-day "not needed today" markers (e.g. water on a non-trading day). Keyed by day so
// they only excuse the check for today, never permanently. Mirrors the vg_na_* pattern.
const naDayKey = (userId,kind) => `vg_naday_${kind}_${userId}_${today()}`;
const isNADay = (userId,kind) => { try{ return localStorage.getItem(naDayKey(userId,kind))==='1'; }catch{ return false; } };
const setNADayFlag = (userId,kind,val) => { try{ localStorage.setItem(naDayKey(userId,kind), val?'1':'0'); }catch{} };
// ── Analytics: GA4 funnel events. track() is suppressed in demo mode so sample
// interactions never pollute the real funnel; trackOnce() fires a milestone only once.
const gaEvent = (name,params) => { try{ if(typeof window!=='undefined'&&window.gtag) window.gtag('event',name,params||{}); }catch{} };
const track = (name,params) => { if(typeof window!=='undefined'&&window.__vgDemo) return; gaEvent(name,params); };
const trackOnce = (key,name,params) => {
if(typeof window!=='undefined'&&window.__vgDemo) return;
let seen=false; try{ seen=!!localStorage.getItem('vg_ev_'+key); }catch{}
if(seen) return;
try{ localStorage.setItem('vg_ev_'+key,'1'); }catch{}
gaEvent(name,params);
};
// Public Google review link for the happy-path of the feedback prompt.
// Paste your Google Business "review us" URL here (leave '' to hide the review nudge).
const GOOGLE_REVIEW_URL='';
// Ask for a rating once per user; if dismissed, don't ask again for 45 days.
const shouldPromptFeedback=uid=>{ try{ if(localStorage.getItem('vg_fb_done_'+uid)) return false; const s=localStorage.getItem('vg_fb_snooze_'+uid); if(s&&(Date.now()-Number(s))<45*864e5) return false; return true; }catch{ return true; } };
// ── ICONS ─────────────────────────────────────────────────────────────────────
const I = {
home:,
diary:,
temp:<>>,
van:,
eho:,
check:,
warn:,
user:,
chevron:,
back:,
fire:,
location:,
water:,
lock:,
logout:,
star:,
plus:,
team:,
download:,
mail:,
chat:,
bell:,
};
const Icon = ({name,size=20,color='currentColor'}) => (
);
// ── VAN ICON SVG ─────────────────────────────────────────────────────────────
const VanIcon = ({size=40, wheelColor='#1A2744'}) => (
);
// ── SHARED UI ─────────────────────────────────────────────────────────────────
const Spinner = ({full}) => (
);
const Card = ({children,className=''}) => {children}
;
const Badge = ({label,color='teal'}) => {
const cls={green:'bg-green-100 text-green-700',red:'bg-red-100 text-red-700',amber:'bg-amber-100 text-amber-700',teal:'bg-teal-light text-teal',navy:'bg-navy/10 text-navy'}[color]||'bg-teal-light text-teal';
return {label};
};
const Checkbox = ({checked,onChange,label,isNA,onToggleNA}) => {
if(isNA) return (
NA
{label}
);
return (
{onToggleNA&&}
);
};
const Input = ({label,value,onChange,type='text',placeholder='',required=false,hint}) => (
{label&&
}
onChange(e.target.value)} placeholder={placeholder}
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-800 bg-gray-50 focus:outline-none focus:border-teal focus:bg-white transition-colors"/>
{hint&&
{hint}
}
);
const Btn = ({children,onClick,variant='primary',className='',disabled=false,loading=false,size='md'}) => {
const cls={primary:'bg-teal text-white active:opacity-80',navy:'bg-navy text-white active:opacity-80',ghost:'bg-gray-100 text-gray-600 active:bg-gray-200',outline:'border border-navy text-navy bg-white active:bg-gray-50',danger:'bg-red-500 text-white active:opacity-80'}[variant];
const sz={md:'py-3.5 text-sm',sm:'py-2.5 text-xs',lg:'py-4 text-base'}[size];
return (
);
};
const ErrMsg = ({msg}) => msg?{msg}
:null;
const OkMsg = ({msg}) => msg?{msg}
:null;
// ── TOP NAV ───────────────────────────────────────────────────────────────────
// label = compact (mobile tab bar), full = clearer wording (desktop sidebar).
const TABS = [
{key:'dashboard',label:'Today',full:'Today',icon:'home'},
{key:'diary',label:'Checks',full:'Daily Checks',icon:'diary'},
{key:'temp',label:'Temps',full:'Temperatures',icon:'temp'},
{key:'van',label:'Safety',full:'Safety & Equipment',icon:'van'},
{key:'records',label:'Records',full:'Records',icon:'eho'},
];
// ── LIVE COMPLIANCE (today's %) ───────────────────────────────────────────────
// Same 4-part logic as the Dashboard: diary (opening+closing), 2+ temps, cleaning, unit.
async function computeTodayCompliance(userId){
try{
const todayStr=today();
const [{data:tD},{data:tT},{data:tC2},{data:tV},{data:prof},{data:lpgR},{data:wT}]=await Promise.all([
sb.from('diaries').select('opening,closing').eq('user_id',userId).eq('date',todayStr),
sb.from('temp_logs').select('id').eq('user_id',userId).eq('date',todayStr),
sb.from('cleaning_logs').select('id').eq('user_id',userId).eq('date',todayStr),
sb.from('van_checks').select('id').eq('user_id',userId).eq('date',todayStr),
sb.from('profiles').select('lpg_enabled,water_enabled').eq('id',userId).maybeSingle(),
sb.from('lpg_logs').select('created_at').eq('user_id',userId).order('created_at',{ascending:false}).limit(1),
sb.from('water_logs').select('id').eq('user_id',userId).eq('date',todayStr),
]);
let na={};try{na=JSON.parse(localStorage.getItem('vg_na_diary_'+userId))||{};}catch{}
const rmO=readRemoved(userId,'opening'),rmC=readRemoved(userId,'closing');
const d0=tD&&tD[0];
const openDone=!!d0&&DEFAULT_OPENING.every((_,i)=>rmO.includes(i)||(d0.opening&&d0.opening[i])||na['o'+i]);
const closeDone=!!d0&&DEFAULT_CLOSING.every((_,i)=>rmC.includes(i)||(d0.closing&&d0.closing[i])||na['c'+i]);
const items=[openDone&&closeDone,(tT?.length||0)>=2,!!(tC2?.length),!!(tV?.length)];
// LPG is a monthly requirement: counts as a check, satisfied while a check is within 30 days.
if(prof?.lpg_enabled!==false){ const r=lpgR&&lpgR[0]; items.push(!!r&&(Date.now()-new Date(r.created_at))/86400000<=30); }
// Water is a daily check (only for units that use it): a log today or "not needed today".
if(prof?.water_enabled===true){ items.push((wT?.length||0)>0||isNADay(userId,'water')); }
const done=items.filter(Boolean).length;
return Math.round(done/items.length*100);
}catch{return 0;}
}
const complianceColor=(p)=>`hsl(${(30+(140-30)*(Math.max(0,Math.min(100,p))/100)).toFixed(1)},78%,45%)`;
const ComplianceRing=({pct=0,variant='header',onClick})=>{
const size=variant==='sidebar'?60:38;
const sw=variant==='sidebar'?6:4.5;
const r=(size-sw)/2-1, c=2*Math.PI*r;
const col=complianceColor(pct);
const done=pct>=100;
const fs=variant==='sidebar'?18:12;
return (
);
};
// ── CONFETTI (plays once on success) ──────────────────────────────────────────
const Confetti=({show,onDone})=>{
const ref=useRef(null);
useEffect(()=>{
if(!show) return;
if(!ref.current||!window.lottie){ const t=setTimeout(()=>onDone&&onDone(),100); return ()=>clearTimeout(t); }
let anim;
try{
anim=window.lottie.loadAnimation({container:ref.current,renderer:'svg',loop:false,autoplay:true,path:'/success-confetti.json'});
anim.addEventListener('complete',()=>onDone&&onDone());
}catch{ onDone&&onDone(); }
const safety=setTimeout(()=>onDone&&onDone(),4500);
return ()=>{clearTimeout(safety);try{anim&&anim.destroy();}catch{}};
},[show]);
if(!show) return null;
return ;
};
const TopNav = ({screen,onChange,profile,compliance=0}) => {
const initials = (profile?.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
return (
{/* Header bar */}
VanGuard
{profile?.business_name||'Stay compliant. Keep moving.'}
onChange('dashboard')}/>
{/* Tab bar */}
{TABS.map(t=>(
))}
);
};
// ── DESKTOP SIDEBAR (≥lg) ─────────────────────────────────────────────────────
const CogIcon = ({size=18})=>(
);
const DesktopSidebar = ({screen,onChange,profile,onHelp,compliance=0}) => {
const item=(active)=>`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-all ${active?'bg-teal text-white':'text-white/60 hover:bg-white/10 hover:text-white'}`;
return (
VanGuard
{profile?.business_name||'Stay compliant'}
onChange('dashboard')}/>
);
};
// ── LANDING PAGE ──────────────────────────────────────────────────────────────
// ── ISO CAROUSEL ──────────────────────────────────────────────────────────────
const IsoCarousel = ({bg='#0C1628'}) => {
const stkRef=useRef(null);
const titleRef=useRef(null);
const dotsRef=useRef(null);
const wrapRef=useRef(null);
useEffect(()=>{
const S=[
{t:'Dashboard',img:'/shots/dashboard.jpg'},
{t:'Temperatures',img:'/shots/temps.jpg'},
{t:'Daily Checks',img:'/shots/diary.jpg'},
{t:'Safety & Equipment',img:'/shots/safety.jpg'},
{t:'Records',img:'/shots/records.jpg'},
];
const ZP=[115,58,0,-58,-115],OP=[1,0.82,0.64,0.48,0.34];
const mkP=s=>``;
const stk=stkRef.current,titleEl=titleRef.current,dotsEl=dotsRef.current;
if(!stk)return;
const cards=S.map((s,i)=>{
const d=document.createElement('div');
d.style.cssText='position:absolute;top:50%;left:50%;width:122px;height:208px;background:#05070d;border:1px solid rgba(255,255,255,0.16);border-radius:22px;box-shadow:0 14px 34px rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;padding:5px;box-sizing:border-box;will-change:transform,opacity';
d.innerHTML=mkP(s);
d.style.transform=`translate(-50%,-50%) translateZ(${ZP[i]}px)`;
d.style.opacity=String(OP[i]);
d.style.zIndex=String(10-i);
stk.appendChild(d);
return d;
});
let queue=[0,1,2,3,4],busy=false;
const updateUI=()=>{
if(titleEl)titleEl.textContent=S[queue[0]].t.toUpperCase();
if(dotsEl)dotsEl.querySelectorAll('.iso-dot').forEach((d,i)=>{d.className='iso-dot'+(queue[0]===i?' iso-on':'');});
};
if(dotsEl){
S.forEach((_,i)=>{
const d=document.createElement('span');
d.className='iso-dot'+(i===0?' iso-on':'');
d.style.cssText='width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.22);display:inline-block;margin:0 3px;cursor:pointer;transition:all 0.3s;vertical-align:middle';
d.onclick=()=>{
if(busy||queue[0]===i)return;
const pos=queue.indexOf(i);if(pos<0)return;
queue=[...queue.slice(pos),...queue.slice(0,pos)];
queue.forEach((sc,p)=>{cards[sc].style.transition='transform 0.46s cubic-bezier(0.4,0,0.2,1),opacity 0.36s';cards[sc].style.transform=`translate(-50%,-50%) translateZ(${ZP[p]}px)`;cards[sc].style.opacity=String(OP[p]);cards[sc].style.zIndex=String(10-p);});
updateUI();
};
dotsEl.appendChild(d);
});
}
// Dot active style via JS since we can't use a
In the print dialog, choose your printer, or set the destination to “Save as PDF”.
${reportInner}