<\/head>
🧬
PHYX44 · PichiaLog
Fermentation Data Logger
v6.0 · Supabase + Local
Contact your administrator to reset your password

⚠️ Deviation Report — PichiaLog v4.0

Generated: ${new Date().toLocaleString()}

${rows.map(r=>r.innerHTML).join('')}
BATCHHOURPARAMETERVALUELIMITDEVIATIONSEVERITY
<\/body><\/html>`; const w=window.open('','_blank'); w.document.write(html); w.document.close(); } // ══════════════════════════════════════ // ONE-PAGE BATCH SUMMARY CARD // ══════════════════════════════════════ function fillSummaryBatch(){ const sel=document.getElementById('sum-batch'), cur=sel.value; sel.innerHTML=''+getBatches().map(b=>``).join(''); } function renderSummaryCard(){ const batch=document.getElementById('sum-batch').value; const el=document.getElementById('sum-preview'); if(!batch){el.innerHTML='
📄

Select a batch

';return;} const bd=vData().filter(d=>d.batch===batch); if(!bd.length){el.innerHTML='

No data for this batch

';return;} const last=bd[bd.length-1]; const first=bd[0]; const hasT1=bd.some(d=>d.t1), hasT2=bd.some(d=>d.t2); const t1h=bd.find(d=>d.newT1)?.hour, t2h=bd.find(d=>d.newT2)?.hour; const doArr=bd.filter(d=>d.doVal!=null).map(d=>d.doVal); const avgDO=doArr.length?(doArr.reduce((a,v)=>a+v,0)/doArr.length).toFixed(1):'—'; const minDO=doArr.length?Math.min(...doArr).toFixed(1):'—'; const tempArr=bd.filter(d=>d.temp!=null).map(d=>d.temp); const avgTemp=tempArr.length?(tempArr.reduce((a,v)=>a+v,0)/tempArr.length).toFixed(1):'—'; const titreArr=bd.filter(d=>d.titre!=null).map(d=>d.titre); const maxTitre=titreArr.length?Math.max(...titreArr).toFixed(1):'—'; const nh3Tot=bd.reduce((s,d)=>s+(d.ammonia||0),0).toFixed(1); const totalGlyVol=(bd.reduce((s,d)=>s+(d.glycerol||0),0)*0.5).toFixed(0); const totalMeohVol=(bd.reduce((s,d)=>s+(d.methanol||0),0)*0.5).toFixed(0); const batchSamples=vSamples().filter(s=>s.batch===batch); const devCount=vData().filter(d=>d.batch===batch&&( (d.doVal&&d.doVal<20)||(d.ph&&(d.ph<4.8||d.ph>5.5))||(d.temp&&(d.temp<28||d.temp>32)) )).length; el.innerHTML=`
🧬 BATCH SUMMARY CARD
PichiaLog v4.0 — Pichia pastoris Fermentation Platform
Generated: ${new Date().toLocaleString()}
Data points: ${bd.length}
BATCH NUMBER
${batch}
STRAIN / CLONE
${last.strain||'—'}
TOTAL TIME
${last.hour}h
${[ ['AVG DO%',avgDO+'%',parseFloat(avgDO)<25?'var(--danger)':'var(--accent2)'], ['MIN DO%',minDO+'%',parseFloat(minDO)<20?'var(--danger)':'var(--warn)'], ['AVG TEMP',avgTemp+'°C','var(--accent)'], ['FINAL WCW',(last.wcw??'—')+' g/L','var(--accent2)'], ['MAX TITRE',maxTitre+' mg/L','var(--accent3)'], ['TOTAL NH₃',nh3Tot+' mL','var(--warn)'], ['GLY FEED',totalGlyVol+' mL','var(--accent)'], ['MeOH FEED',totalMeohVol+' mL','var(--accent)'], ].map(([l,v,c])=>`
${v}
${l}
`).join('')}
PROCESS PHASE TIMELINE
BATCH${t1h?' → '+t1h+'h':''}
${hasT1?`
GLYCEROL FB${t2h?' → '+t2h+'h':''}
`:''} ${hasT2?`
INDUCTION → ${last.hour}h
`:''}
TRIGGER EVENTS
T1 Glycerol: ${hasT1?'✅ Hr '+t1h:'Not reached'}
T2 Induction: ${hasT2?'✅ Hr '+t2h:'Not reached'}
PROCESS QUALITY
Deviations: ${devCount>0?devCount+' events':'✅ None'}
Samples: ${batchSamples.length} logged
OPERATORS
${[...new Set(bd.map(d=>d.operator).filter(Boolean))].join(', ')||'—'}
BATCH NOTES (from log)
${bd.filter(d=>d.notes).map(d=>`
${d.hour}h: ${d.notes}
`).join('')||'No notes recorded'}
`; } function printSummaryCard(){ const inner=document.getElementById('summary-card-inner'); if(!inner){alert('Select a batch first.');return;} const batch=document.getElementById('sum-batch').value||'batch'; const w=window.open('','_blank'); const styles = `*{margin:0;padding:0;box-sizing:border-box;}body{background:#fff;color:#1a1a2e;font-family:'JetBrains Mono',monospace;padding:28px;font-size:12px;}@media print{body{padding:16px;}@page{size:A4;margin:15mm;}}`; const body = inner.outerHTML .replace(/var\(--accent\)/g,'#0369a1') .replace(/var\(--accent2\)/g,'#059669') .replace(/var\(--accent3\)/g,'#7c3aed') .replace(/var\(--warn\)/g,'#d97706') .replace(/var\(--danger\)/g,'#dc2626') .replace(/var\(--text\)/g,'#111827') .replace(/var\(--text2\)/g,'#374151') .replace(/var\(--muted\)/g,'#6b7280') .replace(/var\(--bg2\)/g,'#f3f4f6') .replace(/var\(--panel\)/g,'#fff') .replace(/var\(--border\)/g,'#d1d5db'); w.document.open(); w.document.write('Batch Summary — '+batch+''+body+''); w.document.close(); w.onload = () => w.print(); } // ══════════════════════════════════════ // ALARMS // ══════════════════════════════════════ function addAlarm(type,entry,msg){ vAlarms().push({type,time:entry.time,batch:entry.batch||'—',msg,ts:Date.now()}); saveStorage(); } // ══════════════════════════════════════ // UPDATE ALL // ══════════════════════════════════════ function updateAll(){ updateBatchBrowser(); updateTopBadges(); updatePhaseTracker(); updateKPIs(); updateAlertBanners(); updateMiniCharts(); updateDashSummary(); updateTable(); renderSampleTable(); updateTrafficLights(); updateNextActions(); updateVsLastBatch(); renderShiftNotesPreview(); // Update pH and temp hints based on current phase const inInduction = vData().some(d => d.t2); const phHint = document.getElementById('h-ph'); const tempHint = document.getElementById('h-temp'); if (phHint) phHint.innerHTML = inInduction ? '⚗️ Induction: Target 5.2' : 'Target: 4.5 – 5.5'; if (tempHint) tempHint.innerHTML = inInduction ? '⚗️ Induction: Target 24°C' : 'Target: 30 ± 0.5°C'; } function updateTopBadges(){ const hasT1=vDataView().some(d=>d.t1), hasT2=vDataView().some(d=>d.t2), last=vDataView()[vDataView().length-1]; const phaseLabel = hasT2 ? 'INDUCTION' : hasT1 ? 'GLYCEROL FB' : 'BATCH'; document.getElementById('chip-phase').textContent = phaseLabel; if(last?.batch) document.getElementById('chip-batch').textContent='BATCH: '+last.batch; // Sync minimal topbar const tbName = document.getElementById('topbar-batch-name'); const tbPhase = document.getElementById('topbar-phase-chip'); const tbHours = document.getElementById('topbar-hours-chip'); if (tbName) tbName.textContent = last?.batch ? last.batch + (last.strain ? ' · ' + last.strain : '') : 'No Active Batch'; if (tbPhase) { tbPhase.textContent = phaseLabel; tbPhase.style.display = last ? '' : 'none'; } if (tbHours) { tbHours.textContent = last ? 'Hr ' + last.hour : ''; tbHours.style.display = last ? '' : 'none'; } // Sync dashboard card header const dbName = document.getElementById('dash-batch-name'); const dbStrain = document.getElementById('dash-strain-chip'); const dbPhaseC = document.getElementById('dash-phase-chip'); const dbOp = document.getElementById('dash-op-chip'); if (dbName) dbName.textContent = last?.batch || 'No Active Batch'; if (dbStrain) { dbStrain.textContent = last?.strain || ''; dbStrain.style.display = last?.strain ? '' : 'none'; } if (dbPhaseC) dbPhaseC.textContent = phaseLabel; if (dbOp) { dbOp.textContent = last?.operator ? '👤 ' + last.operator : ''; dbOp.style.display = last?.operator ? '' : 'none'; } // Sync batch status bar const bsbBatch = document.getElementById('bsb-batch'); const bsbPhase = document.getElementById('bsb-phase'); const bsbHours = document.getElementById('bsb-hours'); const bsbOp = document.getElementById('bsb-operator'); if (bsbBatch) bsbBatch.textContent = last?.batch || '—'; if (bsbPhase) bsbPhase.textContent = phaseLabel; if (bsbHours) bsbHours.textContent = last ? last.hour : '—'; if (bsbOp) bsbOp.textContent = last?.operator || '—'; } function updateAlertBanners(){ const hasT1=vDataView().some(d=>d.t1), hasT2=vDataView().some(d=>d.t2); document.getElementById('al-t1').classList.toggle('show', hasT1); document.getElementById('al-t2').classList.toggle('show', hasT2); // Induction setpoint reminder banner let banner = document.getElementById('al-induction-setpoints'); if (!banner) { banner = document.createElement('div'); banner.id = 'al-induction-setpoints'; banner.className = 'alert'; banner.style.cssText = 'background:#f0fdf4;border-color:#86efac;color:#15803d;'; banner.innerHTML = `⚗️
ETHANOL INDUCTION ACTIVE — Check Setpoints
Target: pH → 5.2  ·  Temperature → 24°C  ·  Switch carbon source to ethanol feed
`; const alertsDiv = document.querySelector('.alerts'); if (alertsDiv) alertsDiv.appendChild(banner); } banner.classList.toggle('show', hasT2); } function updatePhaseTracker(){ const hasT1=vDataView().some(d=>d.t1), hasT2=vDataView().some(d=>d.t2); document.getElementById('ps1').className='pstep '+(hasT1?'done':'active'); document.getElementById('ps2').className='pstep '+(hasT2?'done':hasT1?'active':''); document.getElementById('ps3').className='pstep '+(hasT2?'active':''); } function updateKPIs(){ const n=vData().length, last=vData()[n-1]; setKPI('k-entries',n,'',()=>''); if(!last) return; setKPI('k-hours',last.hour,'h',()=>''); setKPI('k-do',last.doVal,'%',v=>v<20?'danger':v<30?'warn':'good'); setKPI('k-ph',last.ph,'',v=>v<4.5||v>6?'warn':'good'); setKPI('k-rpm',last.rpm,'',v=>v>=window._MAX_RPM?'danger':v>=window._MAX_RPM*0.85?'warn':''); setKPI('k-vol',last.batchVol,' L',v=>last.pctFull>90?'danger':last.pctFull>75?'warn':'good'); setKPI('k-wcw',last.wcw,' g/L',v=>v>=300?'good':''); setKPI('k-od',last.od,'',()=>''); setKPI('k-titre',last.titre,' mg/L',()=>'good'); } function setKPI(id,val,unit,clsFn){ const el=document.getElementById(id); if(!el) return; if(val==null||val===''||isNaN(val)){el.textContent='—';el.className='kpi-val';return;} const num = parseFloat(val); const display = Number.isInteger(num) ? num : num.toFixed(2); el.textContent=display+unit; el.className='kpi-val '+(clsFn(num)||''); } function updateDashSummary(){ const el=document.getElementById('dash-summary'); if(!vData().length){el.innerHTML='
📊

Log your first data point to see live summary

';return;} const last=vData()[vData().length-1]; const hasT1=vData().some(d=>d.t1), hasT2=vData().some(d=>d.t2); const t1h=vData().find(d=>d.newT1)?.hour, t2h=vData().find(d=>d.newT2)?.hour; el.innerHTML=`
📦 Batch: ${last.batch||'—'}
🧫 Strain: ${last.strain||'—'}
⏱ Time: ${last.hour}h
🔄 Phase: ${last.phase}
💨 DO: ${last.doVal??'—'}%
⚗️ pH: ${last.ph??'—'}
⚙️ RPM: ${last.rpm}/${window._MAX_RPM}
💨 Air: ${last.air}/${window._MAX_AIR}LPM
⚖️ WCW: ${last.wcw??'—'} g/L
💎 Titre: ${last.titre??'—'} mg/L
🚨 T1: ${hasT1?'✅ Hr '+t1h:'Waiting'}
🔬 T2: ${hasT2?'✅ Hr '+t2h:'Waiting'}
`; } // ══════════════════════════════════════ // TABLE // ══════════════════════════════════════ function updateTable(){ const tbody=document.getElementById('tbl-body'); const filterEl = document.getElementById('tbl-batch-filter'); // Build batch list for filter dropdown const allBatches = [...new Set(vData().map(d=>d.batch).filter(Boolean))].sort(); if (filterEl) { const current = filterEl.value; filterEl.innerHTML = '' + allBatches.map(b=>``).join(''); if (!current && _activeRun && _activeRun.batch && allBatches.includes(_activeRun.batch)) { filterEl.value = _activeRun.batch; } } const filterBatch = viewBatch || (filterEl ? filterEl.value : ''); const rows = filterBatch ? vData().filter(d=>d.batch===filterBatch) : vData(); // Show batch name in panel header const hdr = document.getElementById('tbl-batch-hdr'); if (hdr) hdr.textContent = filterBatch ? filterBatch : (allBatches.length ? allBatches.join(', ') : '—'); document.getElementById('tbl-count').textContent = rows.length + ' entries'; if(!rows.length){tbody.innerHTML='No data for selected batch';return;} // Sort ascending by hour (lowest first) const sorted = [...rows].sort((a,b) => a.hour - b.hour); // Fix phase persistence — once a phase starts, carry it forward let currentPhase = 'BATCH'; const phaseFixed = sorted.map(d => { if (d.phase === 'INDUCTION') currentPhase = 'INDUCTION'; else if (d.phase === 'GLYCEROL_FB' && currentPhase !== 'INDUCTION') currentPhase = 'GLYCEROL_FB'; return { ...d, _displayPhase: currentPhase }; }); tbody.innerHTML = phaseFixed.map((d, i)=>{ const pc = d._displayPhase==='BATCH'?'p-batch':d._displayPhase==='GLYCEROL_FB'?'p-glycerol':'p-induction'; const pl = d._displayPhase==='BATCH'?'BATCH':d._displayPhase==='GLYCEROL_FB'?'GLYCEROL FB':'INDUCTION'; const tf = d.newT2?'🔬T2':d.newT1?'🚨T1':'—'; const volColor = d.pctFull>90?'var(--danger)':d.pctFull>75?'var(--warn)':'var(--accent2)'; const volDisplay = d.batchVol!=null ? `${d.batchVol}L${d.pctFull!=null?' ('+d.pctFull+'%)':''}` : '—'; return` ${i+1} ${(d.time||'').replace('T',' ')}${d.hour}h ${pl} ${d.ph??'—'}${d.doVal??'—'}${d.temp??'—'} ${d.rpm}${d.air}${d.bp??'—'} ${d.glycerol}${d.methanol} ${d.ammonia||'—'} ${d.cumFeed??'—'} ${volDisplay} ${d.wcw??'—'}${d.od??'—'}${d.titre??'—'} ${tf}${d.operator||'—'}${d.notes||'—'} `; }).join(''); } // ══════════════════════════════════════ // CHARTS // ══════════════════════════════════════ const copts=(y2=false)=>{ const base={responsive:true,maintainAspectRatio:true,animation:{duration:300}, plugins:{legend:{labels:{color:'#475569',font:{family:'JetBrains Mono',size:10},boxWidth:10}}}, scales:{x:{ticks:{color:'#64748b',font:{family:'JetBrains Mono',size:9}},grid:{color:'rgba(226,232,240,0.8)'}}, y:{ticks:{color:'#64748b',font:{family:'JetBrains Mono',size:9}},grid:{color:'rgba(226,232,240,0.8)'}}}}; if(y2) base.scales.y1={position:'right',ticks:{color:'#64748b',font:{family:'JetBrains Mono',size:9}},grid:{drawOnChartArea:false}}; return base; }; const ds=(lbl,clr,fill=false)=>({label:lbl,data:[],borderColor:clr, backgroundColor:fill?clr.replace('rgb','rgba').replace(')',',0.12)'):'transparent', tension:0.4,fill,pointRadius:3,borderWidth:2}); function mkC(id,dsets,opts){const ctx=document.getElementById(id);if(!ctx)return null;return new Chart(ctx,{type:'line',data:{labels:[],datasets:dsets},options:opts||copts()});} function initAllCharts(){ const ids=['m-rpm','m-do','m-wcw','m-feed','c-rpm','c-do','c-ph','c-feed','c-nh3','c-bp','c-wcw','c-od','c-titre']; ids.forEach(id=>{if(charts[id]){try{charts[id].destroy();}catch(e){}delete charts[id];}}); charts['m-rpm'] =mkC('m-rpm', [ds('RPM','rgb(56,189,248)',true),ds('Air LPM','rgb(167,139,250)',true)]); charts['m-do'] =mkC('m-do', [ds('DO%','rgb(52,211,153)',true)]); charts['m-wcw'] =mkC('m-wcw', [ds('WCW g/L','rgb(56,189,248)',true)]); charts['m-feed']=mkC('m-feed',[ds('Glycerol','rgb(251,191,36)'),ds('Methanol','rgb(248,113,113)')]); charts['c-rpm'] =mkC('c-rpm', [ds('RPM','rgb(56,189,248)',true),ds('Airflow LPM','rgb(167,139,250)',true)]); charts['c-do'] =mkC('c-do', [ds('DO%','rgb(52,211,153)',true)]); charts['c-feed']=mkC('c-feed',[ds('Glycerol mL/hr','rgb(251,191,36)'),ds('Ethanol mL/hr','rgb(248,113,113)')]); charts['c-nh3'] =mkC('c-nh3', [ds('Ammonia mL','rgb(52,211,153)',true)]); charts['c-bp'] =mkC('c-bp', [ds('Backpressure bar','rgb(167,139,250)',true)]); charts['c-wcw'] =mkC('c-wcw', [ds('WCW g/L','rgb(52,211,153)',true)]); charts['c-od'] =mkC('c-od', [ds('OD600','rgb(56,189,248)',true)]); charts['c-titre']=mkC('c-titre',[ds('Titre mg/L','rgb(167,139,250)',true)]); const phOpts=copts(true); charts['c-ph']=new Chart(document.getElementById('c-ph'),{type:'line',options:phOpts, data:{labels:[],datasets:[{...ds('pH','rgb(251,191,36)'),yAxisID:'y'},{...ds('Temp °C','rgb(248,113,113)'),yAxisID:'y1'}]}}); } function updateMiniCharts(){ if(!vDataView().length) return; const L=vDataView().map(d=>d.hour+'h'); const upd=(id,sets)=>{const c=charts[id];if(!c)return;c.data.labels=L;sets.forEach((v,i)=>{if(c.data.datasets[i])c.data.datasets[i].data=v;});c.update();}; upd('m-rpm',[vDataView().map(d=>d.rpm),vDataView().map(d=>d.air)]); upd('m-do',[vDataView().map(d=>d.doVal)]); upd('m-wcw',[vDataView().map(d=>d.wcw)]); upd('m-feed',[vDataView().map(d=>d.glycerol),vDataView().map(d=>d.methanol)]); } function updateCharts(){ if(!vDataView().length) return; const L=vDataView().map(d=>d.hour+'h'); const upd=(id,sets)=>{const c=charts[id];if(!c)return;c.data.labels=L;sets.forEach((v,i)=>{if(c.data.datasets[i])c.data.datasets[i].data=v;});c.update();}; upd('c-rpm',[vDataView().map(d=>d.rpm),vDataView().map(d=>d.air)]); upd('c-do',[vDataView().map(d=>d.doVal)]); upd('c-ph',[vDataView().map(d=>d.ph),vDataView().map(d=>d.temp)]); upd('c-feed',[vDataView().map(d=>d.glycerol),vDataView().map(d=>d.methanol)]); upd('c-nh3',[vDataView().map(d=>d.ammonia||0)]); upd('c-bp',[vDataView().map(d=>d.bp)]); upd('c-wcw',[vDataView().map(d=>d.wcw)]); upd('c-od',[vDataView().map(d=>d.od)]); upd('c-titre',[vDataView().map(d=>d.titre)]); } // ══════════════════════════════════════ // ══════════════════════════════════════ // GROWTH RATE CALC (unchanged) // ══════════════════════════════════════ function getBatches(){return[...new Set(vData().map(d=>d.batch).filter(Boolean))];} function fillMuBatch(){ const sel=document.getElementById('mu-batch'),cur=sel.value; sel.innerHTML=''+getBatches().map(b=>``).join(''); } function muFill(){ const batch=document.getElementById('mu-batch').value,type=document.getElementById('mu-type').value; if(!batch||type==='manual') return; const bd=vData().filter(d=>d.batch===batch&&d[type]!=null); if(bd.length<2) return; const f=bd[0],l=bd[bd.length-1]; document.getElementById('mu-x1').value=f[type]; document.getElementById('mu-t1').value=f.hour; document.getElementById('mu-x2').value=l[type]; document.getElementById('mu-t2').value=l.hour; } function calcMu(){ const x1=parseFloat(document.getElementById('mu-x1').value),x2=parseFloat(document.getElementById('mu-x2').value); const t1=parseFloat(document.getElementById('mu-t1').value),t2=parseFloat(document.getElementById('mu-t2').value); if(!x1||!x2||isNaN(t1)||isNaN(t2)||x1<=0||x2<=0||t2<=t1){alert('Check inputs.');return;} const mu=(Math.log(x2/x1)/(t2-t1)).toFixed(4),m=parseFloat(mu); let interp='',color='var(--text2)'; if(m<0.005){interp='⚠️ Very low — check culture health';color='var(--danger)';} else if(m<0.01){interp='🟡 Low — ethanol induction range';color='var(--warn)';} else if(m<0.03){interp='✅ Moderate — glycerol fed-batch range';color='var(--accent2)';} else if(m<=0.06){interp='✅ Good — healthy batch phase';color='var(--accent2)';} else{interp='⚠️ High — monitor DO closely';color='var(--warn)';} document.getElementById('mu-val').textContent=mu; document.getElementById('mu-interp').innerHTML=`${interp}`; document.getElementById('mu-result').style.display='block'; renderMuGrowthChart(); } function renderMuGrowthChart(){ const batch=document.getElementById('mu-batch').value,type=document.getElementById('mu-type').value; if(!batch||type==='manual'||!vDataView().length) return; const bd=vDataView().filter(d=>d.batch===batch&&d[type]!=null); if(bd.length<2) return; document.getElementById('mu-growth-chart-box').style.display='block'; if(!charts['c-mu-growth']){ charts['c-mu-growth']=mkC('c-mu-growth',[{label:type==='wcw'?'WCW g/L':'OD600',data:bd.map(d=>d[type]),borderColor:'rgb(52,211,153)',backgroundColor:'rgba(52,211,153,0.1)',tension:0.4,fill:true,pointRadius:4,borderWidth:2}]); charts['c-mu-growth'].data.labels=bd.map(d=>d.hour+'h');charts['c-mu-growth'].update(); } else { charts['c-mu-growth'].data.labels=bd.map(d=>d.hour+'h');charts['c-mu-growth'].data.datasets[0].data=bd.map(d=>d[type]);charts['c-mu-growth'].update(); } } // ══════════════════════════════════════ // EXPORT CSV // ══════════════════════════════════════ function exportCSV(){ if(!vData().length){alert('No data!');return;} const h=['#','Batch','Strain','Timestamp','Hour','Phase','pH','DO%','Temp_C','RPM','Air_LPM','BP_bar','Gly_mL_hr','MeOH_mL_hr','NH3_mL','NH3_%','CumFeed_mL','WCW_gL','OD600','Titre_mgL','T1','T2','Operator','Notes']; const rows=vData().map(d=>[d.id,d.batch??'',d.strain??'',d.time,d.hour,d.phase,d.ph??'',d.doVal??'',d.temp??'',d.rpm,d.air,d.bp??'',d.glycerol,d.methanol,d.ammonia||0,d.nh3||'',d.cumFeed??'',d.wcw??'',d.od??'',d.titre??'',d.t1?'YES':'',d.t2?'YES':'',d.operator??'',d.notes??'']); const csv=[h,...rows].map(r=>r.join(',')).join('\n'); const a=document.createElement('a');a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv); a.download='pichia_log_'+new Date().toISOString().slice(0,10)+'.csv';a.click(); } // ══════════════════════════════════════ // PDF REPORT // ══════════════════════════════════════ function exportPDF(){ if(!vData().length){alert('No data!');return;} const last=vData()[vData().length-1]; const hasT1=vData().some(d=>d.t1),hasT2=vData().some(d=>d.t2); const t1h=vData().find(d=>d.newT1)?.hour,t2h=vData().find(d=>d.newT2)?.hour; const doArr=vData().filter(d=>d.doVal!=null).map(d=>d.doVal); const avgDO=doArr.length?(doArr.reduce((a,v)=>a+v,0)/doArr.length).toFixed(1):'—'; const minDO=doArr.length?Math.min(...doArr).toFixed(1):'—'; const titreArr=vData().filter(d=>d.titre!=null).map(d=>d.titre); const maxTitre=titreArr.length?Math.max(...titreArr).toFixed(1):'—'; const nh3Total=vData().reduce((s,d)=>s+(d.ammonia||0),0).toFixed(1); const html=`Pichia Report — ${last.batch||'Batch'} <\/head>
🧬
PHYX44 · PichiaLog
Fermentation Data Logger
v6.0 · Supabase + Local
Contact your administrator to reset your password

🧬 PICHIA FERMENTATION REPORT v4.0

Batch: ${last.batch||'—'} | Strain: ${last.strain||'—'} | Generated: ${new Date().toLocaleString()}

KEY PERFORMANCE INDICATORS

${vData().length}
DATA POINTS
${last.hour}h
TOTAL TIME
${avgDO}%
AVG DO%
${minDO}%
MIN DO%
${last.wcw??'—'} g/L
FINAL WCW
${maxTitre} mg/L
MAX TITRE
${nh3Total} mL
TOTAL NH₃
${alarms.length}
ALARM EVENTS

PROCESS TRIGGERS

TriggerConditionHourStatus
T1 Glycerol FeedRPM=500 & Air=60LPM${t1h?'Hr '+t1h:'—'}${hasT1?'✅ TRIGGERED':'Pending'}
T2 Ethanol InductionWCW ≥ 300 g/L${t2h?'Hr '+t2h:'—'}${hasT2?'✅ TRIGGERED':'Pending'}

COMPLETE DATA LOG

${vData().map(d=>``).join('')}
#HrPhasepHDO%TempRPMAirGlyMeOHNH₃WCWOD600TitreNotes
${d.id}${d.hour}h${d.phase}${d.ph??'—'}${d.doVal??'—'}${d.temp??'—'}${d.rpm}${d.air}${d.glycerol}${d.methanol}${d.ammonia||0}${d.wcw??'—'}${d.od??'—'}${d.titre??'—'}${d.notes||'—'}
<\/body><\/html>`; const w=window.open('','_blank');w.document.write(html);w.document.close(); } // ══════════════════════════════════════ // MEDIA PREPARATION CALCULATOR // ══════════════════════════════════════ const MEDIA_RECIPES = { bsm: { name: 'BSM (Basal Salts Medium)', components: [ { name:'H₃PO₄ (85%)', stock:'85% w/v', perL:26.7, unit:'mL', note:'Add to water first — exothermic!' }, { name:'CaSO₄·2H₂O', stock:'Anhydrous', perL:0.93, unit:'g', note:'Dissolve separately if needed' }, { name:'K₂SO₄', stock:'ACS grade', perL:18.2, unit:'g', note:'' }, { name:'MgSO₄·7H₂O', stock:'ACS grade', perL:14.9, unit:'g', note:'' }, { name:'KOH (to pH target)', stock:'10M solution',perL:null, unit:'mL', note:'Adjust to target pH after dissolving' }, { name:'Distilled Water', stock:'QS to volume',perL:null, unit:'L', note:'Make up to final volume' }, ] }, fm22: { name: 'FM22 (Defined Minimal Medium)', components: [ { name:'(NH₄)₂SO₄', stock:'ACS grade', perL:5.0, unit:'g', note:'Nitrogen source' }, { name:'KH₂PO₄', stock:'ACS grade', perL:3.0, unit:'g', note:'Buffer + phosphorus' }, { name:'MgSO₄·7H₂O', stock:'ACS grade', perL:0.5, unit:'g', note:'' }, { name:'CaCl₂·2H₂O', stock:'ACS grade', perL:0.1, unit:'g', note:'' }, { name:'NaCl', stock:'ACS grade', perL:0.5, unit:'g', note:'' }, { name:'Citric acid', stock:'Anhydrous', perL:1.0, unit:'g', note:'For pH buffering' }, { name:'Distilled Water', stock:'QS to volume',perL:null, unit:'L', note:'Make up to final volume' }, ] }, yepd: { name: 'YEPD (Rich Medium — Seed Culture)', components: [ { name:'Yeast Extract', stock:'Bacto/Difco', perL:10.0, unit:'g', note:'Complex nitrogen + growth factors' }, { name:'Peptone', stock:'Bacteriological',perL:20.0,unit:'g', note:'Protein hydrolysate' }, { name:'Dextrose (Glucose)', stock:'ACS grade', perL:20.0, unit:'g', note:'Add after autoclave or filter-sterilise' }, { name:'Distilled Water', stock:'QS to volume',perL:null, unit:'L', note:'Make up to final volume' }, ] } }; const PTM1 = [ { name:'CuSO₄·5H₂O', perL:6.0, unit:'g' }, { name:'NaI', perL:0.08, unit:'g' }, { name:'MnSO₄·H₂O', perL:3.0, unit:'g' }, { name:'Na₂MoO₄·2H₂O', perL:0.2, unit:'g' }, { name:'H₃BO₃', perL:0.02, unit:'g' }, { name:'CoCl₂·6H₂O', perL:0.5, unit:'g' }, { name:'ZnCl₂', perL:20.0, unit:'g' }, { name:'FeSO₄·7H₂O', perL:65.0, unit:'g' }, { name:'Biotin (1g/L)', perL:0.2, unit:'mL (of 1g/L stock)' }, { name:'H₂SO₄ (conc.)', perL:5.0, unit:'mL' }, ]; const SUPPLEMENTS = [ { name:'Biotin (free vitamin B7)', rate:'2 mg/L media', calc: v => (v*2).toFixed(1), unit:'mg', note:'Filter sterilise — heat labile' }, { name:'Antifoam (Struktol J673)', rate:'0.2 mL/L', calc: v => (v*0.2).toFixed(1), unit:'mL', note:'Add before inoculation' }, { name:'Antibiotic (Zeocin 25mg/L)',rate:'25 mg/L', calc: v => (v*25).toFixed(0), unit:'mg', note:'Only if using Zeocin selection vector' }, ]; const CHECKLIST_ITEMS = [ 'Autoclave BSM / FM22 base (121°C, 20 min)', 'Cool media to < 60°C before adding PTM1', 'Prepare PTM1 trace salts (filter sterilise)', 'Add PTM1 at 4.35 mL per litre', 'Add biotin (filter sterilised stock)', 'Prepare glycerol (autoclave or filter)', 'Add glycerol to cooled media', 'Adjust pH with H₃PO₄ or NH₄OH', 'Add antifoam', 'Calibrate pH probe (pH 4 + 7 buffers)', 'Calibrate DO probe (polarise 6h, 2-point: 0% and 100%)', 'Sterilise fermenter vessel + probes (SIP or autoclave)', 'Fill fermenter, set temperature to 30°C', 'Inoculate at OD600 ≈ 1 from overnight seed culture', ]; function calcMedia() { const vol = parseFloat(document.getElementById('med-vol').value) || 10; const type = document.getElementById('med-type').value; const recipe = MEDIA_RECIPES[type]; document.getElementById('med-recipe-title').textContent = recipe.name + ' — ' + vol + ' L'; // Main recipe table document.getElementById('med-tbody').innerHTML = recipe.components.map(c => { const amt = c.perL != null ? (c.perL * vol).toFixed(c.unit==='mL'?1:2) : '—'; const display = c.perL != null ? amt : (c.unit==='L' ? vol.toFixed(1) : '— (to pH target)'); return ` ${c.name} ${c.stock} ${display} ${c.unit} ${c.note} `; }).join(''); // PTM1 const ptm1Total = (12 * vol).toFixed(1); document.getElementById('med-ptm1-total').value = ptm1Total; document.getElementById('ptm1-tbody').innerHTML = PTM1.map(s => { const forBatch = (s.perL * parseFloat(ptm1Total) / 1000).toFixed(3); return ` ${s.name} ${s.perL} ${s.unit} ${forBatch} ${s.unit.replace('/L stock','')} ${s.unit} `; }).join(''); // Glycerol const glyConc = parseFloat(document.getElementById('med-gly-conc').value) || 4; const glyG = (glyConc / 100) * vol * 1000; const glyMl = (glyG / 1.26).toFixed(1); document.getElementById('med-gly-g').value = glyG.toFixed(1); document.getElementById('med-gly-vol').value = glyMl; // Supplements document.getElementById('supp-tbody').innerHTML = SUPPLEMENTS.map(s => ` ${s.name} ${s.rate} ${s.calc(vol)} ${s.unit} ${s.note} `).join(''); // Checklist (render only once) const cl = document.getElementById('med-checklist'); if (!cl.children.length) { cl.innerHTML = CHECKLIST_ITEMS.map((item, i) => ` `).join(''); } } function updateCheckItem(i) { const checked = document.getElementById('cl-'+i).checked; const label = document.getElementById('cl-label-'+i); const text = document.getElementById('cl-text-'+i); label.style.background = checked ? '#f0fdf4' : 'var(--bg)'; label.style.borderColor = checked ? '#86efac' : 'var(--border)'; text.style.textDecoration = checked ? 'line-through' : 'none'; text.style.color = checked ? 'var(--muted)' : 'var(--text)'; } // ── Feed prep state ── const feedPreps = { glycerol: [], ethanol: [] }; let feedPrepCounter = 0; function initFeedPreps() { if (feedPreps.glycerol.length === 0) addFeedPrep('glycerol'); if (feedPreps.ethanol.length === 0) addFeedPrep('ethanol'); } function addFeedPrep(type) { const id = ++feedPrepCounter; const num = feedPreps[type].length + 1; feedPreps[type].push({ id, num }); renderFeedPreps(type); } function removeFeedPrep(type, id) { feedPreps[type] = feedPreps[type].filter(p => p.id !== id); feedPreps[type].forEach((p, i) => p.num = i + 1); renderFeedPreps(type); } function renderFeedPreps(type) { const container = document.getElementById(type + '-feed-preps'); if (!container) return; if (feedPreps[type].length === 0) { container.innerHTML = `
No preparations added yet. Click "+ Add Preparation" to start.
`; return; } const isGly = type === 'glycerol'; const accentColor = isGly ? 'var(--accent)' : 'var(--accent2)'; const bgColor = isGly ? 'rgba(14,165,233,0.04)' : 'rgba(16,185,129,0.04)'; const borderColor = isGly ? '#bae6fd' : '#bbf7d0'; container.innerHTML = feedPreps[type].map(prep => { const p = prep.id; const defaultVol = 20; const defaultConc = isGly ? 50 : 60; return `
${isGly ? '🟡 GLYCEROL' : '🟢 ETHANOL'} FEED — Prep #${prep.num}
${feedPreps[type].length > 1 ? `` : ''}
Total volume per bottle
${isGly ? `
Typical: 50% w/v
Pure glycerol (density 1.26)
` : `
Standard induction: 60% v/v
Pure ethanol per bottle
`}
Fixed: 12 mL per litre
Add last to reach total vol
${!isGly ? `
⚠️ Flammable liquid — prepare in fume hood. Keep away from ignition sources.
` : ''}
`; }).join(''); // Calculate all feedPreps[type].forEach(prep => calcFeedPrep(prep.id, type)); } function calcFeedPrep(id, type) { const vol = parseFloat(document.getElementById(`fp-${id}-vol`)?.value) || 20; const bottles = parseFloat(document.getElementById(`fp-${id}-bottles`)?.value) || 1; const conc = parseFloat(document.getElementById(`fp-${id}-conc`)?.value) || (type === 'glycerol' ? 50 : 60); const volMl = vol * 1000; // per bottle in mL const tes = (12 * vol).toFixed(1); // 12 mL per litre const tesMl = parseFloat(tes); let compMl = 0, compG = 0, water = 0; if (type === 'glycerol') { compG = (conc / 100) * vol * 1000; // grams glycerol compMl = (compG / 1.26).toFixed(0); // mL pure glycerol (density 1.26) water = Math.max(0, volMl - parseFloat(compMl) - tesMl).toFixed(0); const gEl = document.getElementById(`fp-${id}-comp-g`); if (gEl) gEl.value = compG.toFixed(0); } else { compMl = ((conc / 100) * volMl).toFixed(0); // mL ethanol water = Math.max(0, volMl - parseFloat(compMl) - tesMl).toFixed(0); } const total = (parseFloat(compMl) + tesMl + parseFloat(water)).toFixed(0); const set = (sfx, val) => { const el = document.getElementById(`fp-${id}-${sfx}`); if (el) el.value = val; }; set('comp-ml', compMl); set('tes', tes); set('water', water); set('total', total); } function calcFeedBottles() { // Legacy stub — now handled by calcFeedPrep } function renderFeedPrepsIfNeeded() { if (!document.getElementById('glycerol-feed-preps')) return; initFeedPreps(); } function resetChecklist() { CHECKLIST_ITEMS.forEach((_,i) => { document.getElementById('cl-'+i).checked = false; updateCheckItem(i); }); ['med-done-by','med-checked-by','med-signoff-notes','med-batch-ref'].forEach(id => { const el = document.getElementById(id); if(el) el.value = ''; }); } function printMediaChecklist() { const vol = document.getElementById('med-vol')?.value || '?'; const type = document.getElementById('med-type'); const typeName = type ? type.options[type.selectedIndex].text : '—'; const doneBy = document.getElementById('med-done-by')?.value || '_______________'; const checkedBy= document.getElementById('med-checked-by')?.value || '_______________'; const dt = document.getElementById('med-datetime')?.value?.replace('T',' ') || '_______________'; const batchRef = document.getElementById('med-batch-ref')?.value || '—'; const notes = document.getElementById('med-signoff-notes')?.value || '—'; const checked = CHECKLIST_ITEMS.map((item,i) => ({ item, done: document.getElementById('cl-'+i)?.checked })); const rows = checked.map(c => ` ${c.done ? '☑' : '☐'} ${c.item} `).join(''); const html = ` Media Prep Record — ${batchRef} <\/head>
🧬
PHYX44 · PichiaLog
Fermentation Data Logger
v6.0 · Supabase + Local
Contact your administrator to reset your password

🧬 Media Preparation Record

PichiaLog v5  |  Batch: ${batchRef}  |  Printed: ${new Date().toLocaleString()}

Preparation Details

Media TypeTarget VolumeDate / TimeBatch Reference
${typeName}${vol} L${dt}${batchRef}

Preparation Checklist

${rows}

Sign-Off

Done By (Operator)
${doneBy}
Checked By (Verifier)
${checkedBy}
Notes / Observations
${notes}
<\/body><\/html>`; const w = window.open('','_blank'); w.document.write(html); w.document.close(); w.onload = () => w.print(); } // ══════════════════════════════════════ // SAMPLING REMINDER — every 8 hours // ══════════════════════════════════════ const SAMPLE_INTERVAL_HRS = 8; const SAMPLE_WARNING_MINS = 15; // warn this many minutes before let lastWarningMilestone = -1; let lastReminderMilestone = -1; function checkSamplingReminder(hour) { // hour is in decimal, e.g. 7.5 = 7h 30min // Next 8-hour milestone const nextMilestone = (Math.floor(hour / SAMPLE_INTERVAL_HRS) + 1) * SAMPLE_INTERVAL_HRS; const warningThreshold = nextMilestone - (SAMPLE_WARNING_MINS / 60); // e.g. 7.75 for hour 8 // 15-min warning — fires once per milestone if (hour >= warningThreshold && lastWarningMilestone < nextMilestone) { lastWarningMilestone = nextMilestone; showSampleModal(nextMilestone, true); return; } // At-milestone reminder — fires once per milestone if (hour >= nextMilestone && lastReminderMilestone < nextMilestone) { lastReminderMilestone = nextMilestone; lastWarningMilestone = nextMilestone; // suppress warning if milestone reached first showSampleModal(nextMilestone, false); } } function showSampleModal(milestone, isWarning) { const modal = document.getElementById('sample-modal'); const msg = document.getElementById('sample-modal-msg'); const recommended = `WCW, OD600${milestone >= 16 ? ', Titre assay' : ''}`; if (isWarning) { msg.innerHTML = `
⏱ ${SAMPLE_WARNING_MINS} minutes to Hour ${milestone}

Get your tubes, centrifuge, and equipment ready.
Recommended: ${recommended}

This popup fires ${SAMPLE_WARNING_MINS} min before each 8-hour mark.`; } else { msg.innerHTML = `
🧫 Hour ${milestone} — Sampling Time!

Take your samples now.
Recommended: ${recommended}

Log results in the Data Table once analysis is complete.`; } document.getElementById('sample-modal-icon').textContent = isWarning ? '⏱' : '🧫'; document.getElementById('sample-modal-title').textContent = isWarning ? 'Prepare for Sampling' : 'Sampling Time!'; modal.style.display = 'flex'; if (navigator.vibrate) navigator.vibrate(isWarning ? [100, 50, 100] : [200, 100, 200]); } function dismissSampleModal(goToEntry) { document.getElementById('sample-modal').style.display = 'none'; if (goToEntry) { const tab = document.querySelector('.nav-tab[onclick*="entry"]'); if (tab) goPage('entry', tab); } } function clearAll(){ if(!vData().length&&!vSamples().length) return; if(confirm('Clear ALL data, samples and alarms?')){ vesselData[activeVessel]={data:[],alarms:[],samples:[]}; saveAllVessels();initAllCharts();updateAll(); ['al-t1','al-t2','al-t1a'].forEach(id=>document.getElementById(id)?.classList.remove('show')); } } function updateGauges() { const rpm = +document.getElementById('f-rpm').value||0; const air = +document.getElementById('f-air').value||0; const doV = +document.getElementById('f-do').value||0; const MAX_RPM = window._MAX_RPM || 300; const MAX_AIR = window._MAX_AIR || 60; const warnPct = vProfile().t1WarnPct || 85; const rP = Math.min((rpm/MAX_RPM)*100,100); const aP = Math.min((air/MAX_AIR)*100,100); if (typeof setG === 'function') { setG('g-rpm','gf-rpm',rpm||'—',rP,rP>=100?'danger':rP>=warnPct?'warn':''); setG('g-air','gf-air',air||'—',aP,aP>=100?'danger':aP>=warnPct?'warn':''); setG('g-do','gf-do',doV?(doV+'%'):'—',doV,doV>0&&doV0&&doV=100 ? '🔴 MAX REACHED — prepare glycerol feed' : rP>=warnPct ? '⚠️ Approaching max' : 'Max: '+MAX_RPM+' RPM'; hR.className = 'hint'+(rP>=100?' danger':rP>=warnPct?' warn':''); } const hA = document.getElementById('h-air'); if (hA) { hA.textContent = aP>=100 ? '🔴 MAX REACHED' : aP>=warnPct ? '⚠️ Approaching max' : 'Max: '+MAX_AIR+' LPM'; hA.className = 'hint'+(aP>=100?' danger':aP>=warnPct?' warn':''); } const al = document.getElementById('al-t1a'); if (al) al.classList.toggle('show',(rP>=warnPct||aP>=warnPct)&&!(rpm>=MAX_RPM&&air>=MAX_AIR)); } // ══════════════════════════════════════ // AUTH — LOGIN / LOGOUT // ══════════════════════════════════════ async function doLogin() { const username = document.getElementById('login-user').value.trim().toLowerCase(); const password = document.getElementById('login-pass').value; const btn = document.getElementById('login-btn'); if (!username || !password) { showLoginError('Enter username and password'); return; } btn.textContent = 'Signing in…'; btn.disabled = true; try { // Try Supabase first if available if (DB) { const hash = hashPw(password); const { data, error } = await DB .from('pl_users') .select('*') .eq('username', username) .eq('password_hash', hash) .eq('active', true) .maybeSingle(); if (!error && data) { currentUser = { id: data.id, username: data.username, role: data.role, fullName: data.full_name || data.username }; await DB.from('pl_users').update({ last_login: new Date().toISOString() }).eq('id', data.id); await loadFromDB(); subscribeRealtime(); } else if (error && error.code !== 'PGRST116') { // Real DB error (not just "no rows") showLoginError('Database error: ' + error.message); btn.textContent = 'Sign In →'; btn.disabled = false; return; } else { // Fall back to local users const user = appUsers.find(u => u.username === username && u.password === hashPw(password) && u.active); if (!user) { showLoginError('Invalid username or password'); btn.textContent='Sign In →'; btn.disabled=false; return; } currentUser = { id: user.id, username: user.username, role: user.role, fullName: user.fullName }; // Still load from DB and subscribe even if user was found locally await loadFromDB(); subscribeRealtime(); } } else { // No DB — use local users only const user = appUsers.find(u => u.username === username && u.password === hashPw(password) && u.active); if (!user) { showLoginError('Invalid username or password'); btn.textContent='Sign In →'; btn.disabled=false; return; } currentUser = { id: user.id, username: user.username, role: user.role, fullName: user.fullName }; } addAuditEntry('login', null, null, null, null, null); document.getElementById('login-screen').style.display = 'none'; document.getElementById('chip-user-name').textContent = currentUser.fullName; document.getElementById('chip-user-role').textContent = currentUser.role; applyRoleUI(); applyVesselProfile(); updateVesselUI(); initAllCharts(); updateAll(); showStorageStatus(); } catch(e) { console.error('Login error:', e); showLoginError('Login error: ' + e.message); btn.textContent = 'Sign In →'; btn.disabled = false; } } function doLogout() { if (!confirm('Sign out?')) return; addAuditEntry('logout', null, null, null, null, null); currentUser = null; document.getElementById('login-screen').style.display = 'flex'; document.getElementById('login-user').value = ''; document.getElementById('login-pass').value = ''; document.getElementById('login-error').style.display = 'none'; } function showLoginError(msg) { const el = document.getElementById('login-error'); el.textContent = msg; el.style.display = 'block'; el.style.background = '#fef2f2'; el.style.color = '#dc2626'; } function applyRoleUI() { document.querySelectorAll('.role-operator').forEach(el => el.style.display = CAN_EDIT() ? '' : 'none'); document.querySelectorAll('.role-reviewer').forEach(el => el.style.display = CAN_REVIEW() ? '' : 'none'); document.querySelectorAll('.role-admin').forEach(el => el.style.display = IS_ADMIN() ? '' : 'none'); const btnLog = document.getElementById('btn-log'); if (btnLog && IS_READONLY()) { btnLog.disabled = true; btnLog.title = 'Read-only access'; } // Redirect non-admins away from settings page if (!IS_ADMIN()) { const settingsPage = document.getElementById('page-settings'); if (settingsPage && settingsPage.classList.contains('active')) { goPage('dashboard', document.querySelector('.nav-tab')); } } } // ══════════════════════════════════════ // AUDIT TRAIL (stored in IndexedDB) // ══════════════════════════════════════ function addAuditEntry(action, table, recordId, field, oldVal, newVal, batch, vessel) { if (!vesselData._audit) vesselData._audit = []; vesselData._audit.push({ action, table, recordId, field, oldVal: oldVal != null ? String(oldVal) : null, newVal: newVal != null ? String(newVal) : null, batch: batch || null, vessel: vessel || activeVessel, performedBy: currentUser?.username || 'system', performedAt: new Date().toISOString(), }); // Save audit to IDB idbSet('pl_audit', vesselData._audit).catch(() => {}); } function loadAudit() { return idbGet('pl_audit').then(val => { vesselData._audit = val || []; }).catch(() => { vesselData._audit = []; }); } // ══════════════════════════════════════ // USER MANAGEMENT (Admin only) // ══════════════════════════════════════ function saveUsers() { idbSet('pl_users', appUsers).catch(() => {}); } function loadUsers() { return idbGet('pl_users').then(val => { appUsers = val || JSON.parse(JSON.stringify(DEFAULT_USERS)); }).catch(() => { appUsers = JSON.parse(JSON.stringify(DEFAULT_USERS)); }); } function createUser(username, password, role, fullName) { if (appUsers.find(u => u.username === username)) throw new Error('Username already exists'); const newUser = { id: 'u' + Date.now(), username: username.toLowerCase(), password: hashPw(password), role, fullName, active: true, createdAt: new Date().toISOString(), createdBy: currentUser?.username }; appUsers.push(newUser); saveUsers(); addAuditEntry('create_user', 'users', newUser.id, 'role', null, role); return newUser; } function updateUserRole(userId, newRole) { const user = appUsers.find(u => u.id === userId); if (!user) return; const oldRole = user.role; user.role = newRole; saveUsers(); addAuditEntry('update_user', 'users', userId, 'role', oldRole, newRole); } function resetUserPassword(userId, newPassword) { const user = appUsers.find(u => u.id === userId); if (!user) return; user.password = hashPw(newPassword); saveUsers(); addAuditEntry('reset_password', 'users', userId, 'password', null, '***'); } function deactivateUser(userId) { const user = appUsers.find(u => u.id === userId); if (!user) return; user.active = false; saveUsers(); addAuditEntry('deactivate_user', 'users', userId, 'active', 'true', 'false'); } // ══════════════════════════════════════ // BATCH SIGN-OFF // ══════════════════════════════════════ function signOffBatch(batchNumber, vesselId, note, password) { // Verify password const user = appUsers.find(u => u.username === currentUser?.username && u.password === hashPw(password)); if (!user) { showToast('❌ Incorrect password — sign-off rejected'); return false; } // Mark all entries for this batch as locked vesselData[vesselId].vData().forEach(e => { if (e.batch === batchNumber) e.locked = true; }); // Record sign-off if (!vesselData[vesselId].signoffs) vesselData[vesselId].signoffs = []; vesselData[vesselId].signoffs.push({ batch: batchNumber, signedBy: currentUser.username, signedAt: new Date().toISOString(), note: note || '' }); saveAllVessels(); addAuditEntry('signoff', 'batch', batchNumber, 'status', 'active', 'signed_off', batchNumber, vesselId); showToast(`✅ Batch ${batchNumber} signed off by ${currentUser.fullName}`); updateAll(); return true; } // ══════════════════════════════════════ // STORAGE STATUS // ══════════════════════════════════════ function showStorageStatus() { const el = document.getElementById('storage-status'); if (!el) return; const v100 = vesselData.v100?.data?.length || 0; const v1kl = vesselData.v1kl?.data?.length || 0; const mode = DB ? '🟢 Supabase' : '💾 Local'; el.textContent = `${mode} · ${v100+v1kl} rows (100L: ${v100} · 1KL: ${v1kl})`; } // ══════════════════════════════════════ function saveStorage() { // Called after logData — entry is already in vData() // The actual Supabase save happens in logData/updateEntry directly // This is kept as a no-op to avoid breaking existing calls } function showStorageStatus() { const el = document.getElementById('storage-status'); if (!el) return; const v100 = vesselData.v100?.data?.length || 0; const v1kl = vesselData.v1kl?.data?.length || 0; const realtimeOn = !!_realtimeChannel; el.textContent = `${mode} · ${v100+v1kl} rows (100L: ${v100} · 1KL: ${v1kl})`; el.textContent = `${mode} · ${v100+v1kl} rows (100L: ${v100} · 1KL: ${v1kl})`; } // ══════════════════════════════════════ // SUPABASE DATA SYNC // ══════════════════════════════════════ async function loadFromDB() { if (!DB) return; try { // Test connection with correct Supabase v2 syntax const { error: pingErr } = await DB.from('pl_entries').select('id').limit(1); if (pingErr) { console.warn('Supabase ping failed:', pingErr.message, pingErr.code); if (pingErr.code === '42501' || pingErr.message?.includes('permission') || pingErr.message?.includes('policy')) { showToast('⚠️ Database permission error — check RLS policies in Supabase'); } else { showToast('⚠️ Cannot reach Supabase — using local data'); } return; } // Load vessel configs (table may not exist yet — skip gracefully) try { const { data: vessels } = await DB.from('pl_vessels').select('*'); if (vessels) { vessels.forEach(v => { if (!VESSELS[v.id]) return; Object.assign(VESSELS[v.id], { name: v.name, workingVol: +v.working_vol, maxVol: +v.max_vol, maxRPM: +v.max_rpm, maxAir: +v.max_air, maxBP: +v.max_bp, strain: v.strain||'', t2WCW: +v.t2_wcw, alarmDO: +v.alarm_do, alarmDOWarn: +v.alarm_do_warn, alarmPHLow: +v.alarm_ph_low, alarmPHHigh: +v.alarm_ph_high, alarmTempLow: +v.alarm_temp_low, alarmTempHigh: +v.alarm_temp_high, alarmVessel: +v.alarm_vessel, t1WarnPct: +v.t1_warn_pct }); }); } } catch(e) { console.warn('pl_vessels table not available:', e.message); } // Load entries let entryCount = 0; try { const { data: entries, error: entryErr } = await DB.from('pl_entries').select('*').order('hour'); if (entryErr) throw entryErr; if (entries && entries.length > 0) { vesselData.v100.data = []; vesselData.v1kl.data = []; entries.forEach(e => { const vid = e.vessel_id; if (!vesselData[vid]) return; vesselData[vid].data.push({ id: e.id, _dbId: e.id, batch: e.batch_number, strain: e.strain||'', time: e.time_logged ? e.time_logged.slice(0,16).replace('T',' ') : '', hour: +e.hour, ph: e.ph, doVal: e.do_val, temp: e.temp, rpm: +e.rpm||0, air: +e.air||0, bp: e.bp, glycerol: +e.glycerol||0, methanol: +e.methanol||0, ammonia: +e.ammonia||0, nh3: e.nh3||25, wcw: e.wcw, od: e.od, titre: e.titre, cumFeed: e.cum_feed, initVol: e.init_vol, maxVol: e.max_vol, batchVol: e.batch_vol, pctFull: e.pct_full, operator: e.operator||'', notes: e.notes||'', t1: e.t1, t2: e.t2, newT1: e.new_t1, newT2: e.new_t2, phase: e.phase, locked: e.locked||false, createdBy: e.created_by }); }); entryCount = entries.length; } } catch(e) { console.warn('pl_entries load failed:', e.message); } // Load alarms try { const { data: alarms } = await DB.from('pl_alarms').select('*').order('created_at'); if (alarms) { vesselData.v100.alarms = []; vesselData.v1kl.alarms = []; alarms.forEach(a => { if (vesselData[a.vessel_id]) { vesselData[a.vessel_id].alarms.push({ type:a.type, msg:a.msg, time:a.time_logged, batch:a.batch_number }); } }); } } catch(e) { console.warn('pl_alarms load failed:', e.message); } // Refresh UI after loading updateAll(); renderSampleTable(); showStorageStatus(); showToast('✅ Connected to Supabase' + (entryCount > 0 ? ` · ${entryCount} entries loaded` : ' · No entries yet')); } catch(e) { console.error('loadFromDB error:', e); showToast('⚠️ Supabase error: ' + (e.message || 'unknown') + ' — using local data'); } } let _realtimeChannel = null; function subscribeRealtime() { if (!DB) return; if (_realtimeChannel) { DB.removeChannel(_realtimeChannel); _realtimeChannel = null; } _realtimeChannel = DB.channel("pichialog-realtime") .on("postgres_changes", { event: "INSERT", schema: "public", table: "pl_entries" }, (payload) => { const e = payload.new; const vid = e.vessel_id; if (!vesselData[vid]) return; if (vesselData[vid].data.find(d => d._dbId === e.id)) return; vesselData[vid].data.push({ id: e.id, _dbId: e.id, batch: e.batch_number, strain: e.strain||"", time: e.time_logged ? e.time_logged.slice(0,16).replace("T"," ") : "", hour: +e.hour, ph: e.ph, doVal: e.do_val, temp: e.temp, rpm: +e.rpm||0, air: +e.air||0, bp: e.bp, glycerol: +e.glycerol||0, methanol: +e.methanol||0, ammonia: +e.ammonia||0, nh3: e.nh3||25, wcw: e.wcw, od: e.od, titre: e.titre, cumFeed: e.cum_feed, initVol: e.init_vol, maxVol: e.max_vol, batchVol: e.batch_vol, pctFull: e.pct_full, operator: e.operator||"", notes: e.notes||"", t1: e.t1, t2: e.t2, newT1: e.new_t1, newT2: e.new_t2, phase: e.phase, locked: e.locked||false, createdBy: e.created_by }); vesselData[vid].data.sort((a, b) => a.hour - b.hour); saveVesselStorage(vid); if (vid === activeVessel) { updateAll(); renderSampleTable(); } showToast("New entry by " + (e.created_by||"another user") + " (Hour " + e.hour + ")"); }) .on("postgres_changes", { event: "UPDATE", schema: "public", table: "pl_entries" }, (payload) => { const e = payload.new; const vid = e.vessel_id; if (!vesselData[vid]) return; const idx = vesselData[vid].data.findIndex(d => d._dbId === e.id); if (idx === -1) return; Object.assign(vesselData[vid].data[idx], { ph: e.ph, doVal: e.do_val, temp: e.temp, rpm: +e.rpm||0, air: +e.air||0, bp: e.bp, glycerol: +e.glycerol||0, methanol: +e.methanol||0, ammonia: +e.ammonia||0, wcw: e.wcw, od: e.od, titre: e.titre, operator: e.operator||"", notes: e.notes||"", t1: e.t1, t2: e.t2, phase: e.phase }); saveVesselStorage(vid); if (vid === activeVessel) { updateAll(); renderSampleTable(); } showToast("Entry updated by " + (e.updated_by||"another user") + " (Hour " + e.hour + ")"); }) .on("postgres_changes", { event: "DELETE", schema: "public", table: "pl_entries" }, (payload) => { const id = payload.old && payload.old.id; if (!id) return; ["v100","v1kl"].forEach(vid => { const before = vesselData[vid].data.length; vesselData[vid].data = vesselData[vid].data.filter(d => d._dbId !== id); if (vesselData[vid].data.length < before) { saveVesselStorage(vid); if (vid === activeVessel) { updateAll(); renderSampleTable(); } showToast("Entry deleted by another user"); } }); }) .subscribe((status) => { console.log("PichiaLog Realtime:", status); if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") { setTimeout(subscribeRealtime, 5000); } }); } async function saveEntryToDB(entry, vesselId) { if (!DB) return; try { // Ensure batch exists await DB.from('pl_batches').upsert( { batch_number: entry.batch, vessel_id: vesselId, strain: entry.strain||'', created_by: currentUser?.username||'system' }, { onConflict: 'batch_number,vessel_id', ignoreDuplicates: true } ); // Save entry const row = { vessel_id: vesselId, batch_number: entry.batch, hour: entry.hour, time_logged: entry.time||null, ph: entry.ph, do_val: entry.doVal, temp: entry.temp, rpm: entry.rpm, air: entry.air, bp: entry.bp, glycerol: entry.glycerol, methanol: entry.methanol, ammonia: entry.ammonia, nh3: entry.nh3, wcw: entry.wcw, od: entry.od, titre: entry.titre, cum_feed: entry.cumFeed, init_vol: entry.initVol, max_vol: entry.maxVol, batch_vol: entry.batchVol, pct_full: entry.pctFull, operator: entry.operator, notes: entry.notes, strain: entry.strain, t1: entry.t1, t2: entry.t2, new_t1: entry.newT1, new_t2: entry.newT2, phase: entry.phase, created_by: currentUser?.username||'system' }; const { data, error } = await DB.from('pl_entries') .upsert(row, { onConflict: 'batch_number,vessel_id,hour' }) .select().single(); if (error) { console.error('saveEntryToDB error:', error); return; } entry._dbId = data.id; // Audit await DB.from('pl_audit').insert({ action: 'create', table_name: 'pl_entries', record_id: data.id, field_name: 'hour', new_value: String(entry.hour), performed_by: currentUser?.username||'system', batch_number: entry.batch, vessel_id: vesselId }); } catch(e) { console.error('saveEntryToDB exception:', e); } } async function updateEntryInDB(entry, vesselId) { if (!DB || !entry._dbId) return; try { await DB.from('pl_entries').update({ ph: entry.ph, do_val: entry.doVal, temp: entry.temp, rpm: entry.rpm, air: entry.air, bp: entry.bp, glycerol: entry.glycerol, methanol: entry.methanol, ammonia: entry.ammonia, wcw: entry.wcw, od: entry.od, titre: entry.titre, notes: entry.notes, operator: entry.operator, t1: entry.t1, t2: entry.t2, phase: entry.phase, updated_by: currentUser?.username||'system', updated_at: new Date().toISOString() }).eq('id', entry._dbId); await DB.from('pl_audit').insert({ action: 'update', table_name: 'pl_entries', record_id: entry._dbId, field_name: 'edit', performed_by: currentUser?.username||'system', batch_number: entry.batch, vessel_id: vesselId }); } catch(e) { console.error('updateEntryInDB error:', e); } } // ══════════════════════════════════════ // NAVIGATION // ══════════════════════════════════════ let _panelMoved = {}; function goPage(id, el) { document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); const page = document.getElementById('page-' + id); if (page) page.classList.add('active'); document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.vnav-item').forEach(t => t.classList.remove('active')); if (el) el.classList.add('active'); document.querySelectorAll('.nav-tab').forEach(function(t) { const oc = t.getAttribute('onclick') || ''; if (oc.indexOf("'" + id + "'") > -1) t.classList.add('active'); }); const vnav = document.getElementById('vnav-' + id); if (vnav) vnav.classList.add('active'); if (id === 'graphs') { updateCharts(); const ge=document.getElementById('graphs-empty'); if(ge) ge.style.display=vDataView().length?'none':'block'; } if (id === 'compare') { setTimeout(() => buildCompareUI('cmp-container'), 80); } if (id === 'growth') { fillMuBatch(); renderMuGrowthChart(); } if (id === 'our') { fillOURBatch(); } if (id === 'yield') { renderYield(); } if (id === 'summary') { fillSummaryBatch(); } if (id === 'table') { updateTable(); renderSampleTable(); } if (id === 'settings'){ renderSettingsPage(); } if (id === 'triggers'){ renderTriggersPage(); } if (id === 'handover'){ renderHandover(); } if (id === 'media') { calcMedia(); renderFeedPrepsIfNeeded(); const dt = document.getElementById('med-datetime'); if (dt && !dt.value) { const n=new Date(); n.setSeconds(0,0); dt.value=n.toISOString().slice(0,16); } } const titles = { entry:'Data Entry', media:'Media Prep', table:'Data Table', graphs:'Graphs', our:'OUR', compare:'Batch Compare', yield:'Yield', summary:'Batch Summary', growth:'Growth Rate', import:'Import Data', analysis:'Batch Analysis' }; const entryTitle = document.getElementById('entry-page-title'); if (entryTitle && titles[id]) entryTitle.textContent = titles[id]; } // ══════════════════════════════════════ // VESSEL SETTINGS PANEL // ══════════════════════════════════════ function renderTriggersPage() { const container = document.getElementById('triggers-page-content'); if (!container) return; const renderVesselTriggers = (vid) => { const p = VESSELS[vid]; const data = vesselData[vid].data; const hasT1 = data.some(d => d.t1); const hasT2 = data.some(d => d.t2); const t1Hour = data.find(d => d.newT1)?.hour; const t2Hour = data.find(d => d.newT2)?.hour; const lastEntry = data[data.length - 1]; const curRPM = lastEntry?.rpm || 0; const curAir = lastEntry?.air || 0; const curWCW = lastEntry?.wcw || 0; const rpmPct = Math.min((curRPM / p.maxRPM) * 100, 100).toFixed(0); const airPct = Math.min((curAir / p.maxAir) * 100, 100).toFixed(0); const wcwPct = Math.min((curWCW / p.t2WCW) * 100, 100).toFixed(0); return `
${p.emoji}
${p.name}
TRIGGER CONFIGURATION & LIVE STATUS
T1 ${hasT1 ? '🚨 FIRED Hr ' + t1Hour : '⏳ PENDING'} T2 ${hasT2 ? '✅ FIRED Hr ' + t2Hour : '⏳ PENDING'}
🚨
Trigger 1 — Glycerol Fed-Batch
Fires when RPM AND Airflow both reach maximum
RPM${curRPM} / ${p.maxRPM} (${rpmPct}%)
Airflow${curAir} / ${p.maxAir} LPM (${airPct}%)
🔬
Trigger 2 — Ethanol Induction
Fires when WCW reaches threshold
WCW${curWCW || '—'} / ${p.t2WCW} g/L (${curWCW ? wcwPct + '%' : '—'})
── ALARM THRESHOLDS
`; }; container.innerHTML = `
Process Triggers
Live status + threshold configuration · Admin only
${renderVesselTriggers('v100')} ${renderVesselTriggers('v1kl')}
`; } function saveTriggerSettings(vid) { const get = id => document.getElementById(`trig-${vid}-${id}`); const num = id => { const v = parseFloat(get(id)?.value); return isNaN(v) ? null : v; }; const p = VESSELS[vid]; p.maxRPM = num('maxRPM') ?? p.maxRPM; p.maxAir = num('maxAir') ?? p.maxAir; p.t1WarnPct = num('t1WarnPct') ?? p.t1WarnPct; p.t2WCW = num('t2WCW') ?? p.t2WCW; p.alarmDO = num('alarmDO') ?? p.alarmDO; p.alarmDOWarn = num('alarmDOWarn') ?? p.alarmDOWarn; p.alarmPHLow = num('alarmPHLow') ?? p.alarmPHLow; p.alarmPHHigh = num('alarmPHHigh') ?? p.alarmPHHigh; p.alarmTempLow = num('alarmTempLow') ?? p.alarmTempLow; p.alarmTempHigh = num('alarmTempHigh')?? p.alarmTempHigh; saveVesselConfig(); if (vid === activeVessel) applyVesselProfile(); showToast(`✅ ${p.emoji} ${p.shortName} trigger settings saved`); renderTriggersPage(); // re-render to reflect new values } function renderSettingsPage() { const container = document.getElementById('settings-page-content'); if (!container) return; container.innerHTML = buildSettingsHTML(); } function renderSettingsPanel() { const panel = document.getElementById('panel-settings'); if (!panel) return; panel.innerHTML = buildSettingsHTML(); } function buildSettingsHTML() { const makeSection = (vid) => { const p = VESSELS[vid]; const isActive = vid === activeVessel; return `
${p.emoji}
${p.name}
${isActive ? '● ACTIVE VESSEL' : 'Inactive'}
${isActive ? `ACTIVE` : ''}
── VESSEL PARAMETERS
── PROCESS TRIGGERS
WCW threshold to switch → INDUCTION phase
── ALARM THRESHOLDS
`; }; return `
⚙️
Vessel Settings
Configure parameters, triggers and alarm thresholds for each vessel
${makeSection('v100')} ${makeSection('v1kl')}
`; } function applyVesselSettings(vid) { const get = id => document.getElementById(`cfg-${vid}-${id}`); const str = id => get(id)?.value?.trim() || ''; const num = id => { const v = parseFloat(get(id)?.value); return isNaN(v) ? null : v; }; const p = VESSELS[vid]; const name = str('name'); if (name) p.name = name; p.strain = str('strain'); p.workingVol = num('workingVol') ?? p.workingVol; p.maxVol = num('maxVol') ?? p.maxVol; p.maxRPM = num('maxRPM') ?? p.maxRPM; p.maxAir = num('maxAir') ?? p.maxAir; p.maxBP = num('maxBP') ?? p.maxBP; p.t1WarnPct = num('t1WarnPct') ?? p.t1WarnPct; p.t2WCW = num('t2WCW') ?? p.t2WCW; p.alarmVessel = num('alarmVessel') ?? p.alarmVessel; p.alarmDO = num('alarmDO') ?? p.alarmDO; p.alarmDOWarn = num('alarmDOWarn') ?? p.alarmDOWarn; p.alarmPHLow = num('alarmPHLow') ?? p.alarmPHLow; p.alarmPHHigh = num('alarmPHHigh') ?? p.alarmPHHigh; p.alarmTempLow = num('alarmTempLow') ?? p.alarmTempLow; p.alarmTempHigh = num('alarmTempHigh') ?? p.alarmTempHigh; saveVesselConfig(); if (vid === activeVessel) { applyVesselProfile(); updateVesselUI(); } showToast(`✅ ${p.emoji} ${p.name} settings saved`); } function showPanel(id) { document.querySelectorAll('.snav').forEach(s => s.classList.remove('active')); const snav = document.getElementById('snav-' + id); if (snav) snav.classList.add('active'); ['entry','media','table','graphs','our','compare','yield','summary','growth','settings','import','analysis'].forEach(pid => { const el = document.getElementById('panel-' + pid); if (el) el.style.display = 'none'; }); const target = document.getElementById('panel-' + id); if (!target) return; target.style.display = 'block'; if (id !== 'entry' && !_panelMoved[id]) { const srcPage = document.getElementById('page-' + id); if (srcPage) { const container = srcPage.querySelector('.container'); if (container) { target.appendChild(container); _panelMoved[id] = true; } } } if (id === 'graphs') { updateCharts(); const ge=document.getElementById('graphs-empty'); if(ge) ge.style.display=vDataView().length?'none':'block'; } if (id === 'compare') { setTimeout(() => buildCompareUI('cmp-panel-container'), 80); } if (id === 'growth') { fillMuBatch(); renderMuGrowthChart(); } if (id === 'our') { fillOURBatch(); } if (id === 'yield') { renderYield(); } if (id === 'summary') { fillSummaryBatch(); } if (id === 'table') { updateTable(); renderSampleTable(); } if (id === 'settings'){ renderSettingsPanel(); } if (id === 'import') { renderImportPanel(); } if (id === 'analysis'){ renderAnalysisPanel(); } if (id === 'media') { calcMedia(); renderFeedPrepsIfNeeded(); const dt = document.getElementById('med-datetime'); if (dt && !dt.value) { const n=new Date(); n.setSeconds(0,0); dt.value=n.toISOString().slice(0,16); } } // Update goPage title const titles = { entry:'Data Entry', media:'Media Prep', table:'Data Table', graphs:'Graphs', our:'OUR', compare:'Batch Compare', yield:'Yield', summary:'Batch Summary', growth:'Growth Rate', import:'Import Data', analysis:'Batch Analysis' }; const entryTitle = document.getElementById('entry-page-title'); if (entryTitle && titles[id]) entryTitle.textContent = titles[id]; } function renderAnalysisPanel() { const el = document.getElementById('panel-analysis'); if (!el) return; // Build tab-style header with links to yield, OUR, growth sub-panels if (!el.dataset.init) { el.dataset.init = '1'; el.innerHTML = `
`; // Move content from old pages into tabs ['yield','our','growth'].forEach(pid => { const srcPage = document.getElementById('page-' + pid); const dest = document.getElementById('atab-content-' + pid); if (srcPage && dest) { const container = srcPage.querySelector('.container'); if (container) dest.appendChild(container); } }); } switchAnalysisTab('yield'); } function switchAnalysisTab(tab) { ['yield','our','growth'].forEach(t => { const btn = document.getElementById('atab-' + t); const content = document.getElementById('atab-content-' + t); const active = t === tab; if (btn) { btn.style.borderBottomColor = active ? 'var(--accent)' : 'transparent'; btn.style.color = active ? 'var(--accent)' : 'var(--muted)'; } if (content) content.style.display = active ? 'block' : 'none'; }); if (tab === 'growth') { fillMuBatch(); renderMuGrowthChart(); } if (tab === 'our') { fillOURBatch(); } if (tab === 'yield') { renderYield(); } } // ══════════════════════════════════════ // MEDIA PREP MODALS // ══════════════════════════════════════ function openMediaModal(type) { const overlay = document.getElementById('media-modal-overlay'); const title = document.getElementById('media-modal-title'); const body = document.getElementById('media-modal-body'); overlay.style.display = 'flex'; if (type === 'ferm') { title.textContent = '🧫 Fermentation Media Calculator'; body.innerHTML = `
Adjust with H₃PO₄ or NaOH
BSM RECIPE — 10 L
COMPONENTSTOCKAMOUNTUNITNOTES
PTM1 TRACE SALTS (12 mL/L)
SALTPER L PTM1YOUR BATCHUNIT
`; calcMediaModal(); } else if (type === 'glycerol') { title.textContent = '🟢 Glycerol Feed Preparation'; body.innerHTML = `
50% w/v glycerol solution — standard fed-batch carbon source for Pichia pastoris glycerol feeding phase.
`; calcGlycerolModal(); } else if (type === 'ethanol') { title.textContent = '🔥 Ethanol Feed Preparation'; body.innerHTML = `
⚠️ FLAMMABLE — Handle in fume hood. Keep away from ignition sources. Wear appropriate PPE.
`; calcEthanolModal(); } else if (type === 'ammonia') { title.textContent = '💧 Ammonia Feed Calculator'; body.innerHTML = `
25% NH₃ solution used for pH control during fermentation. Calculate volume needed based on target pH correction.
Amount to have on standby
`; calcAmmoniaModal(); } } function closeMediaModal() { document.getElementById('media-modal-overlay').style.display = 'none'; } function calcMediaModal() { const vol = parseFloat(document.getElementById('mm-vol')?.value) || 10; const type = document.getElementById('mm-type')?.value || 'bsm'; const recipe = MEDIA_RECIPES[type]; if (!recipe) return; document.getElementById('mm-recipe-title').textContent = recipe.name.toUpperCase() + ' RECIPE — ' + vol + ' L'; document.getElementById('mm-tbody').innerHTML = recipe.components.map(comp => { const amt = comp.perL != null ? (comp.perL * vol).toFixed(comp.unit==='mL'?1:2) : '—'; const display = comp.perL != null ? amt : (comp.unit==='L' ? vol.toFixed(1) : '— (to pH target)'); return ` ${comp.name} ${comp.stock} ${display} ${comp.unit} ${comp.note} `; }).join(''); const ptm1Total = (12 * vol).toFixed(1); document.getElementById('mm-ptm1-tbody').innerHTML = PTM1.map(s => { const forBatch = (s.perL * parseFloat(ptm1Total) / 1000).toFixed(3); return ` ${s.name} ${s.perL} ${s.unit} ${forBatch} ${s.unit.replace('/L stock','')} `; }).join(''); } function calcGlycerolModal() { const vol = parseFloat(document.getElementById('gly-vol')?.value) || 500; const conc = parseFloat(document.getElementById('gly-conc')?.value) || 50; const tes = parseFloat(document.getElementById('gly-tes')?.value) || 12; const glycerolG = (conc / 100) * vol; const glycerolMl = (glycerolG / 1.26).toFixed(1); const tesMl = ((tes / 1000) * vol).toFixed(1); const waterMl = (vol - parseFloat(glycerolMl) - parseFloat(tesMl)).toFixed(1); document.getElementById('gly-results').innerHTML = `
GLYCEROL
${glycerolMl} mL
${glycerolG.toFixed(1)} g (50% w/v stock)
TES ANTIFOAM
${tesMl} mL
${tes} mL per litre
WATER (MAKE UP TO)
${waterMl} mL
Total volume: ${vol} mL
`; document.getElementById('gly-steps').innerHTML = `
PREPARATION STEPS
  1. Weigh ${glycerolG.toFixed(1)} g of 100% glycerol (or measure ${glycerolMl} mL of 50% w/v stock)
  2. Add ${tesMl} mL TES antifoam
  3. Make up to ${vol} mL with dH₂O
  4. Mix thoroughly and autoclave at 121°C for 20 min
  5. Store at room temperature until required
`; } let _ethPrepCount = 1; let _glyPrepCount = 1; function addGlyPreparation() { _glyPrepCount++; const vol = parseFloat(document.getElementById('gly-vol')?.value) || 500; const conc = parseFloat(document.getElementById('gly-conc')?.value) || 50; const tes = parseFloat(document.getElementById('gly-tes')?.value) || 12; const glycerolMl = ((conc/100*vol)/1.26).toFixed(1); const tesMl = ((tes/1000)*vol).toFixed(1); const waterMl = (vol - parseFloat(glycerolMl) - parseFloat(tesMl)).toFixed(1); const div = document.createElement('div'); div.style.cssText = 'background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;padding:18px;margin-top:12px;'; div.innerHTML = `
PREP #${_glyPrepCount} — ${vol} mL @ ${conc}% w/v
🌿 Glycerol: ${glycerolMl} mL 🧴 TES: ${tesMl} mL 💧 Water: ${waterMl} mL
`; document.getElementById('gly-extra-preps').appendChild(div); } function calcEthanolModal() { const vol = parseFloat(document.getElementById('eth-vol')?.value) || 500; const conc = parseFloat(document.getElementById('eth-conc')?.value) || 60; const tes = parseFloat(document.getElementById('eth-tes')?.value) || 12; const ethanolMl = (conc / 100 * vol).toFixed(1); const tesMl = ((tes / 1000) * vol).toFixed(1); const waterMl = (vol - parseFloat(ethanolMl) - parseFloat(tesMl)).toFixed(1); document.getElementById('eth-results').innerHTML = `
ETHANOL (100%)
${ethanolMl} mL
${conc}% v/v stock
TES ANTIFOAM
${tesMl} mL
${tes} mL per litre
WATER (MAKE UP TO)
${waterMl} mL
Total volume: ${vol} mL
`; document.getElementById('eth-steps').innerHTML = `
PREPARATION STEPS
  1. ⚠️ Work in fume hood — wear gloves and eye protection
  2. Measure ${ethanolMl} mL of 100% ethanol into a glass bottle
  3. Add ${tesMl} mL TES antifoam
  4. Make up to ${vol} mL with dH₂O — mix gently
  5. Do NOT autoclave — filter sterilise (0.2 µm) or use directly
  6. Label: FLAMMABLE — store in flammables cabinet
`; } function addEthPreparation() { _ethPrepCount++; const vol = parseFloat(document.getElementById('eth-vol')?.value) || 500; const conc = parseFloat(document.getElementById('eth-conc')?.value) || 60; const tes = parseFloat(document.getElementById('eth-tes')?.value) || 12; const ethanolMl = (conc/100*vol).toFixed(1); const tesMl = ((tes/1000)*vol).toFixed(1); const waterMl = (vol - parseFloat(ethanolMl) - parseFloat(tesMl)).toFixed(1); const div = document.createElement('div'); div.style.cssText = 'background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:18px;margin-top:12px;'; div.innerHTML = `
⚠️ PREP #${_ethPrepCount} — ${vol} mL @ ${conc}% v/v
🔥 Ethanol: ${ethanolMl} mL 🧴 TES: ${tesMl} mL 💧 Water: ${waterMl} mL
`; document.getElementById('eth-extra-preps').appendChild(div); } function calcAmmoniaModal() { const vol = parseFloat(document.getElementById('nh3-vol')?.value) || 60; const phCur = parseFloat(document.getElementById('nh3-ph-cur')?.value) || 4.8; const phTgt = parseFloat(document.getElementById('nh3-ph-tgt')?.value) || 5.0; const conc = parseFloat(document.getElementById('nh3-conc')?.value) || 25; const prepVol= parseFloat(document.getElementById('nh3-prep')?.value) || 500; // Rough estimate: ~1 mL of 25% NH3 raises pH ~0.1 unit in 10L const deltaPh = Math.max(0, phTgt - phCur); const estMl = (deltaPh * vol * 1.0).toFixed(1); const density = 0.91; // g/mL for 25% NH3 const estG = (parseFloat(estMl) * density).toFixed(1); document.getElementById('nh3-results').innerHTML = `
ESTIMATED DOSE
${estMl} mL
≈ ${estG} g of 25% NH₃
pH CORRECTION
${phCur} → ${phTgt}
ΔpH = +${deltaPh.toFixed(2)}
STANDBY PREP
${prepVol} mL
25% NH₃ — ready to add
⚠️ Caution: Add NH₃ slowly with agitation. Monitor pH in real time. Over-correction is hard to reverse. Estimated dose is approximate — actual consumption depends on buffering capacity of your media.
`; } // ══════════════════════════════════════ // LIVE ENTRY TABLE (split layout) // ══════════════════════════════════════ function updateEntryLiveTable() { const tbody = document.getElementById('entry-live-tbody'); const count = document.getElementById('entry-tbl-count'); if (!tbody) return; const rows = vDataView(); if (!rows.length) { tbody.innerHTML = 'No data yet — start a run above, fill the green rows and click ⚡ LOG DATA POINT'; return; } // Sort ascending, fix phase persistence const sorted = [...rows].sort((a, b) => a.hour - b.hour); let currentPhase = 'BATCH'; const phaseFixed = sorted.map(d => { if (d.phase === 'INDUCTION') currentPhase = 'INDUCTION'; else if (d.phase === 'GLYCEROL_FB' && currentPhase !== 'INDUCTION') currentPhase = 'GLYCEROL_FB'; return { ...d, _displayPhase: currentPhase }; }); const phaseBadge = p => { if (p === 'INDUCTION') return 'IND'; if (p === 'GLYCEROL_FB') return 'GLY'; return 'BAT'; }; const BASE = 'padding:5px 8px;font-size:0.8rem;'; const val = (v, extra='') => { const empty = (v == null || v === ''); return `${empty ? '—' : v}`; }; const hi = (v, bad, extra='') => { const empty = v == null || v === ''; const color = empty ? '#cbd5e1' : bad ? 'var(--danger)' : 'var(--text)'; return `${empty ? '—' : v}`; }; const SEP = 'border-bottom:1px dashed #e2e8f0;'; const BOT = 'border-bottom:2px solid var(--border);'; const EVEN = 'background:#fafafa;'; // Render ascending (row 0 = earliest hour at top) tbody.innerHTML = phaseFixed.map((d, i) => { const editing = d.id === window._editingEntryId; const bg1 = editing ? 'background:#fef3c7;' : i===phaseFixed.length-1 ? 'background:#f0fdf4;' : i%2===0 ? '' : EVEN; const bg2 = editing ? 'background:#fef9e7;' : i===phaseFixed.length-1 ? 'background:#f0fdf4;' : i%2===0 ? '' : EVEN; const row1 = ` ${d.hour}h${d.newT1?'
🚨T1':''}${d.newT2?'
🔬T2':''} ${(d.time||'').slice(0,16).replace('T',' ')} ${hi(d.ph, d.ph!=null&&(d.ph<4.5||d.ph>5.5), SEP)} ${hi(d.doVal,d.doVal!=null&&d.doVal<20, SEP)} ${val(d.temp, SEP)} ${val(d.rpm, SEP+'font-weight:600;')} ${val(d.air, SEP+'font-weight:600;')} ${val(d.bp, SEP)} `; const row2 = ` ${val(d.glycerol||null, BOT)} ${val(d.methanol||null, BOT)} ${val(d.ammonia||null, BOT)} ${d.wcw??'—'} ${val(d.od, BOT)} ${d.titre??'—'} ${d.operator||'—'} ${phaseBadge(d._displayPhase)}${d.notes||''} `; return row1 + row2; }).join(''); } // ══════════════════════════════════════ // RUN SETUP // ══════════════════════════════════════ let _activeRun = { batch: '', strain: '', initVol: null, maxVol: null, operator: '' }; function updateRunLabel() { const batch = document.getElementById('f-batch')?.value?.trim(); const hint = document.getElementById('run-setup-hint'); if (hint) hint.textContent = batch ? 'Ready to start — click ▶' : 'Fill batch details then click Start'; } function startRun() { const batch = document.getElementById('f-batch')?.value?.trim(); if (!batch) { showToast('⚠️ Enter a batch number first'); return; } _activeRun = { batch: batch, strain: document.getElementById('f-strain')?.value?.trim() || '', initVol: parseFloat(document.getElementById('f-initvol')?.value) || null, maxVol: parseFloat(document.getElementById('f-maxvol')?.value) || null, operator: document.getElementById('f-op')?.value?.trim() || '', }; // Update run label const label = document.getElementById('run-label'); const meta = document.getElementById('run-meta'); if (label) label.textContent = batch + (_activeRun.strain ? ' · ' + _activeRun.strain : ''); if (meta) meta.textContent = [ _activeRun.initVol ? 'Init: ' + _activeRun.initVol + 'L' : '', _activeRun.maxVol ? 'Max: ' + _activeRun.maxVol + 'L' : '', _activeRun.operator ? _activeRun.operator : '', ].filter(Boolean).join(' · '); // Collapse setup, show active bar const form = document.getElementById('run-setup-form'); const active = document.getElementById('run-active'); if (form) form.style.display = 'none'; if (active) { active.style.display = 'flex'; } // Auto-advance time advanceTime(); showToast('✅ Run started — ' + batch); } function expandRunSetup() { const form = document.getElementById('run-setup-form'); const active = document.getElementById('run-active'); if (form) form.style.display = 'block'; if (active) active.style.display = 'none'; } // ══════════════════════════════════════ // BATCH BROWSER // ══════════════════════════════════════ function updateBatchBrowser() { const sel = document.getElementById('batch-browser-select'); if (!sel) return; const all = vData(); const batches = [...new Set(all.map(d => d.batch).filter(Boolean))].sort(); const current = viewBatch || ''; sel.innerHTML = '' + batches.map(b => '' ).join(''); if (current) sel.value = current; } function setBatchView(batch) { viewBatch = batch || null; // Update navbar select const sel = document.getElementById('batch-browser-select'); if (sel) sel.value = batch || ''; // Show/hide read-only banner on entry panel const banner = document.getElementById('readonly-banner'); const nameEl = document.getElementById('readonly-batch-name'); if (banner) banner.style.display = viewBatch ? 'flex' : 'none'; if (nameEl && viewBatch) nameEl.textContent = viewBatch; // Show/hide data entry input row and log button const inputRow = document.getElementById('entry-input-row'); const btnLog = document.getElementById('btn-log'); const btnUpdate = document.getElementById('btn-update'); const runSetup = document.getElementById('run-setup-bar'); const gauges = document.querySelector('#panel-entry .gauge-strip'); if (inputRow) inputRow.style.display = viewBatch ? 'none' : ''; if (btnLog) btnLog.style.display = viewBatch ? 'none' : ''; if (btnUpdate && viewBatch) btnUpdate.style.display = 'none'; if (runSetup) runSetup.style.display = viewBatch ? 'none' : ''; // Pre-select batch filter in data table const tblFilter = document.getElementById('tbl-batch-filter'); if (tblFilter && viewBatch) tblFilter.value = viewBatch; updateAll(); initAllCharts(); updateCharts(); renderSampleTable(); } // ══════════════════════════════════════ // MAIN INIT // ══════════════════════════════════════ window.onload = async () => { const now = new Date(); now.setSeconds(0,0); now.setMinutes(Math.round(now.getMinutes()/30)*30); document.getElementById('f-time').value = now.toISOString().slice(0,16); if (document.getElementById('f-hour').value === '') document.getElementById('f-hour').value = '0'; initSupabase(); // Restore _activeRun from last entry after storage loads setTimeout(() => { const last = vData().length ? vData()[vData().length-1] : null; if (last && last.batch) { _activeRun = { batch: last.batch, strain: last.strain||'', initVol: last.initVol, maxVol: last.maxVol, operator: last.operator||'' }; const el = document.getElementById('f-batch'); if (el) el.value = last.batch; const es = document.getElementById('f-strain'); if (es) es.value = last.strain||''; const ei = document.getElementById('f-initvol'); if (ei && last.initVol) ei.value = last.initVol; const em = document.getElementById('f-maxvol'); if (em && last.maxVol) em.value = last.maxVol; const eo = document.getElementById('f-op'); if (eo) eo.value = last.operator||''; if (_activeRun.batch) { const label = document.getElementById('run-label'); if (label) label.textContent = _activeRun.batch + (_activeRun.strain ? ' · '+_activeRun.strain : ''); const meta = document.getElementById('run-meta'); if (meta) meta.textContent = [_activeRun.initVol?'Init: '+_activeRun.initVol+'L':'', _activeRun.maxVol?'Max: '+_activeRun.maxVol+'L':'', _activeRun.operator].filter(Boolean).join(' · '); const form = document.getElementById('run-setup-form'); if (form) form.style.display = 'none'; const active = document.getElementById('run-active'); if (active) active.style.display = 'flex'; } } }, 600); // connect to Supabase if available await loadUsers(); // load local users await loadAudit(); // load audit trail await initStorage(); // load IDB data as fallback }; // ══════════════════════════════════════ // EDIT BY HOUR // ══════════════════════════════════════ function editEntry(id) { const entry = vData().find(d => d.id === id); if (!entry) return; _editingEntryId = id; loadEntryIntoForm(entry); const btnLog = document.getElementById('btn-log'); const btnUpdate = document.getElementById('btn-update'); const btnCancel = document.getElementById('btn-cancel-edit'); const banner = document.getElementById('edit-mode-banner'); const label = document.getElementById('edit-hour-label'); if (btnLog) btnLog.style.display = 'none'; if (btnUpdate) btnUpdate.style.display = ''; if (btnCancel) btnCancel.style.display = ''; if (banner) banner.style.display = 'block'; if (label) label.textContent = entry.hour + 'h'; updateEntryLiveTable(); // Scroll form into view on mobile document.getElementById('f-hour')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } let _editingEntryId = null; function checkHourExists(hourVal) { const hour = parseFloat(hourVal); const batch = (_activeRun && _activeRun.batch) || document.getElementById('f-batch')?.value?.trim(); const status = document.getElementById('h-hour-status'); const btnLog = document.getElementById('btn-log'); const btnUpdate = document.getElementById('btn-update'); const btnCancel = document.getElementById('btn-cancel-edit'); if (isNaN(hour) || !batch) { if (status) status.textContent = ''; return; } const existing = vData().find(d => d.batch === batch && d.hour === hour); if (existing) { _editingEntryId = existing.id; loadEntryIntoForm(existing); if (btnLog) btnLog.style.display = 'none'; if (btnUpdate) btnUpdate.style.display = ''; if (btnCancel) btnCancel.style.display = ''; if (status) { status.textContent = '✏️ Editing'; status.style.color = 'var(--warn)'; } const banner = document.getElementById('edit-mode-banner'); const label = document.getElementById('edit-hour-label'); if (banner) banner.style.display = 'block'; if (label) label.textContent = hour + 'h'; updateEntryLiveTable(); } else { _editingEntryId = null; if (btnLog) btnLog.style.display = ''; if (btnUpdate) btnUpdate.style.display = 'none'; if (btnCancel) btnCancel.style.display = 'none'; if (status) { status.textContent = hour >= 0 ? '✚ New entry' : ''; status.style.color = 'var(--accent2)'; } const banner = document.getElementById('edit-mode-banner'); if (banner) banner.style.display = 'none'; } } function loadEntryIntoForm(d) { const set = (id, val) => { const el = document.getElementById(id); if (el && val != null && val !== '') el.value = val; }; set('f-batch', d.batch); set('f-strain', d.strain); set('f-time', d.time); set('f-hour', d.hour); set('f-initvol', d.initVol); set('f-maxvol', d.maxVol); set('f-ph', d.ph); set('f-do', d.doVal); set('f-temp', d.temp); set('f-rpm', d.rpm); set('f-air', d.air); set('f-bp', d.bp); set('f-gly', d.glycerol); set('f-meoh', d.methanol); set('f-ammonia', d.ammonia); set('f-nh3', d.nh3); set('f-wcw', d.wcw); set('f-od', d.od); set('f-titre', d.titre); set('f-op', d.operator); set('f-notes', d.notes); if (typeof updateGauges === 'function') updateGauges(); } function updateEntry() { if (_editingEntryId === null) { logData(); return; } const v = id => document.getElementById(id)?.value; const n = id => { const val = parseFloat(v(id)); return isNaN(val) ? null : val; }; const idx = vData().findIndex(d => d.id === _editingEntryId); if (idx === -1) { showToast('⚠️ Entry not found'); return; } const e = vData()[idx]; const p = vProfile(); const merge = (formVal, existing) => { if (formVal === '' || formVal == null) return existing; const num = parseFloat(formVal); return isNaN(num) ? (formVal || existing) : num; }; const rpm = parseFloat(v('f-rpm')) || e.rpm || 0; const air = parseFloat(v('f-air')) || e.air || 0; const wcw = n('f-wcw') ?? e.wcw; e.batch = v('f-batch') || e.batch; e.strain = v('f-strain') || e.strain; e.time = v('f-time') || e.time; e.ph = merge(v('f-ph'), e.ph); e.doVal = merge(v('f-do'), e.doVal); e.temp = merge(v('f-temp'), e.temp); e.rpm = rpm; e.air = air; e.bp = merge(v('f-bp'), e.bp); e.glycerol = merge(v('f-gly'), e.glycerol); e.methanol = merge(v('f-meoh'), e.methanol); e.ammonia = merge(v('f-ammonia'), e.ammonia); e.wcw = wcw; e.od = merge(v('f-od'), e.od); e.titre = merge(v('f-titre'), e.titre); e.operator = v('f-op') || e.operator; e.notes = v('f-notes') || e.notes; e.t1 = rpm >= window._MAX_RPM && air >= window._MAX_AIR; e.t2 = !!(wcw && wcw >= p.t2WCW); e.phase = e.t2 ? 'INDUCTION' : e.t1 ? 'GLYCEROL_FB' : 'BATCH'; saveStorage(); updateEntryInDB(e,activeVessel); addAuditEntry('update','entries',e.id,'edit',null,e.hour,e.batch,activeVessel); updateAll(); renderSampleTable(); cancelEdit(); showToast(`✅ Hour ${e.hour} entry updated`); } function cancelEdit() { _editingEntryId = null; const btnLog = document.getElementById('btn-log'); const btnUpdate = document.getElementById('btn-update'); const btnCancel = document.getElementById('btn-cancel-edit'); const status = document.getElementById('h-hour-status'); const banner = document.getElementById('edit-mode-banner'); if (btnLog) btnLog.style.display = ''; if (btnUpdate) btnUpdate.style.display = 'none'; if (btnCancel) btnCancel.style.display = 'none'; if (status) status.textContent = ''; if (banner) banner.style.display = 'none'; updateEntryLiveTable(); }
⚡ Quick Log
Log essential parameters fast
Shift Handover
📋 SHIFT SELECTION
OUTGOING OPERATOR
INCOMING OPERATOR
🧬 CURRENT BATCH STATUS
PHASE
FERM. HOURS
pH
DO %
TEMP °C
RPM
WCW g/L
VOL L
⚡ PHASE & TRIGGER STATUS
📝 SHIFT NOTES — THIS SHIFT
No notes logged this shift yet.
ADD HANDOVER NOTE
🎯 UPCOMING ACTIONS FOR INCOMING SHIFT
✅ SIGN-OFF
Complete the form above to generate the handover summary.