NyxCodex logo
Clinical Simulation Deck
NyxCodex Training Lab
Live System
SYSTEM_ONLINE  //  STAFF PORTAL

Build Clinical CalmBefore the Crisis Peaks.

NyxCodex gives psychiatric healthcare teams a place to rehearse difficult moments before they happen on shift with AI coaching, scenario labs, debriefing, and manager-ready visibility.
NyxCodex de-escalation scenario lab interface
Scenario Lab Roleplay difficult patient interactions
Patient microexpression recognition training
Clinical Observation Microexpression and cue recognition
Patient room hazard assessment scenario
Environment Scans Interactive room-safety walkthroughs
Successful de-escalation outcome feedback
Outcome Review Debriefs with success and risk contrast
31 Structured modules
AI Live mentor feedback
Ops Admin visibility built in
“This feels less like annual compliance and more like a rehearsal room for real clinical pressure.” Built for psychiatric hospitals, inpatient teams, and de-escalation readiness programs
Welcome back to NyxCodex

Sign in to continue drills, labs, assigned scenarios, and progress tracking.

or

Don't have an account?

Forgot your password?

Exploring for your organization?

Open the full experience and see how training, drills, and admin oversight connect.

Your information is stored securely and used only for training purposes.

or

Already have an account?

Secure Auth
Mobile Friendly
Professor Vance
Professor Vance · Clinical Mentor
Welcome to the Lab
De-escalation Training Unit
🎓

🎓 Prof. Vance says

Welcome back to NyxCodex®

Pick where you want to go. Restart the full training from slide one, or jump straight into the live Lab to practice scenarios. Your XP, level, and lab credits stay with you.

Interactive Module Training
Lab simulations
Your Progress 0%

✦ Automatically signed out when this tab closes — your data stays private.

Your Role Track
`); win.document.close(); } async function loadAnalytics() { const el = document.getElementById('at-analytics'); if(!el) return; el.innerHTML = `
${[1,2,3,4].map(()=>`
`).join('')}
${[1,2,3].map(()=>`
`).join('')}
`; try { const users = await window._loadAllUsers() || {}; const list = Object.values(users); const T = window.TENANT; const totalSl = (T?.moduleCount||31); const total = list.length; const notStarted = list.filter(u=>!(u.currentSlide>0) && !(u.xp>0)).length; const inProgress = list.filter(u=>(u.currentSlide||0)>0 && (u.currentSlide||0)(u.currentSlide||0)>=totalSl-1).length; const stalled = list.filter(u=> { const daysAgo = u.lastLogin ? (Date.now()-u.lastLogin)/864e5 : 999; return daysAgo > 7 && (u.currentSlide||0) > 0 && (u.currentSlide||0) < totalSl-1; }).length; const avgXP = total ? Math.round(list.reduce((s,u)=>s+(u.xp||0),0)/total) : 0; const avgLvl = total ? (list.reduce((s,u)=>s+(u.level||1),0)/total).toFixed(1) : 0; const complPct = total ? Math.round((compl/total)*100) : 0; el.innerHTML = `
${total}

Total Users

${compl}

Completed

${inProgress}

In Progress

${notStarted}

Not Started

${avgXP.toLocaleString()}

Avg XP

${avgLvl}

Avg Level

Overall Completion Rate

${complPct}% of ${total} users completed all ${totalSl} slides

${stalled > 0 ? `

Needs Attention — ${stalled} staff member${stalled!==1?'s':''} stalled 7+ days

These users started training but haven't logged in for over a week. Visit the Users tab to identify them (highlighted in red "Last Login").

` : `

? No stalled users — all active staff are progressing on schedule.

` }`; } catch(e){ el.innerHTML=`

Error: ${e.message}

`; } } // --- Reports ---------------------------------------------- async function loadReports() { const el = document.getElementById('at-reports'); if(!el) return; el.innerHTML=`

Loading reports…

`; try { const users = await window._loadAllUsers() || {}; let list = Object.entries(users).map(([uid,d])=>({uid,...d})); // Manager filter — only show own department if (window._adminManagerDept) list = list.filter(u => (u.department||'Unassigned') === window._adminManagerDept); if(!list.length){ el.innerHTML='

No user data yet.

'; return; } // Group by department const T = window.TENANT; const totalSl = (T?.moduleCount||31); const depts = {}; list.forEach(u => { const d = u.department || 'Unassigned'; if(!depts[d]) depts[d] = { total:0, completed:0, totalXP:0 }; depts[d].total++; if((u.currentSlide||0)>=totalSl-1) depts[d].completed++; depts[d].totalXP += (u.xp||0); }); let deptRows = Object.entries(depts).sort((a,b)=>b[1].total-a[1].total).map(([name,d])=>{ const pct = Math.round((d.completed/d.total)*100); return ` ${name} ${d.total} ${d.completed}
${pct}%
${Math.round(d.totalXP/d.total).toLocaleString()} `; }).join(''); // Per-user table const userRows = list.sort((a,b)=>(b.xp||0)-(a.xp||0)).map(u=>{ const name = [u.firstName,u.lastName].filter(Boolean).join(' ') || u.name || u.email || 'Unknown'; const done = (u.currentSlide||0)>=totalSl-1; const completedDate = u.completedAt ? new Date(u.completedAt).toLocaleDateString() : (done && u.lastLogin ? new Date(u.lastLogin).toLocaleDateString()+' *' : '—'); const quizScore = u.finalQuizScore != null ? `${u.finalQuizScore}/15` : '—'; const daysAgo = u.lastLogin ? Math.round((Date.now()-u.lastLogin)/864e5) : null; const loginStr = daysAgo === 0 ? 'Today' : daysAgo === 1 ? 'Yesterday' : daysAgo != null ? `${daysAgo}d ago` : '—'; const loginColor = daysAgo != null && daysAgo > 7 && !done ? '#dc2626' : '#475569'; return ` ${name} ${u.department||'—'} ${u.jobTitle||'—'} ${done?'? Complete':'? Slide '+(u.currentSlide||0)+'/'+totalSl} ${(u.xp||0).toLocaleString()} ${quizScore} ${completedDate} ${u.dueDate&&!done?`${Math.ceil((u.dueDate-Date.now())/864e5)<0?'⚠️ Overdue':'📅 '+new Date(u.dueDate).toLocaleDateString()}`:done?'—':'Not set'} ${loginStr} `; }).join(''); el.innerHTML = `

Completion by Department

${deptRows}
Department Users Completed Rate Avg XP

All Users

${userRows}
Name Dept Title Status XP Quiz Completed Due Date Last Login Actions
`; } catch(e){ el.innerHTML=`

Error: ${e.message}

`; } } async function exportCSV() { const users = await window._loadAllUsers() || {}; const rows = [['First Name','Last Name','Email','Department','Job Title','Employee ID','XP','Level','Slide','Completed','Completed Date','Quiz Score','Lab Credits','Tier','Last Login','Training Year']]; const totalSl = (window.TENANT?.moduleCount||31); Object.values(users).forEach(u => { const tier = getTier(u.labCredits||0, (window._settingsCache?.redemptionTiers)||labSettings.redemptionTiers||[]); const done = (u.currentSlide||0)>=totalSl-1; rows.push([ u.firstName||'', u.lastName||'', u.email||'', u.department||'', u.jobTitle||'', u.employeeId||'', u.xp||0, u.level||1, u.currentSlide||0, done ? 'Yes':'No', u.completedAt ? new Date(u.completedAt).toLocaleDateString() : (done && u.lastLogin ? new Date(u.lastLogin).toLocaleDateString()+' (est)' : ''), u.finalQuizScore != null ? u.finalQuizScore : '', u.labCredits||0, tier ? `Tier ${tier}` : '', u.lastLogin ? new Date(u.lastLogin).toLocaleDateString() : '', u.trainingYear||'' ]); }); const csv = rows.map(r=>r.map(v=>`"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n'); const a = document.createElement('a'); a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv); a.download = `${(window.TENANT?.orgId||'training').toUpperCase()}_Report_${new Date().toISOString().slice(0,10)}.csv`; a.click(); } // --- Compliance PDF Report -------------------------------- async function generateCompliancePDF() { const T = window.TENANT || {}; const users = await window._loadAllUsers() || {}; const totalSl = T.moduleCount || 31; const userArr = Object.values(users); const completed = userArr.filter(u => (u.currentSlide||0) >= totalSl - 1); const inProgress = userArr.filter(u => (u.currentSlide||0) > 0 && (u.currentSlide||0) < totalSl - 1); const notStarted = userArr.filter(u => !(u.currentSlide||0)); const pct = userArr.length ? Math.round(completed.length / userArr.length * 100) : 0; const depts = {}; userArr.forEach(u => { const d = u.department || 'Unassigned'; if (!depts[d]) depts[d] = { total:0, done:0, inProg:0 }; depts[d].total++; if ((u.currentSlide||0) >= totalSl - 1) depts[d].done++; else if ((u.currentSlide||0) > 0) depts[d].inProg++; }); const deptRows = Object.entries(depts).sort((a,b)=>a[0].localeCompare(b[0])).map(([dept, s]) => { const dPct = Math.round(s.done / s.total * 100); const color = dPct >= 90 ? '#16a34a' : dPct >= 70 ? '#d97706' : '#dc2626'; return `${dept}`+ `${s.total}`+ `${s.done}`+ `${s.inProg}`+ `${dPct}%`; }).join(''); const staffRows = userArr.sort((a,b)=>((a.lastName||'')+(a.firstName||'')).localeCompare((b.lastName||'')+(b.firstName||''))) .map(u => { const done = (u.currentSlide||0) >= totalSl - 1; const ip = !done && (u.currentSlide||0) > 0; const statusColor = done ? '#16a34a' : ip ? '#d97706' : '#64748b'; const statusLabel = done ? 'Completed' : ip ? 'In Progress' : 'Not Started'; const certDate = done && u.completedAt ? new Date(u.completedAt).toLocaleDateString() : '—'; const renewal = done && u.completedAt && T.certRenewalMonths ? new Date(new Date(u.completedAt).setMonth(new Date(u.completedAt).getMonth() + (T.certRenewalMonths||12))).toLocaleDateString() : '—'; return `${u.lastName||''}${u.firstName ? ', '+u.firstName : ''}`+ `${u.department||'—'}`+ `${u.jobTitle||'—'}`+ `${statusLabel}`+ `${u.finalQuizScore != null ? u.finalQuizScore+'/15' : '—'}`+ `${certDate}`+ `${renewal}`; }).join(''); const reportDate = new Date().toLocaleDateString('en-US', {year:'numeric',month:'long',day:'numeric'}); const html = `

${T.orgName || T.brandName || 'Organization'}

${T.orgAddress || ''}

Annual Competency Report
${T.certType || 'De-escalation Training'}
Generated: ${reportDate}

${userArr.length}

Total Staff

${completed.length}

Certified

${inProgress.length}

In Progress

${notStarted.length}

Not Started

=70?'#fde68a':'#fecaca'}'>

=70?'#d97706':'#dc2626'}'>${pct}%

Compliance

Completion by Department

${deptRows}
DepartmentStaffCertifiedIn ProgressCompliance

Individual Staff Records

${staffRows}
NameDepartmentJob TitleStatusQuizCert DateRenewal

This document may be used for Joint Commission compliance review, state licensing audits, and internal HR records. Retain for a minimum of 3 years.

`; await html2pdf().set({ margin: [0,0,0,0], filename: `${(T.orgId||'org').toUpperCase()}_Compliance_${new Date().toISOString().slice(0,10)}.pdf`, image: { type:'jpeg', quality:.98 }, html2canvas: { scale:2, useCORS:true, letterRendering:true }, jsPDF: { unit:'in', format:'letter', orientation:'portrait' } }).from(html).save(); } // --- Due-date / Assignment system ------------------------- function refreshDueDateBanner() { const banner = document.getElementById('due-date-banner'); if (!banner) return; const ud = window._userData; if (!ud?.dueDate) { banner.style.display = 'none'; return; } const T = window.TENANT; const totalSl = (T?.moduleCount || 31); const done = (ud.currentSlide || 0) >= totalSl - 1; if (done) { banner.style.display = 'none'; return; } const now = Date.now(); const due = ud.dueDate; const daysLeft = Math.ceil((due - now) / 864e5); const overdue = daysLeft < 0; const soon = daysLeft >= 0 && daysLeft <= 3; const bg = overdue ? 'linear-gradient(135deg,#dc2626,#b91c1c)' : soon ? 'linear-gradient(135deg,#d97706,#b45309)' : 'linear-gradient(135deg,#2563eb,#1d4ed8)'; const emoji = overdue ? '⚠️' : soon ? '⏰' : '✅'; const label = overdue ? `Your training was due ${Math.abs(daysLeft)} day${Math.abs(daysLeft)!==1?'s':''} ago. Please complete it today.` : daysLeft === 0 ? 'Your training deadline is TODAY. Please complete it before your shift ends.' : `Training deadline: ${new Date(due).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})} — ${daysLeft} day${daysLeft!==1?'s':''} remaining`; banner.style.display = 'block'; banner.innerHTML = `
${emoji}

${label}

`; } function openSetDueDateModal(uid, name, currentDue) { const existing = currentDue ? new Date(currentDue).toISOString().slice(0,10) : ''; const existing_enc = name.replace(/'/g,"\\'"); const modal = document.createElement('div'); modal.id = 'due-date-modal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:300;display:flex;align-items:center;justify-content:center;padding:1.5rem'; modal.innerHTML = `

Set Training Deadline

Assign a due date for ${name}. They'll see a countdown banner on their Hub.

${existing ? `` : ''}
`; document.body.appendChild(modal); modal.addEventListener('click', e => { if(e.target===modal) modal.remove(); }); setTimeout(()=>document.getElementById('due-date-input')?.focus(), 50); } async function saveDueDate(uid) { const input = document.getElementById('due-date-input'); if (!input?.value) { alert('Please pick a date.'); return; } const dueDate = new Date(input.value + 'T23:59:59').getTime(); try { await window._updateUserData(uid, { dueDate }); document.getElementById('due-date-modal')?.remove(); const cached = window._adminUsersCache; if (cached && cached[uid]) cached[uid].dueDate = dueDate; _renderUsersGrid(window._adminUsersCache || {}); } catch(e) { alert('Error saving: ' + e.message); } } async function clearDueDate(uid) { try { await window._updateUserData(uid, { dueDate: null }); document.getElementById('due-date-modal')?.remove(); const cached = window._adminUsersCache; if (cached && cached[uid]) delete cached[uid].dueDate; _renderUsersGrid(window._adminUsersCache || {}); } catch(e) { alert('Error: ' + e.message); } } function openBulkDueDateModal() { const cached = window._adminUsersCache || {}; const depts = [...new Set(Object.values(cached).map(u=>u.department||'Unassigned'))].sort(); const roleOptions = [ { val: '', label: 'No change' }, { val: 'rn', label: 'RN / Clinical Nurse' }, { val: 'tech', label: 'Behavioral Tech' }, { val: 'charge', label: 'Charge Nurse' }, { val: 'security', label: 'Security' }, { val: 'clear', label: '✕ Clear role (reset to default)' }, ]; const modal = document.createElement('div'); modal.id = 'bulk-due-modal'; modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:300;display:flex;align-items:center;justify-content:center;padding:1.5rem'; modal.innerHTML = `

📅 Bulk Staff Assignment

Assign a deadline and/or role track to an entire department or all staff at once.

`; document.body.appendChild(modal); modal.addEventListener('click', e => { if(e.target===modal) modal.remove(); }); } async function applyBulkDueDate() { return applyBulkAssignment(); } // backwards-compat alias async function applyBulkAssignment() { const dept = document.getElementById('bulk-dept-select')?.value; const dateV = document.getElementById('bulk-date-input')?.value; const roleTrack = document.getElementById('bulk-role-select')?.value; if (!dateV && !roleTrack) { alert('Choose a deadline, a role track, or both.'); return; } const dueDate = dateV ? new Date(dateV + 'T23:59:59').getTime() : null; const cached = window._adminUsersCache || {}; const targets = Object.entries(cached).filter(([,u]) => dept === '__all__' || (u.department||'Unassigned') === dept ); if (!targets.length) { alert('No users found.'); return; } const parts = []; if (dueDate) parts.push(`deadline ${new Date(dueDate).toLocaleDateString()}`); if (roleTrack) parts.push(`role track “${roleTrack === 'clear' ? 'cleared' : roleTrack}”`); if (!confirm(`Apply ${parts.join(' and ')} to ${targets.length} user${targets.length!==1?'s':''}?`)) return; const update = {}; if (dueDate) update.dueDate = dueDate; if (roleTrack === 'clear') update.roleTrack = null; else if (roleTrack) update.roleTrack = roleTrack; try { await Promise.all(targets.map(([uid]) => window._updateUserData(uid, update))); targets.forEach(([uid]) => { if(cached[uid]) Object.assign(cached[uid], update); }); await window._writeAudit('bulk_assignment', { dept, dueDate, roleTrack, count: targets.length }); document.getElementById('bulk-due-modal')?.remove(); _renderUsersGrid(cached); loadReports(); } catch(e) { alert('Error: ' + e.message); } } // --- Completion roster ------------------------------------ async function loadCompletion() { const el = document.getElementById('at-completion'); if(!el) return; el.innerHTML='

Loading...

'; try { const users = await window._loadAllUsers() || {}; const T = window.TENANT; const totalSl = (T?.moduleCount||31); const list = Object.entries(users).map(([uid,d])=>({uid,...d})); const done = list.filter(u=>(u.currentSlide||0)>=totalSl-1) .sort((a,b)=>(b.completedAt||b.lastLogin||0)-(a.completedAt||a.lastLogin||0)); const pending = list.filter(u=>(u.currentSlide||0)(b.currentSlide||0)-(a.currentSlide||0)); const row = (u, isDone) => { const name = [u.firstName,u.lastName].filter(Boolean).join(' ') || u.name || 'Unknown'; const initials = name.split(' ').map(w=>w[0]||'').join('').toUpperCase().slice(0,2)||'?'; const pct = Math.min(100, Math.round(((u.currentSlide||0)/(totalSl-1))*100)); const completedStr = u.completedAt ? new Date(u.completedAt).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}) : (isDone ? 'Date not recorded' : ''); const score = u.finalQuizScore != null ? u.finalQuizScore : null; return `
${initials}

${name}

${u.department||u.jobTitle||u.email||''}

${isDone ? `
${score!==null?`
Quiz: ${score}/15
`:''}
${completedStr}
? Done` : `

${pct}% — Slide ${u.currentSlide||0}

` }
`; }; el.innerHTML = `

Training Completion Status

${done.length} completed — ${pending.length} in progress or not started

? Completed — ${done.length}
${done.length ? done.map(u=>row(u,true)).join('') : '

No completions yet.

'}
? In Progress / Not Started — ${pending.length}
${pending.length ? pending.map(u=>row(u,false)).join('') : '

All staff have completed training!

'}
`; } catch(e) { el.innerHTML=`

Error: ${e.message}

`; } } // --- Settings --------------------------------------------- // --- Admin CSV Export ------------------------------------- async function exportUsersCSV() { try { const users = await window._loadAllUsers() || {}; const T = window.TENANT; const totalSl = T?.moduleCount || 31; const rows = [['Name','Email','Department','Job Title','Employee ID','Progress %','Slide','XP','Level','Lab Credits','Quiz Score','Completed','Last Login','Training Year']]; Object.values(users).forEach(u => { const name = [u.firstName,u.lastName].filter(Boolean).join(' ') || u.name || 'Unknown'; const pct = Math.min(100, Math.round(((u.currentSlide||0)/(totalSl-1))*100)); const done = (u.currentSlide||0) >= totalSl-1 ? 'Yes' : 'No'; const lastLogin = u.lastLogin ? new Date(u.lastLogin).toLocaleDateString('en-US') : ''; rows.push([ name, u.email||'', u.department||'', u.jobTitle||'', u.employeeId||'', pct+'%', u.currentSlide||0, u.xp||0, u.level||1, u.labCredits||0, u.finalQuizScore!=null ? `${u.finalQuizScore}/15` : '', done, lastLogin, u.trainingYear||'' ]); }); const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n'); const blob = new Blob([csv], {type:'text/csv'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `NyxCodex_TrainingReport_${new Date().toISOString().slice(0,10)}.csv`; a.click(); } catch(e) { alert('Export failed: ' + e.message); } } async function loadSettings() { if(currentAdminRole!=='owner') return; const el = document.getElementById('at-settings'); if(!el) return; el.innerHTML=`

Loading settings…

`; try { const [allUsers, allAdmins, settings] = await Promise.all([ window._loadAllUsers(), window._loadAllAdmins(), window._getSettings() ]); adminCodeHash = settings?.adminCodeHash || null; window._settingsCache = settings || {}; labSettings = { labBaseLC: settings?.labBaseLC ?? 50, labScoreBonus: settings?.labScoreBonus || {A:50,B:30,C:15,D:5}, redemptionTiers: settings?.redemptionTiers || [300,600,1000,1800], weeklyBonusLC: settings?.weeklyBonusLC ?? 200 }; const userList = Object.entries(allUsers||{}); const adminUIDs = Object.keys(allAdmins||{}); const currentYear = settings.trainingYear || new Date().getFullYear(); const userOptions = userList.map(([uid,u])=>``).join(''); const adminRows = adminUIDs.map(uid=>{ const role = allAdmins[uid]?.role; const u = allUsers[uid]||{}; if(uid===window._currentUser?.uid) return ` ${u.firstName||''} ${u.lastName||''} (You) ${role} Cannot modify own account `; return ` ${u.firstName||''} ${u.lastName||''} ${role==='owner'?'👑':'👤'} ${role} `; }).join(''); el.innerHTML = `

📅 Training Year

Changing the year marks all users' current training as outdated at year-end rollover. Auto-Rollover resets completion for users whose cert has lapsed.

✉ Allowed Email Domain

Staff may only register with an email from this domain (e.g. destinysprings.com). Leave blank to allow any email.

@

Grant Access Roles

🏥 Grant Department Manager

Admin/Owner Access Code

Owners can rotate the code anytime. The hash is stored in Firebase settings; no code is embedded in the page.

Two-Factor Authentication (Owner)

${settings?.ownerTotpEnabled ? '✅ 2FA is currently enabled on this account.' : '2FA is not enabled. Add an extra layer of security by scanning a TOTP secret into Google Authenticator, Authy, or similar.'}

${settings?.ownerTotpEnabled ? `` : ` `}

Lab Credits & Tiers

A Bonus:
B Bonus:
C Bonus:
D Bonus:

HR System Integrations

${['ADP','Paycom','Paycor','Workday','BambooHR','UKG','Cornerstone','SAP SF'].map(p=>`${p}`).join('')}

Connect via outbound webhooks ? Zapier/Make ? any HR platform, xAPI for LMS/LRS, or let HR systems pull completion data via REST API.

Outbound Webhook — fires to Zapier, Make.com, or your HR endpoint on training events

Fire on events:
${['training_completed','cert_downloaded','cert_email','lab_completed','user_created'].map(ev=>{ const on = (settings?.webhookEvents||['training_completed','cert_downloaded','lab_completed']).includes(ev); const note = ev==='cert_email' ? ' — auto-send cert to employee' : ''; return ``; }).join('')}

xAPI / Tin Can LRS — Cornerstone, Workday Learning, SAP SuccessFactors, UKG, SCORM Cloud

REST Completion API — ADP, Paycom, Paycor, or any system that can make HTTP GET requests

HR systems can poll this endpoint to get completion status for any employee by email.

GET /api/hr/completion?orgId=${(typeof TENANT_ID!=='undefined'?TENANT_ID:settings?.orgId||'YOUR_ORG_ID')}&email=employee@company.com&apiKey=${settings?.apiKey ? settings.apiKey.slice(0,8)+'—' : 'YOUR_API_KEY'}
API Key:
${settings?.apiKey || '— not generated —'}

One-time Vercel setup required: add FIREBASE_DB_SECRET and FIREBASE_DB_URL as environment variables in your Vercel project settings.

CSV Roster Import — bulk-provision accounts from an ADP/Paycom employee export

Required columns: firstName, lastName, email. Optional: department, employeeId, dueDate (YYYY-MM-DD). Accounts will be staged for self-activation via the sign-up flow.

No file selected

📨 Email Reminder Automation

Automatically send training deadline reminders to employees via Zapier, Make.com, or any webhook. Reads the webhook URL from your org settings.

⚡ Webhook URL is configured in the Org Setup Wizard below. Each eligible user's data is POSTed with event: 'training_reminder'.

🔔 Push Notification Config (Vercel)

Generate a VAPID key pair once and save the public key here. Add VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, and VAPID_SUBJECT as Vercel environment variables to enable /api/push.js.

White-Label Org Setup

Configure branding, colors, certification details, logo, and reminder webhooks for your organization. Settings are saved to Firebase and applied immediately on next login.

Current Admins

${adminUIDs.length ? `
${adminRows}
User Role Actions
` : '

No admins assigned yet.

'}

Danger Zone

These actions are irreversible. Proceed with caution.

`; } catch(e) { el.innerHTML=`

Error: ${e.message}

`; } } async function saveAllowedDomain() { const val = document.getElementById('setting-allowed-domain')?.value.trim().toLowerCase().replace(/^@/,''); if(val === undefined) return; await window._saveSettings({ allowedDomain: val }); window._settingsCache = window._settingsCache || {}; window._settingsCache.allowedDomain = val; if(val) alert('Allowed email domain set to: @' + val + '\nOnly staff with this domain may register.'); else alert('Domain restriction removed. Anyone may register.'); } async function saveTrainingYear() { const val = document.getElementById('setting-year')?.value; if(!val) return; await window._saveSettings({ trainingYear: parseInt(val) }); alert(`Training year set to ${val}. New user sessions will reflect this year.`); } async function saveAdminAccessCode() { if(currentAdminRole!=='owner') { alert('Only owners can change the access code.'); return; } const a = document.getElementById('setting-code-new')?.value.trim(); const b = document.getElementById('setting-code-conf')?.value.trim(); if(!a||!b){ alert('Enter and confirm the access code.'); return; } if(a!==b){ alert('Codes do not match.'); return; } const hash = await hashText(a); await window._saveSettings({ adminCodeHash: hash }); adminCodeHash = hash; alert('Access code updated. Share the new code with admins/owners securely.'); } let _pendingTotpSecret = null; async function generateTotpSecret() { const arr = new Uint8Array(20); crypto.getRandomValues(arr); const b32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let secret = ''; for(let i=0;iparseInt(b,2))); const counter = Math.floor(Date.now()/30000); const buf = new ArrayBuffer(8); new DataView(buf).setUint32(4, counter, false); const key = await crypto.subtle.importKey('raw', keyBytes, {name:'HMAC',hash:'SHA-1'}, false, ['sign']); const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, buf)); const off = sig[sig.length-1] & 0xf; const code = ((sig[off]&0x7f)<<24|(sig[off+1]&0xff)<<16|(sig[off+2]&0xff)<<8|(sig[off+3]&0xff)) % 1000000; return String(code).padStart(6,'0'); } async function verifyAndEnableTotp() { const input = document.getElementById('totp-verify-code')?.value.trim(); const errEl = document.getElementById('totp-setup-error'); if(!_pendingTotpSecret || !input) return; const expected = await _totpCode(_pendingTotpSecret); if(input !== expected) { if(errEl) { errEl.textContent='Code incorrect. Codes refresh every 30 seconds — try again.'; errEl.style.display='block'; } return; } await window._saveSettings({ ownerTotpSecret: _pendingTotpSecret, ownerTotpEnabled: true }); if(window._settingsCache) { window._settingsCache.ownerTotpSecret=_pendingTotpSecret; window._settingsCache.ownerTotpEnabled=true; } _pendingTotpSecret = null; alert('✅ Two-factor authentication enabled!'); loadSettings(); } async function disableOwnerTotp() { if(!confirm('Disable 2FA? This removes the extra security layer from admin access.')) return; await window._saveSettings({ ownerTotpSecret: null, ownerTotpEnabled: false }); if(window._settingsCache) { window._settingsCache.ownerTotpSecret=null; window._settingsCache.ownerTotpEnabled=false; } loadSettings(); } async function saveLabSettings() { if(currentAdminRole!=='owner') { alert('Only owners can change lab settings.'); return; } const base = parseInt(document.getElementById('setting-lab-base')?.value||'50',10); const weekly = parseInt(document.getElementById('setting-weekly-bonus')?.value||'200',10); const tiersStr = document.getElementById('setting-tiers')?.value||''; const bonusA = parseInt(document.getElementById('setting-bonus-a')?.value||'50',10); const bonusB = parseInt(document.getElementById('setting-bonus-b')?.value||'30',10); const bonusC = parseInt(document.getElementById('setting-bonus-c')?.value||'15',10); const bonusD = parseInt(document.getElementById('setting-bonus-d')?.value||'5',10); const tiers = tiersStr.split(',').map(t=>parseInt(t.trim(),10)).filter(n=>!isNaN(n)).sort((a,b)=>a-b); const payload = { labBaseLC: base, weeklyBonusLC: weekly, redemptionTiers: tiers, labScoreBonus: {A:bonusA,B:bonusB,C:bonusC,D:bonusD} }; await window._saveSettings(payload); labSettings = Object.assign({}, labSettings, payload); alert('Lab settings saved.'); } async function grantAdminRole(role) { const uid = document.getElementById('grant-admin-uid')?.value; if(!uid) return; if(!confirm(role==='owner'?'Grant OWNER access to this user?':'Grant ADMIN access to this user?')) return; await window._setAdminRole(uid, role); loadSettings(); } async function grantManagerRole() { const uid = document.getElementById('grant-manager-uid')?.value; const dept = document.getElementById('grant-manager-dept')?.value; if(!uid || !dept) return; if(!confirm(`Grant Department Manager access for ${dept} to this user?`)) return; await window._setAdminRole(uid, 'manager', { department: dept }); loadSettings(); } async function revokeAdmin(uid, name) { if(!confirm(`Revoke admin access for ${name}?`)) return; await window._setAdminRole(uid, null); loadSettings(); } async function confirmResetTraining() { if(!confirm("⚠️ This will reset all users' slide progress to 0 for the new training year. This CANNOT be undone. Are you absolutely sure?")) return; if(!confirm('Final confirmation: Reset all training progress?')) return; resetAllTraining(); } async function resetAllTraining() { await window._resetAllProgress(); alert('All training progress has been reset for the new year.'); loadSettings(); } // --- Integration Settings -------------------------------- async function saveIntegrationSettings() { if(currentAdminRole!=='owner') { alert('Only owners can change integration settings.'); return; } const webhookUrl = document.getElementById('setting-webhook-url')?.value.trim() || ''; const webhookSecret = document.getElementById('setting-webhook-secret')?.value.trim() || ''; const xapiEndpoint = document.getElementById('setting-xapi-endpoint')?.value.trim() || ''; const xapiUsername = document.getElementById('setting-xapi-user')?.value.trim() || ''; const xapiPassword = document.getElementById('setting-xapi-pass')?.value.trim() || ''; const xapiActivityId= document.getElementById('setting-xapi-activity')?.value.trim() || ''; const webhookEvents = ['training_completed','cert_downloaded','cert_email','lab_completed','user_created'] .filter(ev => document.getElementById(`wh-ev-${ev}`)?.checked); const payload = { webhookUrl, webhookSecret, xapiEndpoint, xapiUsername, xapiPassword, xapiActivityId, webhookEvents }; await window._saveSettings(payload); Object.assign(window._settingsCache||{}, payload); alert('Integration settings saved.'); } async function generateApiKey() { if(currentAdminRole!=='owner') { alert('Only owners can regenerate the API key.'); return; } if(!confirm('Regenerate the REST API key? Any systems currently using the old key will need to be updated.')) return; const arr = new Uint8Array(24); crypto.getRandomValues(arr); const key = Array.from(arr).map(b=>b.toString(16).padStart(2,'0')).join(''); await window._saveSettings({ apiKey: key }); if(window._settingsCache) window._settingsCache.apiKey = key; alert(`New API key: ${key}\n\nCopy this now — it is shown once in plain text and then stored in Firebase.`); loadSettings(); } async function testWebhook() { const url = document.getElementById('setting-webhook-url')?.value.trim(); if(!url) { alert('Enter a webhook URL first.'); return; } const user = window._currentUser; const payload = { event:'test_ping', timestamp: new Date().toISOString(), orgId: window.TENANT?.orgId||'test', orgName: window.TENANT?.orgName||'NyxCodex®', message:'This is a test ping from NyxCodex® Platform.', user: { email: user?.email||'admin@test.com', name: user?.displayName||'Admin' } }; try { const secret = document.getElementById('setting-webhook-secret')?.value.trim()||''; const body = JSON.stringify(payload); const res = await fetch('/api/webhook-proxy', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ url, event:'test_ping', secret, body }) }); const data = await res.json(); alert(data.ok ? `✅ Webhook delivered! HTTP ${data.status}` : `⚠️ Webhook sent but destination returned an error: ${data.error||data.status}`); } catch(e) { alert(`? Failed to send webhook: ${e.message}`); } } async function testXAPI() { const endpoint = document.getElementById('setting-xapi-endpoint')?.value.trim(); if(!endpoint) { alert('Enter an LRS endpoint URL first.'); return; } const tmpSettings = { xapiEndpoint: endpoint, xapiUsername: document.getElementById('setting-xapi-user')?.value.trim()||'', xapiPassword: document.getElementById('setting-xapi-pass')?.value.trim()||'', xapiActivityId: document.getElementById('setting-xapi-activity')?.value.trim()||'' }; const orig = window._settingsCache; window._settingsCache = Object.assign({}, orig||{}, tmpSettings); await window._emitXAPI('attempted', { completion: false }); window._settingsCache = orig; alert('? xAPI statement sent. Check your LRS dashboard to confirm receipt.'); } async function importRosterCSV(input) { const file = input?.files?.[0]; if(!file) return; document.getElementById('roster-file-name').textContent = file.name; const text = await file.text(); const lines = text.trim().split(/\r?\n/); if(lines.length < 2) { alert('CSV must have a header row and at least one data row.'); return; } // Parse header const headers = lines[0].split(',').map(h=>h.trim().toLowerCase().replace(/[^a-z]/g,'')); const get = (row, ...keys) => { for(const k of keys) { const i = headers.indexOf(k); if(i>=0) return (row[i]||'').trim(); } return ''; }; const log = document.getElementById('roster-import-log'); log.innerHTML = '

Processing…

'; let created=0, skipped=0, errors=0; const results = []; const existingUsers = await window._loadAllUsers().catch(()=>({})); for(let i=1;ic.trim().replace(/^"|"$/g,'')); const email = get(row,'email').toLowerCase(); const firstName = get(row,'firstname','first_name','first'); const lastName = get(row,'lastname','last_name','last'); if(!email||!firstName||!lastName) { skipped++; results.push(`Row ${i+1}: missing required fields (email, firstName, lastName)`); continue; } // Check if already exists const exists = Object.values(existingUsers||{}).some(u=>(u.email||'').toLowerCase()===email); if(exists) { skipped++; results.push(`— ${email}: already has an account`); continue; } // Stage invite record in Firebase try { const dept = get(row,'department','dept','division'); const empId = get(row,'employeeid','employee_id','employeenumber','id','empid'); const dueStr = get(row,'duedate','due_date','due','deadline'); const dueTs = dueStr ? new Date(dueStr).getTime() : null; await update(_ref('invites/' + btoa(email).replace(/=/g,'')), { email, firstName, lastName, ...(dept ? { department: dept } : {}), ...(empId ? { employeeId: empId } : {}), ...(dueTs && !isNaN(dueTs) ? { dueDate: dueTs } : {}), createdAt: Date.now(), source:'csv_import' }); created++; results.push(`? ${firstName} ${lastName} (${email}) — staged`); } catch(e) { errors++; results.push(`? ${email}: ${e.message}`); } } log.innerHTML = `
Import complete: ${created} staged${skipped} skipped${errors?` — ${errors} errors`:''}
Staged users will see their pre-filled profile when they sign up with the matching email.
${results.join('
')}
`; input.value = ''; } // --- Audit Log ------------------------------------------ async function loadAudit() { if(currentAdminRole !== 'owner') return; const el = document.getElementById('at-audit'); if(!el) return; el.innerHTML = `

Audit Log

Loading audit events…
`; try { const snap = await get(_ref('audit')); const raw = snap.val() || {}; const rows = Object.entries(raw) .map(([k,v]) => ({...v, _key: k})) .sort((a,b) => b.ts - a.ts) .slice(0, 300); window._auditRows = rows; const COLOR = { login: 'background:#dbeafe;color:#1d4ed8', logout: 'background:#f1f5f9;color:#475569', cert_downloaded: 'background:#fef9c3;color:#92400e', lab_completed: 'background:#dcfce7;color:#166534', drill_completed: 'background:#fef3c7;color:#92400e', admin_access: 'background:#f3e8ff;color:#6b21a8', }; if(!rows.length) { document.getElementById('audit-table-wrap').innerHTML = '
No audit events recorded yet.
'; return; } const tbl = `
${rows.map(r=>{ const dt = new Date(r.ts).toLocaleString(); const badge = COLOR[r.action] || 'background:#e2e8f0;color:#334155'; const det = Object.entries(r) .filter(([k])=>!['ts','iso','uid','email','displayName','action','ua','_key'].includes(k)) .map(([k,v])=>`${k}: ${v}`).join(' — '); return ``; }).join('')}
Time User Action Details
${dt}
${r.displayName||''}
${r.email||''}
${r.action} ${det}
`; document.getElementById('audit-table-wrap').innerHTML = tbl; } catch(e) { document.getElementById('audit-table-wrap').innerHTML = `
Error loading audit log: ${e.message}
`; } } function exportAuditCSV() { const rows = window._auditRows || []; if(!rows.length) { alert('No audit data to export.'); return; } const headers = ['timestamp','email','displayName','action','uid','details']; const lines = rows.map(r => { const det = Object.entries(r) .filter(([k])=>!['ts','iso','uid','email','displayName','action','ua','_key'].includes(k)) .map(([k,v])=>`${k}=${v}`).join('; '); return [ new Date(r.ts).toISOString(), r.email||'', r.displayName||'', r.action||'', r.uid||'', det ].map(v=>`"${String(v).replace(/"/g,'""')}"`).join(','); }); const csv = [headers.join(','), ...lines].join('\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], {type:'text/csv'})); a.download = `audit_log_${new Date().toISOString().slice(0,10)}.csv`; a.click(); } // --- Daily Drill ----------------------------------------- function refreshDrillStreak() { const streak = window._userData?.drillStreak || 0; drillStreak = streak; const badge = document.getElementById('hub-streak-badge'); if (!badge) return; if (streak > 0) { badge.style.display = 'block'; badge.textContent = '◆ ' + streak; } else { badge.style.display = 'none'; } } function openDrillModal() { const modal = document.getElementById('drill-modal'); if (!modal) return; modal.style.display = 'flex'; const ud = window._userData || {}; const today = new Date().toISOString().slice(0, 10); const alreadyDone = ud.lastDrillDate === today; const sd = document.getElementById('drill-streak-display'); if (sd) { const s = ud.drillStreak || 0; sd.textContent = s > 0 ? `◆ ${s}-day streak` : '⭐ Start your streak!'; } if (alreadyDone) { document.getElementById('drill-done-today').style.display = 'block'; document.getElementById('drill-steps-wrap').style.display = 'none'; const dds = document.getElementById('drill-done-streak'); if (dds) dds.textContent = `◆ ${ud.drillStreak || 1}-day streak`; } else { document.getElementById('drill-done-today').style.display = 'none'; document.getElementById('drill-steps-wrap').style.display = 'block'; document.getElementById('drill-step-1').style.display = 'block'; document.getElementById('drill-step-2').style.display = 'none'; document.getElementById('drill-score-panel').style.display = 'none'; const sb = document.getElementById('drill-submit-btn'); if (sb) { sb.disabled = false; sb.textContent = 'Submit Response'; sb.style.display = ''; } const fb = document.getElementById('drill-finish-btn'); if (fb) fb.style.display = 'none'; document.getElementById('drill-response').value = ''; drillFlipped = false; drillXPEarned = false; loadDrillCard(); } } function closeDrillModal() { const modal = document.getElementById('drill-modal'); if (modal) modal.style.display = 'none'; } function loadDrillCard() { const card = SKILL_CARDS[Math.floor(Math.random() * SKILL_CARDS.length)]; drillCurrentCard = card; drillFlipped = false; const inner = document.getElementById('drill-flip-inner'); if (inner) inner.style.transform = 'rotateY(0deg)'; document.getElementById('drill-card-category').textContent = card.category; document.getElementById('drill-card-title').textContent = card.title; document.getElementById('drill-card-front').textContent = card.front; document.getElementById('drill-card-back').textContent = card.back; document.getElementById('drill-card-prompt').textContent = card.prompt; } function flipDrillCard() { drillFlipped = !drillFlipped; const inner = document.getElementById('drill-flip-inner'); if (inner) inner.style.transform = drillFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)'; } function advanceDrillToStep2() { document.getElementById('drill-step-1').style.display = 'none'; document.getElementById('drill-step-2').style.display = 'block'; loadDrillScenario(); } function loadDrillScenario() { const dxKeys = Object.keys(scenarios || {}); if (!dxKeys.length) { document.getElementById('drill-scenario-box').textContent = 'Loading scenarios…'; return; } const dx = dxKeys[Math.floor(Math.random() * dxKeys.length)]; const pool = scenarios[dx] || []; const scen = pool[Math.floor(Math.random() * pool.length)] || 'A patient is becoming increasingly agitated in the common room and is not responding to verbal prompts.'; drillCurrentScenario = scen; document.getElementById('drill-scenario-box').textContent = scen; } function rerollDrillScenario() { loadDrillScenario(); document.getElementById('drill-score-panel').style.display = 'none'; document.getElementById('drill-response').value = ''; const sb = document.getElementById('drill-submit-btn'); if (sb) { sb.disabled = false; sb.textContent = 'Submit Response'; sb.style.display = ''; } const fb = document.getElementById('drill-finish-btn'); if (fb) fb.style.display = 'none'; } async function submitDrill() { const resp = (document.getElementById('drill-response')?.value || '').trim(); if (resp.length < 20) { alert('Please write a more detailed response (at least 20 characters).'); return; } const btn = document.getElementById('drill-submit-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Scoring…'; } const words = resp.toLowerCase(); const keyTerms = ['safe','calm','choice','validate','empathize','listen','space','support','trigger','reduce','grounding','breathe','acknowledge','help','understand','trauma','feelings','option']; const matched = keyTerms.filter(t => words.includes(t)).length; let grade, feedbackText; if (matched >= 5 || resp.length > 200) { grade = 'A'; feedbackText = 'Outstanding. You demonstrated validation, space creation, and patient-centered language. That\'s textbook de-escalation.'; } else if (matched >= 3 || resp.length > 100) { grade = 'B'; feedbackText = 'Solid response. You used key de-escalation language. Push further into explicit validation and structuring a choice for the patient.'; } else if (matched >= 2 || resp.length > 50) { grade = 'C'; feedbackText = 'Developing. Your response was safe but lacked depth. Try explicitly naming the patient\'s emotion and offering a forced choice.'; } else { grade = 'D'; feedbackText = 'Needs work. Response was too brief or directive. Remember: validate before you redirect. Review today\'s skill card and try again.'; } const ud = window._userData || {}; const today = new Date().toISOString().slice(0, 10); let finalXP = 0; let streakBonus = 0; let newStreak = ud.drillStreak || 0; if (!drillXPEarned) { drillXPEarned = true; const baseXP = grade === 'A' ? 300 : grade === 'B' ? 200 : grade === 'C' ? 120 : 60; const lastDate = ud.lastDrillDate || ''; const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); if (lastDate === yesterday) { newStreak = (ud.drillStreak || 0) + 1; } else if (lastDate !== today) { newStreak = 1; // gap in streak — reset } // Streak XP multipliers: 3-day +25%, 7-day +50%, 14-day +75%, 30-day +100% const mult = newStreak >= 30 ? 2.0 : newStreak >= 14 ? 1.75 : newStreak >= 7 ? 1.5 : newStreak >= 3 ? 1.25 : 1.0; finalXP = Math.round(baseXP * mult); streakBonus = finalXP - baseXP; drillStreak = newStreak; await window._saveUserData({ lastDrillDate: today, drillStreak: newStreak, totalDrillsCompleted: (ud.totalDrillsCompleted || 0) + 1 }).catch(() => {}); window._userData = Object.assign({}, ud, { lastDrillDate: today, drillStreak: newStreak }); awardXP(finalXP); refreshDrillStreak(); const sd = document.getElementById('drill-streak-display'); if (sd) sd.textContent = `◆ ${newStreak}-day streak`; } document.getElementById('drill-score-panel').style.display = 'block'; document.getElementById('drill-grade').textContent = grade; document.getElementById('drill-feedback').textContent = feedbackText; const xpBadge = document.getElementById('drill-xp-badge'); if (xpBadge) { xpBadge.textContent = finalXP > 0 ? `+${finalXP} XP earned` : 'Practice only (XP already earned today)'; xpBadge.style.color = finalXP > 0 ? '#16a34a' : '#94a3b8'; } const vsb = document.getElementById('drill-streak-bonus'); if (vsb) { if (streakBonus > 0) { vsb.style.display = 'block'; vsb.textContent = `◆ ${drillStreak}-day streak bonus: +${streakBonus} extra XP`; } else vsb.style.display = 'none'; } if (btn) { btn.style.display = 'none'; } const fb = document.getElementById('drill-finish-btn'); if (fb) fb.style.display = ''; if (finalXP > 0) showXPToast(finalXP, `Daily Drill ◆${drillStreak > 1 ? '—' + drillStreak : ''}`); window._fireWebhook && window._fireWebhook('drill_completed', { email: window._currentUser?.email, grade, xpEarned: finalXP, streak: drillStreak, timestamp: new Date().toISOString() }).catch(() => {}); window._writeAudit('drill_completed', { grade, xpEarned: finalXP, streak: drillStreak }).catch(() => {}); window._checkAchievements && window._checkAchievements({ drillStreak, totalDrillsCompleted: window._userData?.totalDrillsCompleted||0 }).catch(()=>{}); } function startBoxBreathing() { if(breathInterval) clearInterval(breathInterval); const phases=[{text:'Inhale',dur:4},{text:'Hold',dur:4},{text:'Exhale',dur:4},{text:'Hold',dur:4}]; let pi=0, countdown=phases[0].dur; const phaseEl = document.getElementById('box-phase'); const countEl = document.getElementById('box-count'); const anim = document.getElementById('box-breath-anim'); const colors = ['#3b82f6','#8b5cf6','#06b6d4','#10b981']; function update(){ if(phaseEl) phaseEl.textContent=phases[pi].text; if(countEl) countEl.textContent=countdown; if(anim){ anim.style.background=colors[pi]; anim.style.transform=pi===0?'scale(1.3)':pi===2?'scale(0.7)':'scale(1)'; } countdown--; if(countdown<0){ pi=(pi+1)%4; countdown=phases[pi].dur; } } update(); breathInterval=setInterval(update,1000); awardXP(25); } // --- Lab Room Helpers ----------------------------------- function openLabRoom() { document.getElementById('lab-modal').style.display='flex'; document.getElementById('lab-modal-credits').textContent = labCredits.toLocaleString(); document.getElementById('lab-score-panel').style.display='none'; document.getElementById('lab-feedback').textContent=''; document.getElementById('lab-cooldown').style.display='none'; document.getElementById('lab-response').value=''; const mrw = document.getElementById('marcus-reply-wrap'); if(mrw) mrw.style.display='none'; // Show mic button if browser supports MediaRecorder const micBtn = document.getElementById('lab-mic-btn'); if (micBtn && (typeof MediaRecorder !== 'undefined' && navigator.mediaDevices)) { micBtn.style.display = 'flex'; } // Sync patient UI (name/avatar/badge/selector) _setPatientAvatarEl('lab-patient-avatar-wrap', 'lab-patient-avatar-img', activePatient); _setPatientAvatarEl('reply-patient-avatar-wrap', 'reply-patient-avatar-img', activePatient); const nameEl = document.getElementById('lab-patient-name'); if (nameEl) nameEl.innerHTML = `${activePatient.name} \u2014 ${activePatient.dx}, ${activePatient.bio}`; const rl = document.getElementById('reply-patient-label'); if (rl) rl.textContent = `${activePatient.name} responds`; renderPatientSelector(); updateMarcusStateBadge(); } function closeLabRoom() { document.getElementById('lab-modal').style.display='none'; cancelVance(); // Return to hub instead of leaving the slide view behind showHomeGateway(); } function openVideoModal() { const modal = document.getElementById('video-modal'); if(modal) modal.style.display='flex'; } function closeVideoModal() { const modal = document.getElementById('video-modal'); if(modal) modal.style.display='none'; } // --- Lab Room Tabs --------------------------------------- function updateMarcusStateBadge() { const badge = document.getElementById('marcus-state-badge'); if(!badge) return; const map = { Neutral: { bg:'#dcfce7', color:'#166534', border:'#bbf7d0', dot:'⚫' }, Frustrated:{ bg:'#fef9c3', color:'#854d0e', border:'#fde68a', dot:'🟡' }, Rage: { bg:'#fee2e2', color:'#991b1b', border:'#fca5a5', dot:'🔴' } }; const s = map[marcusState] || map.Neutral; badge.style.background = s.bg; badge.style.color = s.color; badge.style.borderColor = s.border; badge.innerHTML = s.dot + ' ' + marcusState; } // --- Patient selector helpers ----------------------------- function _setPatientAvatarEl(wrapId, imgId, patient) { const wrap = document.getElementById(wrapId); const img = document.getElementById(imgId); if (!wrap) return; // Remove old initials span if any const old = wrap.querySelector('.pat-initials'); if (old) old.remove(); if (patient.avatar) { wrap.style.background = 'transparent'; if (img) { img.style.display = 'block'; img.src = patient.avatar; } } else { wrap.style.background = patient.color; if (img) img.style.display = 'none'; const t = document.createElement('span'); t.className = 'pat-initials'; t.style.cssText = 'pointer-events:none;font-size:.68rem;font-weight:900;color:white'; t.textContent = patient.initials; wrap.appendChild(t); } wrap.style.borderColor = patient.color; } function renderPatientSelector() { const sel = document.getElementById('patient-selector'); if (!sel) return; sel.innerHTML = LAB_PATIENTS.map(p => { const isActive = p.id === activePatient.id; const avatarHtml = p.avatar ? `` : `
${p.initials}
`; return ``; }).join(''); } function selectLabPatient(id) { const p = LAB_PATIENTS.find(x => x.id === id); if (!p) return; activePatient = p; marcusState = 'Neutral'; // Status bar — name/bio const nameEl = document.getElementById('lab-patient-name'); if (nameEl) nameEl.innerHTML = `${p.name} \u2014 ${p.dx}, ${p.bio}`; // Avatars _setPatientAvatarEl('lab-patient-avatar-wrap', 'lab-patient-avatar-img', p); _setPatientAvatarEl('reply-patient-avatar-wrap', 'reply-patient-avatar-img', p); // Reply label const label = document.getElementById('reply-patient-label'); if (label) label.textContent = `${p.name} responds`; // Auto-set dx dropdown const dxSel = document.getElementById('lab-dx'); if (dxSel) dxSel.value = p.dxKey; // Reset scenario UI const scenBox = document.getElementById('lab-scenario-box'); if (scenBox) scenBox.textContent = `Patient loaded: ${p.name} (${p.dx}). Click \u{1F3B2} New Scenario to begin.`; const respEl = document.getElementById('lab-response'); if (respEl) respEl.value = ''; const panel = document.getElementById('lab-score-panel'); if (panel) panel.style.display = 'none'; const mrw = document.getElementById('marcus-reply-wrap'); if (mrw) mrw.style.display = 'none'; renderPatientSelector(); updateMarcusStateBadge(); } async function switchLabTab(tab) { cancelVance(); // stop any Vance speech from the previous tab ['scenarios','debrief','script','microexp'].forEach(t => { const pane = document.getElementById('lab-tab-'+t); const btn = document.getElementById('lab-tab-btn-'+t); if(pane) pane.style.display = t === tab ? 'block' : 'none'; if(btn) { if(t === tab) { btn.classList.add('active'); } else { btn.classList.remove('active'); } } }); if(tab === 'microexp') updateMarcusStateBadge(); } async function runDebrief() { const btn = document.getElementById('debrief-btn'); const input = document.getElementById('debrief-input'); const output = document.getElementById('debrief-output'); const text = (input?.value||'').trim(); if(!text) { alert('Describe an incident first.'); return; } if(btn) { btn.disabled=true; btn.textContent='Analyzing…'; } if(output) output.innerHTML = 'Professor Vance is reviewing your incident·'; const sys = `You are Professor Vance, a calm and experienced clinical supervisor for psychiatric healthcare staff. Review this real incident and provide structured clinical feedback. Format your response using these four sections: WHAT WENT WELL | MISSED OPPORTUNITIES | NEXT TIME TRY | COMPETENCY LEVEL. Be supportive and growth-focused. This is about learning, not judgment. Keep response under 250 words.`; try { const res = await fetch(CHAT_ENDPOINT, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ system: sys, messages:[{role:'user',content:`Incident Debrief:\n${text}`}] }) }); const data = await res.json(); const reply = (data?.reply || data?.choices?.[0]?.message?.content || 'Unable to retrieve feedback.').trim(); if(output) output.textContent = reply; const expBtn = document.getElementById('debrief-export-btn'); if(expBtn) expBtn.style.display = ''; awardXP(100); } catch(e) { if(output) output.textContent = '⚠️ Professor Vance is temporarily unreachable. Check your internet connection and try again.'; } finally { if(btn) { btn.disabled=false; btn.textContent='Get Clinical Feedback ✨'; } } } function exportDebriefNote() { const incident = document.getElementById('debrief-input')?.value || ''; const feedback = document.getElementById('debrief-output')?.textContent || ''; const ud = window._userData || {}; const name = [ud.firstName, ud.lastName].filter(Boolean).join(' ') || 'Staff Member'; const org = window.TENANT?.name || 'NyxCodex\u00AE'; const dateStr = new Date().toLocaleDateString('en-US',{weekday:'long',year:'numeric',month:'long',day:'numeric'}); const win = window.open('','_blank'); if(!win) { alert('Pop-up blocked. Please allow pop-ups for this site.'); return; } win.document.write(`Clinical Supervision Note

${org} — Clinical Supervision Note

Date: ${dateStr}

Staff Member: ${name}

Department: ${ud.department||'—'}

Incident Description

${incident.replace(/&/g,'&').replace(//g,'>').replace(/\n/g,'
')}

Clinical Mentor Feedback (Prof. Vance)

${feedback.replace(/&/g,'&').replace(//g,'>').replace(/\n/g,'
')}


`); win.document.close(); } async function buildLabScriptAI() { const btn = document.getElementById('script-btn'); const topicEl = document.getElementById('script-topic'); const output = document.getElementById('script-output'); const topic = (topicEl?.value||'').trim() || 'Medication refusal'; if(btn) { btn.disabled=true; btn.textContent='Building…'; } if(output) output.innerHTML = 'Generating script…'; const sys = `You are a clinical trainer writing roleplay scripts for psychiatric staff training. Write a 1–2 minute realistic roleplay script between a STAFF MEMBER and a PATIENT in a psychiatric unit. Include: Patient dialogue, Staff dialogue, and [Directions] for body language. Demonstrate de-escalation principles including validation and the Wait-5 Rule. Format clearly with speaker labels. Be realistic — something teams can do in a 5-minute huddle.`; try { const res = await fetch(CHAT_ENDPOINT, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ system: sys, messages:[{role:'user',content:`Create a training script for this scenario: ${topic}`}] }) }); const data = await res.json(); const reply = (data?.reply || data?.choices?.[0]?.message?.content || 'Script generation failed.').trim(); if(output) output.innerHTML = reply.replace(/\n/g,'
').replace(/\[([^\]]+)\]/g,'[$1]'); awardXP(50); } catch(e) { if(output) output.textContent = '⚠️ Script generation unavailable — check your internet connection and try again.'; } finally { if(btn) { btn.disabled=false; btn.textContent='Build Script ?'; } } } async function generateLabScenario() { // Use active patient's dx key; sync the dropdown to match const dxSel = document.getElementById('lab-dx'); if (dxSel) dxSel.value = activePatient.dxKey; const dx = activePatient.dxKey; let pool; if (dx === 'ptsd') { // Keep Marcus-specific pool for PTSD patient pool = scenarios.ptsd.filter(s => s.includes('Marcus')); if (!pool.length) pool = scenarios.ptsd; } else { pool = scenarios[dx] || scenarios.general || []; } const idx = Math.floor(Math.random()*pool.length); const scen= pool[idx] || 'No scenario available for this category yet.'; currentLabDx = dx; currentLabScenarioId = `${dx}_${idx}`; document.getElementById('lab-scenario-box').textContent = scen; document.getElementById('lab-response').value = ''; document.getElementById('lab-score-panel').style.display='none'; document.getElementById('lab-feedback').textContent=''; document.getElementById('lab-cooldown').style.display='none'; document.getElementById('lab-submit-btn').disabled=false; const mrw = document.getElementById('marcus-reply-wrap'); if(mrw) mrw.style.display='none'; } // --- Audio response recording (Whisper) ------------------ let _labRecorder = null; let _labChunks = []; async function toggleLabMic() { const btn = document.getElementById('lab-mic-btn'); const label = document.getElementById('lab-mic-label'); const status = document.getElementById('lab-mic-status'); const textarea = document.getElementById('lab-response'); // Stop recording if (_labRecorder && _labRecorder.state === 'recording') { _labRecorder.stop(); return; } // Start recording let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch(e) { status.style.display = 'block'; status.textContent = 'Microphone access denied. Please allow microphone in browser settings.'; return; } const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : ''; _labChunks = []; _labRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {}); _labRecorder.ondataavailable = e => { if(e.data.size > 0) _labChunks.push(e.data); }; _labRecorder.onstart = () => { btn.style.background = '#fef2f2'; btn.style.borderColor = '#fca5a5'; btn.style.color = '#dc2626'; label.textContent = 'Stop recording ⏹'; status.style.display = 'none'; status.textContent = ''; }; _labRecorder.onstop = async () => { stream.getTracks().forEach(t => t.stop()); btn.style.background = '#f8fafc'; btn.style.borderColor = '#e2e8f0'; btn.style.color = '#475569'; label.textContent = 'Transcribing…'; btn.disabled = true; status.style.display = 'block'; status.textContent = '⏳ Transcribing your response…'; try { const blob = new Blob(_labChunks, { type: _labRecorder.mimeType || 'audio/webm' }); const reader = new FileReader(); const base64 = await new Promise(res => { reader.onloadend = () => res(reader.result.split(',')[1]); reader.readAsDataURL(blob); }); const resp = await fetch('/api/whisper', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audio: base64, mimeType: blob.type }) }); const data = await resp.json(); if (data.text) { textarea.value = (textarea.value ? textarea.value + ' ' : '') + data.text.trim(); status.textContent = '✓ Transcribed — edit text above if needed.'; status.style.color = '#16a34a'; } else { status.textContent = data.error || 'Transcription returned empty. Try speaking clearly.'; status.style.color = '#dc2626'; } } catch(e) { status.textContent = 'Transcription failed. Please type your response instead.'; status.style.color = '#dc2626'; } finally { btn.disabled = false; label.textContent = 'Speak response'; } }; _labRecorder.start(); } function submitLabScenario() { const resp = document.getElementById('lab-response').value.trim(); const diff = document.getElementById('lab-diff').value; const cooldown = document.getElementById('lab-cooldown'); if(resp.length<20){ alert('Please write a more detailed response (at least 20 characters).'); return; } if(!currentLabScenarioId){ alert('Generate a scenario first.'); return; } // Score based on response length and key words const words = resp.toLowerCase(); const keyTerms=['safe','calm','choice','validate','empathize','listen','space','support','trigger','reduce','grounding','breathe','acknowledge','help','understand']; const matched= keyTerms.filter(t=>words.includes(t)).length; let grade, xpGain; if(matched>=5||resp.length>200) { grade='A'; xpGain=250; } else if(matched>=3||resp.length>120) { grade='B'; xpGain=175; } else if(matched>=2||resp.length>60) { grade='C'; xpGain=100; } else { grade='D'; xpGain=50; } // Marcus state escalation/de-escalation if(grade==='A'||grade==='B') { if(marcusState==='Rage') marcusState='Frustrated'; else if(marcusState==='Frustrated') marcusState='Neutral'; } else if(grade==='D') { if(marcusState==='Neutral') marcusState='Frustrated'; else if(marcusState==='Frustrated') marcusState='Rage'; } updateMarcusStateBadge(); // Lab credit calculation with once-per-day per scenario guard const today = new Date().toISOString().slice(0,10); const lastRun = (window._userData?.lastLabRun||{})[currentLabScenarioId]; let lcEarned = 0; if(lastRun === today) { lcEarned = 0; if(cooldown){ cooldown.textContent = 'Credits already earned for this scenario today. Replays are practice-only.'; cooldown.style.display='block'; } } else { const base = labSettings.labBaseLC || 50; const bonusMap = labSettings.labScoreBonus || {A:50,B:30,C:15,D:5}; const diffMult = diff==='advanced'?1.1 : diff==='intensive'?1.2 : 1; lcEarned = Math.round((base + (bonusMap[grade]||0)) * diffMult); // persist lastLabRun const updated = Object.assign({}, window._userData?.lastLabRun||{}); updated[currentLabScenarioId] = today; window._saveUserData({ lastLabRun: updated, labCredits: labCredits + lcEarned }).catch(()=>{}); window._userData.lastLabRun = updated; awardLabCredits(lcEarned, false); } // UI updates document.getElementById('lab-score-panel').style.display='block'; document.getElementById('lab-score-letter').textContent = grade; document.getElementById('lab-lc-earned').textContent = lcEarned; document.getElementById('lab-feedback').innerHTML = 'Prof. Vance is reviewing your response·'; document.getElementById('lab-modal-credits').textContent = labCredits.toLocaleString(); document.getElementById('lab-submit-btn').disabled=true; scenariosDone++; scoreTotal += ['A','B','C','D'].indexOf(grade)+1; awardXP(xpGain); updateFinalStats(); // Fire lab webhook (non-blocking) window._fireWebhook('lab_completed', { email: window._currentUser?.email, displayName: window._currentUser?.displayName||window._currentUser?.email, scenarioId: currentLabScenarioId, diagnosis: currentLabDx, patient: (typeof activePatient !== 'undefined' ? activePatient.name : 'Marcus'), grade, lcEarned, timestamp: new Date().toISOString() }).catch(()=>{}); window._writeAudit('lab_completed', { patient: (typeof activePatient !== 'undefined' ? activePatient.name : 'Marcus'), diagnosis: currentLabDx, grade, lcEarned }).catch(()=>{}); // Track patient set for Full Spectrum badge const _patName = typeof activePatient !== 'undefined' ? activePatient.name : 'Marcus'; const _patSet = [...new Set([...(window._userData?._labPatientSet||[]), _patName])]; if(window._userData) window._userData._labPatientSet = _patSet; window._checkAchievements && window._checkAchievements({ scenariosCompleted: (window._userData?.scenariosCompleted||0), _lastLabGrade: grade, _labPatientSet: _patSet }).catch(()=>{}); window._emitXAPI('experienced', { completion:true, success: grade==='A'||grade==='B', score:{ raw:xpGain,min:0,max:250 } }).catch(()=>{}); // --- Mastery count for diagnosis specialist badges --- if(grade==='A'||grade==='B') { const _mc = Object.assign({}, window._userData?.masteryCount||{}); _mc[currentLabDx] = (_mc[currentLabDx]||0) + 1; window._saveUserData({ masteryCount: _mc }).catch(()=>{}); if(window._userData) window._userData.masteryCount = _mc; window._checkAchievements && window._checkAchievements({ masteryCount: _mc }).catch(()=>{}); } // --- Scenario history (capped at 50 entries) --- const _histEntry = { dx: currentLabDx, patient: (typeof activePatient!=='undefined' ? activePatient.name : 'Marcus'), grade, xp: xpGain, lc: lcEarned, ts: Date.now() }; const _hist = [...((window._userData?.scenarioHistory)||[]), _histEntry].slice(-50); window._saveUserData({ scenarioHistory: _hist }).catch(()=>{}); if(window._userData) window._userData.scenarioHistory = _hist; // Marcus in-character reply + AI clinical feedback const scenarioText = document.getElementById('lab-scenario-box')?.textContent || ''; generateMarcusReply(grade, scenarioText, resp); generateAIFeedback(grade, scenarioText, resp, currentLabDx || 'general'); } async function generateMarcusReply(grade, scenario, staffResponse) { const wrap = document.getElementById('marcus-reply-wrap'); const textEl = document.getElementById('marcus-reply-text'); const dotEl = document.getElementById('marcus-reply-state-dot'); if(!wrap || !textEl) return; // Show bubble with loading state wrap.style.display = 'block'; textEl.textContent = '...'; const stateStyles = { Neutral: { bg:'#dcfce7', color:'#166534', label:'Neutral' }, Frustrated:{ bg:'#fef9c3', color:'#854d0e', label:'Frustrated' }, Rage: { bg:'#fee2e2', color:'#991b1b', label:'Rage' } }; const ss = stateStyles[marcusState] || stateStyles.Neutral; if(dotEl){ dotEl.textContent = ss.label; dotEl.style.background = ss.bg; dotEl.style.color = ss.color; } const toneMap = { Neutral: 'You are slightly guarded but not hostile. You can be brief and flat — cautious, not warm.', Frustrated:'You are visibly irritated. Your words are short and clipped. You feel dismissed or talked at.', Rage: 'You are at a breaking point. Your reply is sharp, loud (implied), raw. One or two sentences maximum — you are barely holding it together.' }; const gradeContext = { A: 'The staff member responded well — they gave you space, validated your feelings, and offered a choice. You are slightly less tense.', B: 'The staff response was okay. Not great, but not threatening. You are still unsure.', C: 'The staff response felt robotic or clinical. It did not land for you. You feel unheard.', D: 'The staff response made things worse — too directive, too close, or dismissive. You feel cornered.' }; const sys = `${activePatient.persona} Current emotional state: ${marcusState}. ${toneMap[marcusState]} Keep your reply to 1-3 short sentences. No explanations, no narration — just ${activePatient.name} speaking. Do not use quotation marks.`; const userMsg = `Clinical scenario: ${scenario.slice(0,300)} A staff member just responded to you by saying: "${staffResponse.slice(0,250)}" ${gradeContext[grade]} How do you respond right now?`; // Also update the reply header label dynamically in case it wasn't set yet const rlDyn = document.getElementById('reply-patient-label'); if (rlDyn) rlDyn.textContent = `${activePatient.name} responds`; try { const res = await fetch(CHAT_ENDPOINT, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ system: sys, messages:[{role:'user', content: userMsg}] }) }); const data = await res.json(); const reply = (data?.reply || data?.choices?.[0]?.message?.content || '...').trim(); window._typewrite(textEl, reply, 18); } catch(e) { window._typewrite(textEl, 'Fine. Whatever.', 22); } } // --- AI-Powered Lab Feedback (Prof. Vance clinical coaching) ---------------- async function generateAIFeedback(grade, scenario, staffResponse, diagnosis) { const feedbackEl = document.getElementById('lab-feedback'); if(!feedbackEl) return; const dxLabel = { autism:'Autism Spectrum Disorder', adhd:'ADHD', schizophrenia:'Schizophrenia', bipolar:'Bipolar Disorder', ptsd:'PTSD', bpd:'Borderline Personality Disorder', medication_refusal:'Medication Refusal', general:'General' }[diagnosis] || diagnosis; const gradeDesc = {A:'Excellent (A)',B:'Good (B)',C:'Fair (C)',D:'Needs Improvement (D)'}[grade] || grade; const sys = `You are Professor Vance, a clinical de-escalation mentor at a high-acuity inpatient psychiatric facility. A BHT just submitted a response in a NyxCodex® training simulation. The grade has already been assigned (${gradeDesc}) — do NOT re-grade. Provide specific, actionable clinical feedback tied to THIS scenario and THIS response for a patient with ${dxLabel}. Reference real techniques by name: LEAP model, Choice Architecture, Reaction Gap, Trauma-Informed Care, or diagnosis-specific adaptations for ${dxLabel}. For B/C/D grades: give 2-3 concrete improvements the BHT can apply NEXT TIME. For A grade: identify 2-3 specific strengths and one advanced technique to elevate further. Limit to 5 sentences maximum. Begin directly with feedback — no introductions, no repetition of grade. Plain text only.`; const userMsg = `SCENARIO (${dxLabel}): ${scenario.slice(0,400)}\n\nBHT RESPONSE: "${staffResponse.slice(0,280)}"\n\nGrade: ${gradeDesc}. Provide specific clinical mentor feedback now.`; try { const res = await fetch(CHAT_ENDPOINT, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ system: sys, messages:[{role:'user', content: userMsg}] }) }); const data = await res.json(); const reply = (data?.reply || data?.choices?.[0]?.message?.content || '').trim(); if(reply && feedbackEl) { feedbackEl.style.transition = 'opacity .35s'; feedbackEl.style.opacity = '0'; setTimeout(() => { feedbackEl.textContent = reply || feedback[grade]; feedbackEl.style.opacity = '1'; }, 260); } } catch(_e) { // Graceful fallback to static feedback if(feedbackEl) feedbackEl.textContent = feedback[grade]; } } // --- Crisis Scenario Lab ---------------------------------- const scenarios = { autism:[ `James, 28, ASD Level 2, is eating in the dining room. The TV was turned on at high volume by another patient. He begins rocking rapidly, covers his ears, and makes a high-pitched sound. Staff approached him from behind and touched his shoulder. He spun around and shoved them away. He is now standing in the corner, rocking, eyes shut. He hasn't responded to verbal prompts for 4 minutes.`, `Maria, 19, non-verbal ASD, has been pacing the hallway for 30 minutes. Her AAC device fell earlier and hasn't been given back. She is now hitting the wall repeatedly with her palm and appears to be escalating.`, `Eli, 24, ASD Level 1, is in group therapy when the facilitator changes the scheduled topic without warning. He immediately stands up, knocks his chair back, and begins repeating "This is wrong, this is wrong" loudly. He refuses to sit and is blocking the doorway.`, `Sophie, 31, ASD/ID, completed her morning routine in the wrong order due to a staff scheduling change. She is now sitting on the floor of the bathroom, rocking, holding her toothbrush, and crying. She has not come to breakfast and refuses to leave.` ], adhd:[ `Devon, 14, ADHD combined type, was told he must wait 30 minutes before his phone time. He immediately began yelling "This is stupid!" and threw a pillow across the room. He's now pacing rapidly and muttering to himself.`, `Lexi, 22, ADHD-I, has been sitting in group for 45 minutes. She is now standing up, pushing her chair back, and declaring she "can't do this anymore" — she's moving toward the door.`, `Marcus T., 17, ADHD/ODD, was asked to put his phone away three times during evening programming. On the third ask he threw the phone across the room, said "You can't make me do anything," and is now standing in the corner of the day room refusing to engage.`, `Destiny, 29, ADHD combined type, became frustrated during an art group when another patient took her supplies. She has been speaking loudly and rapidly for 10 minutes about "everything being unfair here" and is starting to draw attention from other patients.` ], schizophrenia:[ `Robert, 45, schizophrenia paranoid type, approaches you in the hallway and says the night staff have been poisoning his food. He's not eaten in 36 hours. He's escalating and says he needs to "get out before they hurt him." He's blocking the hallway exit.`, `Diane, 34, is responding to internal stimuli during a quiet hour. She's whispering rapidly and pointing at the wall. Another patient approaches to see what's happening — Diane suddenly shouts "Get away from me, I know what you are!"`, `Victor, 58, schizophrenia with command hallucinations, refused his evening medications saying "the voice told me not to take them — they'll kill me if I do." He is pacing the hallway, agitated, and has refused to return to his room.`, `Camille, 42, has been calm for two days but this morning woke up believing she is being filmed for a government surveillance program. She is covering every reflective surface in her room with cloth and refuses to allow staff in. She is not threatening but is clearly distressed and disorganized.` ], bipolar:[ `Tyler, 30, bipolar I, manic episode, has been awake for 48 hours. He's writing a 40-page business plan, insists he doesn't need medication today, and has been speaking so fast staff cannot interrupt. He's asked 6 times to use the phone.`, `Sarah, 26, bipolar I, depressive phase, has not left her room in 18 hours. Staff found her lying on the floor staring at the ceiling. She says "What's the point of anything" and won't respond to further prompts.`, `James, 35, bipolar I manic, is in the common room loudly announcing that he is the CEO of a major corporation and that the other patients "work for him." He has been giving out his personal belongings to other patients as "bonuses" and is becoming irritable when staff try to redirect.`, `Priya, 22, bipolar II in a mixed state, was found crying in her room at 3AM and then 20 minutes later was laughing uncontrollably. She has now called the nurses station three times in the past hour. She states she has a plan to hurt herself but "doesn't really want to." She appears to be high risk.` ], ptsd:[ `A male nurse entered Ana's room without knocking while she was changing. Ana is a trauma survivor. She has now barricaded behind her bed, is hyperventilating, and keeps shouting "Don't come near me." She doesn't appear to recognize staff.`, `Marcus, 38, combat PTSD, was startled by a door slamming in the hallway. He is now in a crouched position near the wall, scanning the room, and appears to be in a flashback state saying "They're coming."`, `Marcus refused his morning medications and told staff "Those pills make me feel like I'm not myself." He is now pacing the hallway with fists clenched, and just told another patient to "stay out of my space." His voice is rising. Vitals were last taken 4 hours ago.`, `During group therapy, another patient described a combat story. Marcus abruptly stood up, knocked his chair backwards, and walked out of the room without a word. He is now sitting alone in the corner of the dayroom, hood up, knees pulled to his chest, with his back to the wall. He has not responded to two verbal check-ins from staff.`, `Marcus asked staff twice to lower the TV volume in the common room. It was not addressed. He is now standing at the nursing station, voice at a raised level, saying "Nobody listens in this place. I'm done with all of you." His affect is intense and his hands are visibly shaking.`, `It is 2:00 AM. Marcus was found by night staff sitting on the floor of the hallway outside his room, arms wrapped around himself, whispering. When asked if he is okay, he said "Just leave me alone. You wouldn't understand." He appears tearful and is not making eye contact.`, `Karen, 44, complex trauma/PTSD, has been admitted 24 hours. During her first vital signs check she became extremely distressed when the blood pressure cuff was applied without warning. She now refuses all physical assessments and says "Nobody touches me here." The charge nurse has asked you to obtain her morning vitals.`, `A care team meeting was called to discuss Marcus's treatment plan. He was not invited. When he overheard staff discussing his case in the hallway, he immediately went to his room and has refused to come out for the past two hours, stating "You're doing things to me without telling me. Just like before."` ], bpd:[ `Leah, 25, BPD, feels her discharge date was changed without her input. She approached the nursing desk crying and then began shouting that "nobody here cares about me." She said "I'll hurt myself if you discharge me." She's now in her room with the door locked.`, `Jaylen, 32, BPD, had a positive interaction with Staff A. When Staff B took over for the shift, Jaylen immediately said "You're the mean one — I hate when you work here." Staff B has set a limit. Jaylen is now escalating.`, `Monique, 27, BPD, overheard two staff members laughing near the nurses station during a difficult moment for her. She has now approached the station demanding to know "what's so funny" and is raising her voice. She's threatening to file a formal complaint and says "I know you were laughing at me."`, `Daniel, 20, BPD/MDD, was placed on 1:1 monitoring following a self-harm incident last night. He tells you he feels "like a prisoner" and that the monitoring itself is making him want to hurt himself more. He becomes tearful and says "You're punishing me for being sick." His affect is rapidly shifting from tearful to angry.` ], medication_refusal:[ `Marcus, 38, PTSD, is refusing his morning Prazosin. He tells you: "Last time I took that stuff I had nightmares so bad I woke up screaming. I'm not doing it again." He is sitting with his arms crossed, tone firm but calm. He has not been aggressive.`, `Victor, 58, schizophrenia, is refusing his depot injection appointment saying "I don't need that anymore. I'm better." He has been stable for 6 weeks post-last injection. He is at risk of rapid decompensation if the injection is missed. He is walking away from you.`, `Leah, 25, BPD, is refusing her evening Seroquel stating "It makes me feel fat and I can't think straight on it." She appears tearful and frustrated. She has not eaten today. Her psychiatrist is not immediately available.`, `James, 35, bipolar I, in early mania, is refusing his mood stabilizer, saying he "feels great for the first time in years" and that the medication is "ruining" that feeling. He is animated, fast-talking, and insists he knows better than the doctors about what he needs.` ] }; const feedback = { A:`? EXCELLENT RESPONSE: Outstanding clinical judgment demonstrated. Key strengths shown: — Safety and de-escalation were prioritized before any clinical task — Trauma-informed language used throughout — you met the patient where they were — Patient autonomy was honored through choice and collaboration — Your emotional regulation (calm tone, non-threatening posture) modeled regulation for the patient — You assessed before acting — no rushing to fix the problem Clinical takeaway: The best de-escalation interventions feel like a conversation, not a procedure. You embodied that here. This is the DSH standard of excellence.`, B:`? GOOD RESPONSE: Solid clinical skills with room to sharpen. Strengths demonstrated: — You correctly identified the escalation stage and adjusted your approach — Your language was largely therapeutic and non-confrontational — You maintained appropriate boundaries while showing genuine care To elevate to A-level: — Try offering two specific, concrete choices next time (Choice Architecture) — Reduce verbal input when the patient is overwhelmed — silence can de-escalate — Lead with validation before problem-solving or offering resources — Always announce your physical movements before making them Clinical takeaway: Strong foundations — refine the fine-tuning with repetition and peer feedback.`, C:`🟡 FAIR RESPONSE: Genuine effort, but key clinical elements were missing. Gaps to address: — Did you reduce environmental triggers BEFORE speaking? (noise, crowding, lighting) — Did you approach from the side at 45°, not head-on? — Was your tone actually calm, or just your words? — Did you start with validation, or did you jump to explaining/fixing? — Did you offer the patient any choices or sense of control? Specific technique to practice: The LEAP model — Listen, Empathize, Agree (on something small), Partner in a plan. Use it verbatim until it becomes natural. Clinical takeaway: The instinct to help is there — now build the technical muscle memory around it.`, D:`🔴 NEEDS IMPROVEMENT: This response used approaches that are likely to escalate a crisis. Critical issues to correct: — Commands, directives, and threats increase arousal in already-distressed patients — Approaching without announcing yourself or entering personal space without consent re-traumatizes — Clinical tasks (vitals, meds, compliance) must ALWAYS come after safety and rapport, not before — Managing your own reactions (frustration, urgency) is part of the clinical intervention Required review: — Reread the module for this patient's diagnosis — Practice the Validation + Choice + Presence formula: “I hear you. Here are two options. I'm not going anywhere.” — Complete a peer supervision session on a similar scenario Clinical takeaway: Everyone starts somewhere — this feedback is a learning tool, not a judgment. These skills are trainable with practice.` }; function generateScenario() { const dx = document.getElementById('scenario-dx').value; const pool= scenarios[dx]||scenarios.general||[]; const scen= pool[Math.floor(Math.random()*pool.length)] || 'No scenario available for this category yet.'; document.getElementById('scenario-box').textContent = scen; document.getElementById('scenario-response').value = ''; document.getElementById('scenario-feedback').innerHTML = '

Write your response and submit...

'; document.getElementById('scenario-score').style.display='none'; document.getElementById('submit-btn').disabled=false; scenarioActive=true; } function submitScenario() { const resp = document.getElementById('scenario-response').value.trim(); if(resp.length<20){ alert('Please write a more detailed response (at least 20 characters).'); return; } // Score based on response length and key words const words = resp.toLowerCase(); const keyTerms=['safe','calm','choice','validate','empathize','listen','space','support','trigger','reduce','grounding','breathe','acknowledge','help','understand']; const matched= keyTerms.filter(t=>words.includes(t)).length; let grade, xpGain; if(matched>=5||resp.length>200) { grade='A'; xpGain=250; } else if(matched>=3||resp.length>120) { grade='B'; xpGain=175; } else if(matched>=2||resp.length>60) { grade='C'; xpGain=100; } else { grade='D'; xpGain=50; } document.getElementById('scenario-feedback').innerHTML=`
${feedback[grade]}
`; document.getElementById('scenario-score').style.display='block'; document.getElementById('score-letter').textContent=grade; document.getElementById('score-xp').textContent=`+${xpGain} XP earned`; document.getElementById('submit-btn').disabled=true; scenariosDone++; scoreTotal += ['A','B','C','D'].indexOf(grade)+1; // 1=A,2=B,etc awardXP(xpGain); updateFinalStats(); } // --- Role-Play Script Builder ----------------------------- function buildScript() { const topic = document.getElementById('slide-script-topic').value.trim() || 'Medication Refusal'; const dx = document.getElementById('script-dx').value; const level = document.getElementById('script-level').value; const dxMap = {general:'General',autism:'Autism Spectrum Disorder',schizophrenia:'Schizophrenia',bpd:'Borderline Personality Disorder',ptsd:'PTSD',bipolar:'Bipolar Disorder (Manic)',medication_refusal:'Medication Refusal'}; const dxLabel = dxMap[dx]||dx; const script = ` +---------------------------------------------------------- NyxCodex® TRAINING SCRIPT Topic: ${topic} Diagnosis Context: ${dxLabel} Level: ${level.charAt(0).toUpperCase()+level.slice(1)} © 2026 NyxCodex® · Nyx Collective LLC — Training Use Only +---------------------------------------------------------- SCENARIO SETUP: --------------- Setting: Inpatient psychiatric acute care unit Characters: — STAFF — [Training participant plays this role] — PATIENT — [Facilitator/co-trainer plays this role] — OBSERVER — [Notes interventions & technique use] PATIENT BACKGROUND (For facilitator only): ----------------------------------------- Diagnosis: ${dxLabel} Current State: Escalating (Stage 2-3 of CPI model) Trigger: ${topic} OPENING — STAFF ENTERS: ----------------------- STAFF: [Knock or announce presence — approach calmly from side] "Hey [Name]. I wanted to check in with you for a minute. Is it okay if I sit here?" [WAIT — allow patient to respond or not respond] PATIENT: [Escalated response related to ${topic}] STAFF: [Validation — do NOT problem-solve yet] "I hear you. That sounds really frustrating." [Pause — silence is therapeutic] PATIENT: [Continues expressing distress] STAFF: [Empathy + offer choice] "What would help you feel more comfortable right now? We could [Option A] or [Option B] — your choice." PATIENT: [Either escalates, de-escalates, or tests limits] STAFF: [Respond to patient's lead] IF escalating: "I can see this is really hard. I'm not going anywhere. Take your time." IF limit-testing: "I care about what happens to you. That's why I can't do [X]. What I can do is [Y]." IF de-escalating: "That's good. You're doing great. Let's take a slow breath together." RESOLUTION: ----------- STAFF: [Collaborative problem-solve ONLY after calm] "Now that things are a bit calmer — can we talk about [topic]? I want to understand your perspective." POST-SCENARIO DEBRIEF QUESTIONS: ---------------------------------- 1. What did STAFF do well in the first 60 seconds? 2. When was empathy most clearly demonstrated? 3. What could have been done differently? 4. How did offering choice impact escalation level? 5. What diagnosis-specific techniques were used? INSTRUCTOR NOTES: ----------------- Key techniques to watch for: — Proxemics (approach angle, distance) — Voice modulation (tone match ? gradual slow) — Validation before any directive — Choice-offering language — Non-punitive limit setting ------------------------------------------------------------`; document.getElementById('slide-script-output').textContent = script; awardXP(50); } function printScript() { const content = document.getElementById('slide-script-output').textContent; const w = window.open('','_blank'); w.document.write(`NyxCodex® Training Script${content} `); w.document.close(); w.print(); } // --- Certificate ----------------------------------------- function downloadCertificate() { if(!finalQuizPassed) { alert('Please pass the 15-question knowledge check to unlock your certificate.'); return; } const user = window._currentUser; const ud = window._userData || {}; const T = window.TENANT || {}; const name = user ? (user.displayName||user.email) : (T.orgName||'DSH') + ' Staff Member'; const date = new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'}); const orgName = T.orgName || 'Your Organization'; const brandName = T.brandName || 'NyxCodex\u2122'; const tagline = T.tagline || '31-Module Inpatient Psychiatric Crisis Intervention Training'; const contactHrs = T.certContactHours || 4.0; const certType = T.certType || 'Internal Competency Verification'; const certProvider = T.certProvider || orgName + ' Education Dept.'; const adminName = T.trainingAdminName || 'Training & Education Department'; const domains = T.certCompetencyDomains || [ 'De-escalation & Crisis Communication','Trauma-Informed Care', 'Diagnosis-Specific Behavioral Interventions','Safety & Restraint Prevention', 'Cultural Humility & Person-Centered Care','Mandated Reporting & Documentation']; const renewalMonths = T.certRenewalMonths || 12; const renewalDate = (() => { const d2=new Date(); d2.setMonth(d2.getMonth()+renewalMonths); return d2.toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'}); })(); const verifyId = 'CERT-' + (user?.uid||'ANON').slice(-6).toUpperCase() + '-' + Date.now().toString(36).slice(-6).toUpperCase(); const jobInfo = [ud.jobTitle, ud.department].filter(Boolean).join(' \u2014 '); const html = `
${certType}
${contactHrs} Contact Hours
${orgName}
Certificate of Competency Verification
This certifies that
${name}
${jobInfo ? `
${jobInfo}
` : ''}
has successfully completed all competency requirements for
${brandName}
${tagline}
Competency Domains Verified
${domains.map(d=>`
${d}
`).join('')}
${[{l:'Completed',v:date,c:'#2563eb'},{l:'Knowledge Check',v:finalQuizScore+'/15',c:'#16a34a'},{l:'XP Earned',v:xp.toLocaleString(),c:'#7c3aed'},{l:'Clinician Level',v:'Lv '+level,c:'#d97706'},{l:'Lab Scenarios',v:String(scenariosDone||0),c:'#0891b2'}].map(s=>`
${s.l}
${s.v}
`).join('')}
${adminName}
${orgName}
🎓
${certProvider}
${date}
Date of Issue
✓ Valid through ${renewalDate} — Annual renewal required per ${orgName} policy
${verifyId}
`; const d = document.createElement('div'); d.style.position='fixed'; d.style.inset='0'; d.style.padding='32px'; d.style.background='#ffffff'; d.style.zIndex='9999'; d.style.overflow='auto'; d.style.display='flex'; d.style.justifyContent='center'; d.style.alignItems='flex-start'; d.innerHTML=html; document.body.appendChild(d); renderInlineLogos(); const certEl = d.firstElementChild; const cleanup = ()=>{ try{ d.remove(); }catch(_e){} }; const celebrate = () => { if(!window.confetti) return; const fire = (o) => window.confetti({ particleCount:90, spread:70, ...o }); fire({ origin:{x:0.5,y:0.65} }); setTimeout(()=>fire({ origin:{x:0.2,y:0.55}, angle:60, particleCount:70 }), 220); setTimeout(()=>fire({ origin:{x:0.8,y:0.55}, angle:120, particleCount:70 }), 440); setTimeout(()=>fire({ origin:{x:0.5,y:0.3}, spread:110, particleCount:130 }), 700); setTimeout(()=>window.confetti({ particleCount:55, spread:100, startVelocity:12, origin:{x:0.5,y:0.9}, scalar:1.5 }), 1050); setTimeout(()=>fire({ origin:{x:0.35,y:0.5}, angle:80, particleCount:60 }), 1400); setTimeout(()=>fire({ origin:{x:0.65,y:0.5}, angle:100, particleCount:60 }), 1600); }; const finishFlow = ()=>{ try { // Record completion timestamp once (do not overwrite if already stored) if(!window._userData?.completedAt) { window._saveUserData({ completedAt: Date.now() }).catch(()=>{}); } // Fire HR integrations const _compUser = window._currentUser; const _compPayload = { email: _compUser?.email, displayName: _compUser?.displayName||_compUser?.email, completedAt: new Date().toISOString(), xp, level, department: window._userData?.department||null, employeeId: window._userData?.employeeId||null }; window._fireWebhook('training_completed', _compPayload).catch(()=>{}); window._fireWebhook('cert_downloaded', _compPayload).catch(()=>{}); window._fireWebhook('cert_email', { ..._compPayload, subject: `Your ${window.TENANT?.name||'Training'} certificate is ready`, message: `Congratulations! ${_compPayload.displayName||'You'} completed the ${window.TENANT?.name||'de-escalation'} training. The certificate PDF is attached or can be downloaded from the training platform.`, trainingUrl: window.location.origin, }).catch(()=>{}); window._writeAudit('cert_downloaded', { xp, level, completedAt: new Date().toISOString() }).catch(()=>{}); window._scorm?.complete(finalQuizScore || 0, 15); // SCORM: report score + completion to LMS window._checkAchievements && window._checkAchievements({ completedAt: true }).catch(()=>{}); window._emitXAPI('completed', { completion:true, success:true, duration:'PT0S', extensions:{ 'https://www.destinysprings.com/extensions/xp': xp, 'https://www.destinysprings.com/extensions/level': level } }).catch(()=>{}); window._emitXAPI('passed', { completion:true, success:true, score:{ raw:xp, min:0 } }).catch(()=>{}); currentSlide = 0; showSlide(0); window._saveUserData({ currentSlide:0 }).catch(()=>{}); setTimeout(()=>{ const signOut = window.confirm('Certificate saved! Do you want to sign out? Press OK to sign out, Cancel to continue training.'); if(signOut) doSignOut(); }, 300); } catch(_e){} }; if(window.html2pdf && certEl){ window.html2pdf().from(certEl).set({ margin:0, filename:`${(window.TENANT?.orgId||'cert').toUpperCase()}-Certificate-${name.replace(/\s+/g,'-')}.pdf`, html2canvas:{scale:2,useCORS:true,backgroundColor:'#ffffff'}, jsPDF:{unit:'in',format:'letter',orientation:'landscape'} }).save().then(()=>{ cleanup(); celebrate(); finishFlow(); }).catch(()=>cleanup()); } else { alert('PDF library not loaded. Please try again in a moment.'); cleanup(); } } // --- Quick Reference Cards -------------------------------- const cardContent = { deescalation:{ title:'Top 10 De-escalation Phrases', icon:'💬', color:'#1e3a8a', items:[ '1. "I hear you. That sounds really frustrating."', '2. "What would help you feel more comfortable right now?"', '3. "You have a choice here: [A] or [B]."', '4. "I\'m not going anywhere. Take your time."', '5. "I care about what happens to you."', '6. "Let\'s figure this out together."', '7. "You are safe right now. I am here."', '8. "I need you to know I take this seriously."', '9. "What do you need from me right now?"', '10. "I\'m listening. I won\'t rush you."' ] }, diagnoses:{ title:'Diagnosis Quick Guide', icon:'🧠', color:'#5b21b6', items:[ 'AUTISM: Reduce stimulation first. No unexpected touch.', 'ADHD: Short sentences. Quick choices. Movement ok.', 'SCHIZOPHRENIA: Calm, reality-anchoring. No arguing with delusions.', 'BIPOLAR (MANIC): Brief directives. Don\'t match energy. Space.', 'PTSD: Always explain before touch. Orient to present. Ground.', 'BPD: Validate emotion. Consistent limits. Never threaten abandonment.' ] }, grounding:{ title:'Grounding Techniques', icon:'🌱', color:'#065f46', items:[ '5-4-3-2-1: 5 see, 4 touch, 3 hear, 2 smell, 1 taste', 'BOX BREATHING: 4 inhale — 4 hold — 4 exhale — 4 hold', 'TIPP: Temperature — Intense exercise — Paced breathing — Relaxation', 'SAFE PLACE: Guide to a calming mental image', 'OBJECT FOCUS: "Hold this — describe how it feels"', 'FEET ON FLOOR: "Feel your feet pressing on the ground"' ] }, suicide:{ title:'Suicide Risk C-SSRS Quick Reference', icon:'🛡️', color:'#991b1b', items:[ 'IDEATION: "Any thoughts of killing yourself?"', 'PLAN: "Have you thought about how you would do it?"', 'MEANS: "Do you have access to [method]?"', 'INTENT: "Do you intend to act on this?"', 'TIMELINE: "When are you thinking about?"', 'LOW: Document + increase monitoring', 'MODERATE: Immediate psych eval + 1:1', 'HIGH: STAY with patient. Call charge RN NOW.' ] }, escalation:{ title:'CPI Crisis Escalation Stages', icon:'◆', color:'#92400e', items:[ '1. BASELINE: Typical behavior — proactive engagement', '2. TRIGGER: Stressor occurs — validate + redirect', '3. AGITATION: Visible agitation — reduce stimulation', '4. ACCELERATION: Escalating toward crisis — limit setting', '5. CRISIS: Loss of control — safety protocol actived', '6. DE-ESCALATION: Decreasing — continue calm presence', '7. RECOVERY/DEBRIEF: After event — process + plan' ] }, trauma:{ title:'Trauma-Informed Principles', icon:'💙', color:'#1d4ed8', items:[ 'REALIZE: Most psychiatric patients have trauma histories', 'RECOGNIZE: "What happened to you?" not "What\'s wrong with you?"', 'RESPOND: Safety — Trust — Choice — Collaboration — Empowerment', 'RESIST RE-TRAUMATIZATION: Avoid power-over dynamics', 'NEVER: Unexpected touch — Public shaming — Removing all control', 'ALWAYS: Explain before you act — Offer options — Follow through' ] } }; function downloadCard(type) { const card = cardContent[type]; if(!card) return; const itemsHtml = card.items.map(i=>`
${i}
`).join(''); const html = `
${card.icon}
${card.title}
NyxCodex® · Nyx Collective LLC — Clinical Training Platform
${itemsHtml}
© 2026 NyxCodex® · Nyx Collective LLC — All Rights Reserved
`; const d = document.createElement('div'); d.style.position='fixed'; d.style.top='-9999px'; d.innerHTML=html; document.body.appendChild(d); if(window.html2pdf){ window.html2pdf().from(d).set({ margin:0.3, filename:`DSH-Card-${type}.pdf`, html2canvas:{scale:2}, jsPDF:{unit:'in',format:[4.5,7],orientation:'portrait'} }).save().then(()=>d.remove()); } else { const w=window.open('','_blank'); w.document.write(`${html} `); w.document.close(); w.print(); d.remove(); } } // --- Final Stats Update ----------------------------------- function updateFinalStats() { const fxp = document.getElementById('final-xp'); const flv = document.getElementById('final-level'); const fsc = document.getElementById('final-scenarios'); const fsg = document.getElementById('final-score'); const cxp = document.getElementById('cert-xp'); const clv = document.getElementById('cert-level'); const csc = document.getElementById('cert-scenarios'); const cqs = document.getElementById('cert-score'); if(fxp) fxp.textContent = xp.toLocaleString(); if(flv) flv.textContent = level; if(fsc) fsc.textContent = scenariosDone; if(fsg) fsg.textContent = scenariosDone ? ['A','B','C','D'][Math.round(scoreTotal/scenariosDone)-1]||'—' : '—'; if(cxp) cxp.textContent = xp.toLocaleString(); if(clv) clv.textContent = level; if(csc) csc.textContent = scenariosDone; if(cqs) cqs.textContent = finalQuizPassed ? `${finalQuizScore}/15` : 'Locked'; } // --- Sign Out --------------------------------------------- async function doSignOut() { if(breathInterval) clearInterval(breathInterval); if(window.speechSynthesis) window.speechSynthesis.cancel(); window._scorm?.finish(); // report SCORM session end before sign-out window._writeAudit('logout', {}).catch(()=>{}); window._signOut(); } // --- Fallback: support both direct call and event dispatch -- document.addEventListener('DSHUserLoaded', initApp); // Pre-cache the 4 idle nudge phrases so the first fire is instant async function _preCacheIdleNudges() { if(paxMuted) return; const nudges = [ "Don't miss the detail on the right - that could be a safety breach.", "This concept maps directly to how we approach Marcus. Think about the timing.", "This is certification exam material. Lock it in before moving on.", "Think of Marcus right now - how would this principle change your next move?" ]; // Stagger requests 4 seconds apart to avoid hammering the TTS API at startup nudges.forEach((text, i) => { setTimeout(async () => { const key = text.trim().slice(0,120); if(_vanceAudioCache.has(key)) return; try { const res = await fetch('/api/tts', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text}) }); if(!res.ok) return; const blob = await res.blob(); _vanceAudioCache.set(key, URL.createObjectURL(blob)); } catch(_e) {} }, 8000 + i * 4000); // start 8s after load, stagger by 4s }); } document.addEventListener('DSHUserLoaded', () => setTimeout(_preCacheIdleNudges, 500)); // --- Micro-Expression Drill ------------------------------------------------- const MICROEXP_ROUNDS = [ { prompt: "Marcus crosses his arms, jaw tightens, eyes narrow. He just heard 'You have to calm down.'", emotion: "Anger", tip: "Jaw tightening + narrowed eyes = suppressed anger. Never command calm - offer choice instead." }, { prompt: "Marcus goes still. Brows pull together and upward. Lips press together. A door just slammed.", emotion: "Fear", tip: "Inner brow raise + lip press = startle/fear. Lower voice, slow movements, give space." }, { prompt: "Marcus's gaze drops to the floor. Lip corners pull down slightly. He says 'It doesn't matter.'", emotion: "Sadness", tip: "Downward gaze + lip corners down = despair. Validate feelings - do not problem-solve." }, { prompt: "Marcus sits relaxed, face neutral, eyes mid-level. He responds to his name with 'Yeah.'", emotion: "Neutral", tip: "Relaxed facial muscles, neutral gaze = regulated state. Build rapport and offer choices now." }, { prompt: "Marcus's nostrils flare briefly. Upper lip twitches. He turns slightly away while you speak.", emotion: "Anger", tip: "Nostril flare + upper lip pull = micro-contempt. Avoid confrontational language immediately." }, { prompt: "Marcus glances rapidly toward the door twice. Breathing shallow. Hands grip chair arms.", emotion: "Fear", tip: "Eye movement to exits + shallow breathing = threat assessment. State room is safe; do not block exits." }, { prompt: "Marcus stares at the table. Shoulders slump. Has not responded in 90 seconds.", emotion: "Sadness", tip: "Withdrawn stillness + slumped posture = shutdown. Silence can be therapeutic - wait before re-prompting." }, { prompt: "Marcus makes brief eye contact, nods once, says 'I hear you.' Body language open.", emotion: "Neutral", tip: "Brief eye contact + open body = engagement signal. Transition to collaborative problem solving now." } ]; let _meIndex = 0, _meScore = 0, _meAnswered = false; let _meTimerHandle = null, _meTimerBarHandle = null; function startMicroExpDrill() { _meIndex = 0; _meScore = 0; _meAnswered = false; document.getElementById('microexp-score').textContent = '0'; document.getElementById('microexp-total').textContent = '0'; var res = document.getElementById('microexp-result-badge'); if(res) res.style.display = 'none'; var tip = document.getElementById('microexp-vance-tip'); if(tip) tip.style.display = 'none'; nextMicroExpRound(); } function nextMicroExpRound() { // Hide the Next/See Results button at the start of every round var nextBtn = document.getElementById('microexp-next-btn'); if(nextBtn) nextBtn.style.display = 'none'; if(_meIndex >= MICROEXP_ROUNDS.length) { var prompt = document.getElementById('microexp-prompt'); if(prompt) prompt.textContent = 'Drill complete! Score: ' + _meScore + '/' + MICROEXP_ROUNDS.length; var wrap = document.getElementById('microexp-timer-bar-wrap'); if(wrap) wrap.style.display = 'none'; awardXP(_meScore * 30); showXPToast(_meScore * 30, 'Micro-Exp Drill Complete'); speakWithVance('Excellent drill work. You identified ' + _meScore + ' out of ' + MICROEXP_ROUNDS.length + ' expressions correctly. In a real crisis, that reaction time saves lives.'); return; } _meAnswered = false; var round = MICROEXP_ROUNDS[_meIndex]; var prompt = document.getElementById('microexp-prompt'); if(prompt) prompt.textContent = round.prompt; var res = document.getElementById('microexp-result-badge'); if(res) res.style.display = 'none'; var tip = document.getElementById('microexp-vance-tip'); if(tip) tip.style.display = 'none'; var wrap = document.getElementById('microexp-timer-bar-wrap'); var bar = document.getElementById('microexp-timer-bar'); if(wrap) wrap.style.display = 'block'; if(bar) { bar.style.transition = 'none'; bar.style.width = '100%'; bar.style.background = '#22c55e'; } clearTimeout(_meTimerHandle); clearInterval(_meTimerBarHandle); var START = Date.now(), DURATION = 8000; // 8 seconds to study the expression _meTimerBarHandle = setInterval(function() { var pct = Math.max(0, 1 - (Date.now() - START) / DURATION); if(bar) { bar.style.transition='none'; bar.style.width=(pct*100)+'%'; bar.style.background=pct>0.5?'#22c55e':pct>0.25?'#f59e0b':'#ef4444'; } if(pct <= 0) clearInterval(_meTimerBarHandle); }, 50); _meTimerHandle = setTimeout(function() { if(!_meAnswered) answerMicroExp(null); }, DURATION); } async function answerMicroExp(chosen) { if(_meAnswered) return; _meAnswered = true; clearTimeout(_meTimerHandle); clearInterval(_meTimerBarHandle); var round = MICROEXP_ROUNDS[_meIndex]; var isRight = (chosen === round.emotion); if(isRight) _meScore++; document.getElementById('microexp-score').textContent = _meScore; document.getElementById('microexp-total').textContent = _meIndex + 1; var bar = document.getElementById('microexp-timer-bar'); if(bar) bar.style.width = '0'; var res = document.getElementById('microexp-result-badge'); if(res) { res.style.display = 'inline-block'; if(!chosen){ res.textContent='Too slow! Correct: '+round.emotion; res.style.background='#fee2e2'; res.style.color='#991b1b'; } else if(isRight){ res.textContent='Correct!'; res.style.background='#dcfce7'; res.style.color='#166534'; awardXP(30); } else{ res.textContent='Incorrect - was '+round.emotion; res.style.background='#fef9c3'; res.style.color='#854d0e'; } } var tipEl = document.getElementById('microexp-vance-tip'); var tipTxt = document.getElementById('microexp-vance-text'); if(tipEl && tipTxt) { tipTxt.textContent = round.tip; tipEl.style.display = 'block'; } speakWithVance(round.tip); _meIndex++; // Don't auto-advance — show a button so the user moves on when ready var nextBtn = document.getElementById('microexp-next-btn'); if(nextBtn) { nextBtn.textContent = _meIndex < MICROEXP_ROUNDS.length ? 'Next Round ?' : 'See Results ?'; nextBtn.style.display = 'inline-block'; } } // --------------------------------------------------------------------------- // ====================== FEATURE SUITE v2 ==================================== // --------------------------------------------------------------------------- // =========================================================================== // DEMO MODE // =========================================================================== async function startDemoMode() { try { await signInAnonymously(auth); window._demoMode = true; window._userData = { uid: window._currentUser?.uid || 'demo', firstName: 'Alex', lastName: 'Demo', name: 'Alex Demo', email: 'demo@destinysprings.com', department: 'Clinical Education', jobTitle: 'RN, Staff Nurse', xp: 4200, level: 9, tier: 'Gold', currentSlide: 14, labCredits: 3, moduleCount: 31, finalQuizScore: 13, trainingYear: new Date().getFullYear(), }; const banner = document.getElementById('demo-banner'); if (banner) { banner.style.display = 'block'; document.body.style.paddingTop = '2.5rem'; } } catch(e) { alert('Demo mode unavailable: ' + e.message); } } // =========================================================================== // MANAGER REASSIGNMENT — User Manage Modal // =========================================================================== let _ummUid = null; async function openUserManageModal(uid) { _ummUid = uid; const modal = document.getElementById('user-manage-modal'); if (!modal) return; modal.style.display = 'flex'; document.getElementById('umm-name').textContent = 'Loading…'; document.getElementById('umm-email').textContent = ''; try { const users = await window._loadAllUsers(); const u = users[uid]; if (!u) { alert('User not found.'); closeUserManageModal(); return; } const T = window.TENANT; const totalSl = T?.moduleCount || 31; const done = (u.currentSlide || 0) >= totalSl - 1; document.getElementById('umm-name').textContent = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email || uid; document.getElementById('umm-email').textContent = u.email || uid; document.getElementById('umm-fname').value = u.firstName || ''; document.getElementById('umm-lname').value = u.lastName || ''; document.getElementById('umm-dept').value = u.department || ''; document.getElementById('umm-title').value = u.jobTitle || ''; document.getElementById('umm-due').value = u.dueDate ? new Date(u.dueDate).toISOString().slice(0, 10) : ''; // Populate assign section const assignNote = document.getElementById('umm-assign-note'); const assignDue = document.getElementById('umm-assign-due'); const assignCur = document.getElementById('umm-assign-current'); if(assignNote) assignNote.value = u.assignedScenario?.note || ''; if(assignDue) assignDue.value = u.assignedScenario?.dueDate ? new Date(u.assignedScenario.dueDate).toISOString().slice(0,10) : ''; if(assignCur) assignCur.textContent = u.assignedScenario?.note ? `Current: ${u.assignedScenario.note}` : 'No scenario assigned'; const daysAgo = u.lastLogin ? Math.round((Date.now() - u.lastLogin) / 864e5) : null; const loginStr = daysAgo === 0 ? 'Today' : daysAgo === 1 ? 'Yesterday' : daysAgo != null ? `${daysAgo}d ago` : '—'; document.getElementById('umm-stats').innerHTML = [ { label: 'XP', val: (u.xp || 0).toLocaleString(), color: '#C9A84C' }, { label: 'Status', val: done ? '✅ Done' : `Slide ${u.currentSlide || 0}/${totalSl}`, color: done ? '#15803d' : '#d97706' }, { label: 'Last Login', val: loginStr, color: '#475569' }, ].map(s => `
${s.val}
${s.label}
`).join(''); } catch(e) { alert('Error loading user: ' + e.message); closeUserManageModal(); } } async function closeUserManageModal() { const modal = document.getElementById('user-manage-modal'); if (modal) modal.style.display = 'none'; _ummUid = null; } async function saveUserManage() { if (!_ummUid) return; const btn = document.querySelector('#user-manage-modal .btn-primary'); if (btn) { btn.textContent = 'Saving…'; btn.disabled = true; } try { const dueRaw = document.getElementById('umm-due').value; const data = { firstName: document.getElementById('umm-fname').value.trim(), lastName: document.getElementById('umm-lname').value.trim(), department: document.getElementById('umm-dept').value.trim(), jobTitle: document.getElementById('umm-title').value.trim(), dueDate: dueRaw ? new Date(dueRaw).getTime() : null, }; await window._updateUserData(_ummUid, data); if (btn) { btn.textContent = '✅ Saved!'; btn.disabled = false; } setTimeout(() => { if (btn) btn.textContent = 'Save Changes'; }, 2000); await window._writeAudit('admin_edit_user', { targetUid: _ummUid, fields: Object.keys(data) }); loadReports(); } catch(e) { alert('Save failed: ' + e.message); if (btn) { btn.textContent = 'Save Changes'; btn.disabled = false; } } } async function adminResetTraining() { if (!_ummUid) return; const name = document.getElementById('umm-name').textContent; if (!confirm(`Reset ALL training progress for ${name}? This cannot be undone.`)) return; await window._updateUserData(_ummUid, { currentSlide: 0, xp: 0, level: 1, tier: 'Bronze', labCredits: 0, finalQuizScore: null, completedAt: null, trainingYear: new Date().getFullYear(), }); await window._writeAudit('admin_reset_training', { targetUid: _ummUid }); alert(`✅ Training reset for ${name}.`); await openUserManageModal(_ummUid); loadReports(); } async function adminExtendDeadline(days) { if (!_ummUid) return; const users = await window._loadAllUsers(); const u = users[_ummUid]; const currentDue = u?.dueDate ? u.dueDate : Date.now(); const newDue = currentDue + days * 864e5; await window._updateUserData(_ummUid, { dueDate: newDue }); document.getElementById('umm-due').value = new Date(newDue).toISOString().slice(0, 10); alert(`✅ Deadline extended by ${days} days.`); loadReports(); } async function adminSendNudge() { if (!_ummUid) return; const users = await window._loadAllUsers(); const u = users[_ummUid]; if (!u) return; try { const settings = await window._getSettings(); const webhookUrl = settings?.reminderWebhookUrl || settings?.webhookUrl; if (!webhookUrl) { alert('No webhook URL configured. Add one in Settings > Org Setup Wizard.'); return; } await fetch(webhookUrl, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'training_reminder', uid: _ummUid, ...u, triggeredBy: 'admin_manual' }), }); alert('✅ Reminder webhook triggered for ' + (u.email || _ummUid)); await window._writeAudit('admin_send_reminder', { targetUid: _ummUid }); } catch(e) { alert('Webhook error: ' + e.message); } } // =========================================================================== // EMAIL REMINDER AUTOMATION // =========================================================================== async function sendDueDateReminders() { const resultEl = document.getElementById('reminder-result'); if (resultEl) resultEl.innerHTML = '

⏳ Sending reminders…

'; try { const settings = await window._getSettings(); const reminderDays = parseInt(document.getElementById('reminder-days-input')?.value || settings?.reminderDays || 3); const webhookUrl = settings?.reminderWebhookUrl || settings?.webhookUrl; if (!webhookUrl) { if (resultEl) resultEl.innerHTML = '

⚠️ No reminder webhook URL configured. Add one in Settings > Org Setup Wizard.

'; return; } await window._saveSettings({ reminderDays }); const users = await window._loadAllUsers(); const now = Date.now(); const threshold = reminderDays * 864e5; const eligible = Object.entries(users).filter(([, u]) => { if (!u.dueDate) return false; const totalSl = window.TENANT?.moduleCount || 31; if ((u.currentSlide || 0) >= totalSl - 1) return false; const msUntilDue = u.dueDate - now; return msUntilDue > 0 && msUntilDue <= threshold; }); let sent = 0, failed = 0; for (const [uid, u] of eligible) { try { await fetch(webhookUrl, { method: 'POST', mode: 'no-cors', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'training_reminder', uid, email: u.email, firstName: u.firstName, lastName: u.lastName, department: u.department, dueDate: new Date(u.dueDate).toLocaleDateString(), daysUntilDue: Math.ceil((u.dueDate - now) / 864e5), currentSlide: u.currentSlide || 0, trainingUrl: window.location.origin, }), }); sent++; } catch(_) { failed++; } } await window._writeAudit('bulk_reminders_sent', { count: sent, reminderDays }); if (resultEl) resultEl.innerHTML = `

✅ Sent ${sent} reminder${sent !== 1 ? 's' : ''}${failed ? ` · ${failed} failed` : ''}. ${eligible.length === 0 ? 'No users with upcoming deadlines in the reminder window.' : ''}

`; } catch(e) { if (resultEl) resultEl.innerHTML = `

Error: ${e.message}

`; } } // =========================================================================== // WHITE-LABEL ORG SETUP WIZARD // =========================================================================== async function openOnboardingWizard() { const modal = document.getElementById('org-wizard-modal'); if (!modal) return; modal.style.display = 'flex'; try { const settings = await window._getSettings(); const T = window.TENANT; document.getElementById('wiz-orgname').value = settings?.orgName || T?.name || ''; document.getElementById('wiz-brand').value = settings?.brandName || T?.shortName || ''; document.getElementById('wiz-address').value = settings?.address || T?.address || ''; document.getElementById('wiz-logo').value = settings?.logoUrl || T?.logoUrl || ''; document.getElementById('wiz-cprimary').value = settings?.colorPrimary || '#1e3a8a'; document.getElementById('wiz-caccent').value = settings?.colorAccent || '#C9A84C'; document.getElementById('wiz-cbutton').value = settings?.colorButton || '#B8892A'; document.getElementById('wiz-cbg').value = settings?.colorBg || '#0f172a'; document.getElementById('wiz-certtype').value = settings?.certType || T?.certType || ''; document.getElementById('wiz-certhours').value = settings?.certContactHours || T?.certContactHours || ''; document.getElementById('wiz-certprovider').value = settings?.certProvider || T?.certProvider || ''; document.getElementById('wiz-certrenewal').value = settings?.certRenewalMonths || T?.certRenewalMonths || 12; document.getElementById('wiz-remwh').value = settings?.reminderWebhookUrl || settings?.webhookUrl || ''; document.getElementById('wiz-remdays').value = settings?.reminderDays || 3; } catch(_) { /* pre-fill best-effort */ } } async function closeOrgWizard() { const modal = document.getElementById('org-wizard-modal'); if (modal) modal.style.display = 'none'; } async function saveOrgWizard() { const btn = document.querySelector('#org-wizard-modal .btn-primary'); if (btn) { btn.textContent = 'Saving…'; btn.disabled = true; } try { const overrides = { orgName: document.getElementById('wiz-orgname').value.trim(), brandName: document.getElementById('wiz-brand').value.trim(), address: document.getElementById('wiz-address').value.trim(), logoUrl: document.getElementById('wiz-logo').value.trim(), colorPrimary: document.getElementById('wiz-cprimary').value, colorAccent: document.getElementById('wiz-caccent').value, colorButton: document.getElementById('wiz-cbutton').value, colorBg: document.getElementById('wiz-cbg').value, certType: document.getElementById('wiz-certtype').value.trim(), certContactHours: parseFloat(document.getElementById('wiz-certhours').value) || null, certProvider: document.getElementById('wiz-certprovider').value.trim(), certRenewalMonths: parseInt(document.getElementById('wiz-certrenewal').value) || 12, reminderWebhookUrl: document.getElementById('wiz-remwh').value.trim(), reminderDays: parseInt(document.getElementById('wiz-remdays').value) || 3, }; await window._saveSettings(overrides); // Apply brand color CSS vars live const r = document.documentElement.style; if (overrides.colorPrimary) r.setProperty('--color-primary', overrides.colorPrimary); if (overrides.colorAccent) r.setProperty('--color-accent', overrides.colorAccent); if (overrides.colorButton) r.setProperty('--color-button', overrides.colorButton); // Patch TENANT in memory if (window.TENANT) { if (overrides.orgName) window.TENANT.name = overrides.orgName; if (overrides.logoUrl) window.TENANT.logoUrl = overrides.logoUrl; if (overrides.certType) window.TENANT.certType = overrides.certType; } await window._writeAudit('org_wizard_saved', { fields: Object.keys(overrides) }); closeOrgWizard(); if (btn) { btn.textContent = '✅ Apply & Save'; btn.disabled = false; } loadSettings(); } catch(e) { alert('Save failed: ' + e.message); if (btn) { btn.textContent = '✅ Apply & Save'; btn.disabled = false; } } } // =========================================================================== // PUSH NOTIFICATIONS // =========================================================================== async function saveVapidKey() { const key = document.getElementById('setting-vapid-pub')?.value.trim(); if (!key) { alert('Enter a VAPID public key first.'); return; } await window._saveSettings({ vapidPublicKey: key }); alert('✅ VAPID public key saved.'); } async function subscribeToPush() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { alert('Push notifications are not supported in this browser.'); return; async function sendPushCampaign() { const msg = document.getElementById('setting-push-msg')?.value.trim(); const resultEl = document.getElementById('push-campaign-result'); if(!msg) { alert('Enter a message first.'); return; } if(!confirm(`Send push notification to all subscribed staff?\n\n"${msg}"`)) return; if(resultEl) resultEl.innerHTML = '

⏳ Sending…

'; try { const res = await fetch('/api/push', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ action:'broadcast', message: msg, adminUid: window._currentUser?.uid }) }); const data = await res.json(); if(resultEl) resultEl.innerHTML = `

✅ Sent to ${data.sent||0} subscribers.

`; } catch(e) { if(resultEl) resultEl.innerHTML = `

Error: ${e.message}

`; } } } async function saveMonthlyChallenge() { const label = document.getElementById('setting-challenge-label')?.value.trim(); const target = parseInt(document.getElementById('setting-challenge-target')?.value||'0',10); await window._saveSettings({ monthlyChallenge: { label, target } }); if(window._settingsCache) window._settingsCache.monthlyChallenge = { label, target }; alert('Monthly challenge saved.'); } try { const settings = await window._getSettings(); const vapidKey = settings?.vapidPublicKey; if (!vapidKey) { alert('Push notifications are not configured yet. Ask your admin to set up the VAPID key in Settings → Push Notification Config.'); return; } const reg = await navigator.serviceWorker.ready; const existing = await reg.pushManager.getSubscription(); if (existing) { alert('✅ You are already subscribed to training reminders!'); return; } const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: _urlBase64ToUint8Array(vapidKey), }); // Persist subscription object await window._updateUserData(window._currentUser.uid, { pushSubscription: JSON.stringify(sub) }); // Register with server await fetch('/api/push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'subscribe', uid: window._currentUser.uid, subscription: sub }), }); // Update UI const btn = document.querySelector('#hub-push-wrap button'); if (btn) { btn.textContent = '✅ Reminders Enabled'; btn.disabled = true; btn.style.background = 'rgba(22,163,74,.08)'; btn.style.color = '#15803d'; btn.style.border = '1px solid rgba(22,163,74,.25)'; } alert('✅ Training reminders enabled! You’ll receive a push notification before your deadline.'); } catch(e) { alert('Could not subscribe: ' + e.message); } } function _urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map(c => c.charCodeAt(0))); } // =========================================================================== // SCENARIO BUILDER (Admin) // =========================================================================== async function loadScenarioBuilder() { const el = document.getElementById('at-scenarios'); if (!el) return; el.innerHTML = '

Loading scenarios…

'; try { const snap = await get(_ref('scenarios')); const scenarios = snap.exists() ? Object.entries(snap.val()) : []; const published = scenarios.filter(([,s]) => s.active !== false); const drafts = scenarios.filter(([,s]) => s.active === false); const renderRows = (list) => list.map(([id, s]) => { const isDraft = s.active === false; return `
${s.title || 'Untitled'} ${isDraft ? 'DRAFT' : '• LIVE'}
${(s.description || '').slice(0, 200)}
${s.role ? `👤 ${s.role}` : ''} ${s.difficulty ? `${s.difficulty==='crisis'?'🔴 Crisis':s.difficulty==='advanced'?'🟡 Advanced':'🟢 Foundational'}` : ''} ${s.tags ? s.tags.split(',').map(t => `${t.trim()}`).join('') : ''}
`; }).join(''); const publishedSection = published.length ? renderRows(published) : ''; const draftSection = drafts.length ? `
Drafts (${drafts.length})
${renderRows(drafts)}
` : ''; const rows = publishedSection + draftSection; el.innerHTML = `

📚 Custom Scenario Library

${scenarios.length} scenario${scenarios.length !== 1 ? 's' : ''} (${published.length} live, ${drafts.length} draft) — live ones inject into Lab sessions alongside AI-generated ones

${rows || '

No custom scenarios yet. Create your first one below.

'}

✏️ Create New Scenario

`; } catch(e) { el.innerHTML = `

Error: ${e.message}

`; } } async function saveCustomScenario() { const title = document.getElementById('sc-title')?.value.trim(); const description = document.getElementById('sc-description')?.value.trim(); if (!title || !description) { alert('Title and description are required.'); return; } const btn = document.getElementById('sc-save-btn'); if (btn) { btn.textContent = 'Saving…'; btn.disabled = true; } try { const id = `sc_${Date.now()}`; await set(_ref('scenarios/' + id), { title, description, role: document.getElementById('sc-role')?.value || '', difficulty: document.getElementById('sc-difficulty')?.value || 'foundational', tags: document.getElementById('sc-tags')?.value.trim() || '', opener: document.getElementById('sc-opener')?.value.trim() || '', active: true, createdAt: Date.now(), createdBy: window._currentUser?.uid || 'admin', }); await window._writeAudit('scenario_created', { id, title }); await loadScenarioBuilder(); } catch(e) { alert('Failed to save: ' + e.message); if (btn) { btn.textContent = '💾 Save Scenario'; btn.disabled = false; } } } async function deleteCustomScenario(id) { if (!confirm('Delete this scenario? It will be removed from the library immediately.')) return; await remove(_ref('scenarios/' + id)); await window._writeAudit('scenario_deleted', { id }); await loadScenarioBuilder(); } // =========================================================================== // CUSTOM SCENARIO INJECTION into Lab generateLabScenario // =========================================================================== window._getCustomScenarios = async function() { try { const snap = await get(_ref('scenarios')); if (!snap.exists()) return []; return Object.values(snap.val()).filter(s => s.active !== false); } catch(_) { return []; } }; // =========================================================================== // SHAREABLE LEADERBOARD // =========================================================================== function shareLeaderboard() { const T = window.TENANT; const cached = window._adminUsersCache || {}; const totalSl = T?.moduleCount || 31; const sorted = Object.values(cached) .sort((a, b) => (b.xp || 0) - (a.xp || 0)); if (!sorted.length) { alert('No user data loaded. Open the Leaderboard tab first.'); return; } const medals = ['🥇', '🥈', '🥉']; const rows = sorted.map((u, i) => { const nm = [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email || 'Staff Member'; const done = (u.currentSlide || 0) >= totalSl - 1; const pct = Math.min(100, Math.round(((u.currentSlide || 0) / (totalSl - 1)) * 100)); return ` ${medals[i] || i + 1} ${nm} ${u.department || '—'} ${(u.xp || 0).toLocaleString()}
${done ? '✅ Done' : pct + '%'} `; }).join(''); const generatedAt = new Date().toLocaleString(); const html = ` ${T?.name || 'Training'} Leaderboard
${T?.logoUrl ? `` : ''}

${T?.name || 'Training Platform'} — Leaderboard

Generated ${generatedAt} · ${sorted.length} staff members

${rows}
#NameDepartmentXPProgressStatus
`; const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const win = window.open(url, '_blank'); // Offer print dialog immediately after load if (win) win.addEventListener('load', () => win.focus()); setTimeout(() => URL.revokeObjectURL(url), 60000); } // =========================================================================== // SCENARIO PUBLISH TOGGLE // =========================================================================== async function toggleScenarioActive(id, makeActive) { await update(_ref('scenarios/' + id), { active: makeActive }); await window._writeAudit('scenario_toggled', { id, active: makeActive }); await loadScenarioBuilder(); } // --------------------------------------------------------------------------- async function rolloverExpiredCerts() { if(!confirm('Auto-rollover will reset training completion for all users whose cert year does not match the current training year. Continue?')) return; try { const users = await window._loadAllUsers() || {}; const settings = await window._getSettings() || {}; const certYear = settings.trainingYear || new Date().getFullYear(); const updates = {}; Object.entries(users).forEach(([uid, u]) => { if((u.trainingYear||0) < certYear && (u.currentSlide||0) > 0) { updates['users/' + uid + '/currentSlide'] = 0; updates['users/' + uid + '/scenariosDone'] = 0; updates['users/' + uid + '/trainingYear'] = certYear; } }); const count = Object.keys(updates).length / 3; if(!count){ alert('No expired certs found.'); return; } const { update: dbUpdate, ref: dbRef } = await import('https://www.gstatic.com/firebasejs/11.0.2/firebase-database.js'); // Use existing _ref and update from module if(window._dbUpdate) { await window._dbUpdate(updates); } else { alert(`${count} user(s) would be rolled over. (dbUpdate not exposed — add window._dbUpdate in module script.)`); return; } await window._writeAudit('cert_rollover', { count, certYear }); alert(`✅ Rolled over ${count} user cert(s) to year ${certYear}.`); } catch(e) { alert('Rollover error: ' + e.message); } } function previewScenario(id) { const scenarios = window._scenariosCache || {}; const s = scenarios[id]; if(!s) { alert('Scenario not found in cache.'); return; } const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,.7);backdrop-filter:blur(4px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem'; overlay.innerHTML = `

${(s.title||'Untitled').replace(/

${s.role ? `👤 ${s.role}` : ''}

${(s.description||'No description.').replace(/ ${s.objective ? `

🎯 Objective

${s.objective.replace(/

` : ''} ${s.tags ? `
${s.tags.split(',').map(t=>`${t.trim()}`).join('')}
` : ''}

Status: ${s.active===false?'⏸ Draft':'▶ Published'}  |  ID: ${id}

`; overlay.addEventListener('click', e => { if(e.target===overlay) overlay.remove(); }); document.body.appendChild(overlay); } // --------------------------------------------------------------------------- // THEME SYSTEM — Gold & Silver Light / Luxury Dark // --------------------------------------------------------------------------- function toggleTheme() { const current = document.documentElement.getAttribute('data-theme') || 'dark'; const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); localStorage.setItem('nyxTheme', next); _updateThemeUI(next); } function applyTheme() { const theme = localStorage.getItem('nyxTheme') || 'dark'; document.documentElement.setAttribute('data-theme', theme); _updateThemeUI(theme); } function _updateThemeUI(theme) { const isLight = theme === 'light'; // ── Toggle button text/style ──────────────────────────────────── const btn = document.getElementById('theme-toggle-btn'); const label = document.getElementById('theme-toggle-label'); const mobileLabel = document.getElementById('theme-toggle-label-mobile'); if (btn) { btn.title = isLight ? 'Switch to Dark Mode' : 'Switch to Gold & Silver Light Mode'; btn.style.background = isLight ? 'rgba(167,139,250,.1)' : 'rgba(184,137,42,.1)'; btn.style.borderColor = isLight ? 'rgba(167,139,250,.3)' : 'rgba(184,137,42,.3)'; btn.style.color = isLight ? '#a78bfa' : '#C9A84C'; // update icon + label text (first text node + span) btn.childNodes[0].textContent = isLight ? '🌙 ' : '✦ '; } if (label) label.textContent = isLight ? 'Dark' : 'Gold'; if (mobileLabel) mobileLabel.textContent = isLight ? 'Dark Mode' : 'Gold Mode'; // ── Nav inline-styled elements that CSS vars can't reach ──────── const g = id => document.getElementById(id); const navRow = g('main-nav-main-row'); if (navRow) { // CSS var --ui-panel handles bg via !important CSS rule; just sync border navRow.style.borderTopColor = isLight ? 'rgba(184,137,42,.14)' : 'rgba(255,255,255,.06)'; } const statGroup = g('nav-stats-group'); if (statGroup) statGroup.style.borderRightColor = isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.07)'; const divider = g('nav-divider'); if (divider) divider.style.background = isLight ? 'rgba(0,0,0,.08)' : 'rgba(255,255,255,.07)'; const prev = g('nav-prev-btn'); if (prev) { prev.style.background = isLight ? 'rgba(0,0,0,.06)' : 'rgba(255,255,255,.07)'; prev.style.borderColor = isLight ? 'rgba(0,0,0,.12)' : 'rgba(255,255,255,.12)'; prev.style.color = isLight ? '#1a1825' : 'white'; } const home = g('nav-home-btn'); if (home) { home.style.background = isLight ? 'rgba(0,0,0,.04)' : 'rgba(255,255,255,.05)'; home.style.borderColor = isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.1)'; } const acct = g('nav-account-btn'); if (acct) { acct.style.background = isLight ? 'rgba(0,0,0,.04)' : 'rgba(255,255,255,.05)'; acct.style.borderColor = isLight ? 'rgba(0,0,0,.1)' : 'rgba(255,255,255,.1)'; } const name = g('nav-name'); if (name) name.style.color = isLight ? '#1a1825' : 'white'; const signout = g('nav-signout-btn'); if (signout) { signout.style.background = isLight ? 'rgba(220,38,38,.07)' : 'rgba(239,68,68,.08)'; signout.style.borderColor = isLight ? 'rgba(220,38,38,.2)' : 'rgba(239,68,68,.18)'; signout.style.color = isLight ? '#dc2626' : '#fca5a5'; } } // ---------------------------------------------------------------------------