=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 + '%' : '—'})
💾 Save ${p.emoji} ${p.shortName} Trigger Settings
`;
};
container.innerHTML = `
⚡
Process Triggers
Live status + threshold configuration · Admin only
🔄 Refresh
${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 ` : ''}
💾 Save ${p.emoji} ${p.shortName} Settings
`;
};
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 = `
📊 Yield
⚗️ OUR
🧮 Growth Rate
`;
// 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 = `
BSM RECIPE — 10 L
COMPONENT STOCK AMOUNT UNIT NOTES
PTM1 TRACE SALTS (12 mL/L)
SALT PER L PTM1 YOUR BATCH UNIT
🖨 Print
`;
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.
+ Add Another Prep
🖨 Print
`;
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.
+ Add Another Prep
🖨 Print
`;
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.
🖨 Print
`;
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
Weigh ${glycerolG.toFixed(1)} g of 100% glycerol (or measure ${glycerolMl} mL of 50% w/v stock)
Add ${tesMl} mL TES antifoam
Make up to ${vol} mL with dH₂O
Mix thoroughly and autoclave at 121°C for 20 min
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
⚠️ Work in fume hood — wear gloves and eye protection
Measure ${ethanolMl} mL of 100% ethanol into a glass bottle
Add ${tesMl} mL TES antifoam
Make up to ${vol} mL with dH₂O — mix gently
Do NOT autoclave — filter sterilise (0.2 µm) or use directly
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 = '▶ Active Run '
+ batches.map(b =>
'' + 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();
}
⏱
Prepare for Sampling
⏭ Skip
✅ Go to Data Entry
⚡ Quick Log
Log essential parameters fast
Cancel
⚡ Log Data Point
← Dashboard
Shift Handover
🖨️ Print / Export PDF
📋 SHIFT SELECTION
🌅 Morning06:00 – 14:00
☀️ Afternoon14:00 – 22:00
🌙 Night22:00 – 06:00
📝 SHIFT NOTES — THIS SHIFT
No notes logged this shift yet.
🎯 UPCOMING ACTIONS FOR INCOMING SHIFT
✅ SIGN-OFF
Complete the form above to generate the handover summary.
🖨️ Print / Save as PDF