// ============================================================================= // SLA Calculator - Script // All business rules loaded from config.json. Zero hard-coded logic. // ============================================================================= let CONFIG = null; // ── API fetch helper ────────────────────────────────────────────────────────── // Automatically includes the in-memory access token as a Bearer header. // On 401, attempts one silent token refresh before giving up. async function apiFetch(path, options = {}) { const url = `/loreal-sla-calculator/api${path}`; const headers = { ...(options.headers || {}) }; if (currentAccessToken) headers['Authorization'] = `Bearer ${currentAccessToken}`; let res = await fetch(url, { ...options, credentials: 'include', headers }); if (res.status === 401) { // Try to refresh and retry once const refreshed = await tryRefresh(); if (refreshed && currentAccessToken) { headers['Authorization'] = `Bearer ${currentAccessToken}`; res = await fetch(url, { ...options, credentials: 'include', headers }); } } return res; } let currentStep = 1; let activeStages = [false, false, false, false, false, false, false, false]; let lastCalculationData = null; // ---- Usage Tracking ---- const TRACK_API = (() => { if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return null; const base = location.pathname.replace(/\/[^/]*$/, ''); return `${base}/api/events`; })(); async function getVisitorId() { const raw = [ navigator.language, screen.width + 'x' + screen.height, screen.colorDepth, Intl.DateTimeFormat().resolvedOptions().timeZone, navigator.hardwareConcurrency || '', ].join('|'); const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } function trackEvent(event, metadata = {}) { if (!TRACK_API) return; getVisitorId().then(visitor_id => { fetch(TRACK_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, page: 'calculator', visitor_id, metadata }), }).catch(() => {}); }); } // ---- Bootstrap ---- document.addEventListener('DOMContentLoaded', async () => { await initAuth(); await loadConfig(); initDarkMode(); initStepper(); initDatePickers(); bindEvents(); trackEvent('page_view'); }); async function loadConfig() { try { const res = await fetch('config.json?v=2026050801'); CONFIG = await res.json(); populateBriefTypes(); populateStageDropdowns(); } catch (e) { console.error('Failed to load config.json:', e); alert('Error loading configuration. Please ensure config.json is in the same directory.'); } } // ---- Populate Dropdowns from Config ---- function populateBriefTypes() { const sel = document.getElementById('briefType'); CONFIG.briefTypes.forEach(bt => { const opt = document.createElement('option'); opt.value = bt.id; opt.textContent = bt.label; sel.appendChild(opt); }); } function populateStageDropdowns() { const cfg = CONFIG.stageConfigs; // Stage 1 complexity populateSelect('stage1Complexity', cfg.stage1.fields.complexity.options, 'value', 'label'); // Stage 2 populateSelect('stage2MasteringComplexity', cfg.stage2.fields.masteringComplexity.options, 'value', 'label'); populateSelect('stage2CopyComplexity', cfg.stage2.fields.copyComplexity.options, 'value', 'label'); // Stage 4/5 word count populateSelect('stage45WordCount', cfg.stage4_5.fields.wordCount.options, 'value', 'label'); // Stage 6 populateSelect('stage6Complexity', cfg.stage6.fields.complexity.options, 'value', 'label'); populateSelect('stage6AssetVolume', cfg.stage6.fields.assetVolume.options, 'value', 'label'); // Stage 8 populateSelect('stage8SyndicationType', cfg.stage8.fields.syndicationType.options, 'value', 'label'); document.getElementById('stage8SyndicationType').value = cfg.stage8.fields.syndicationType.default; } function populateSelect(id, options, valueKey, labelKey) { const sel = document.getElementById(id); if (!sel) return; sel.innerHTML = ''; options.forEach(opt => { const o = document.createElement('option'); o.value = opt[valueKey]; o.textContent = opt[labelKey]; sel.appendChild(o); }); } // ---- Dark Mode ---- function initDarkMode() { const saved = localStorage.getItem('sla-dark-mode'); if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('dark'); } document.getElementById('darkModeToggle').addEventListener('click', () => { document.documentElement.classList.toggle('dark'); localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark')); }); } // ---- Stepper ---- function initStepper() { document.querySelectorAll('.step-item').forEach(item => { item.addEventListener('click', () => { const step = parseInt(item.dataset.step); if (step <= currentStep || canAdvanceTo(step)) { goToStep(step); } }); }); } function canAdvanceTo(step) { if (step >= 2 && !document.getElementById('briefType').value) return false; if (step >= 4 && !document.getElementById('kickOffDate').value) return false; return true; } function goToStep(step) { if (step > currentStep + 1) return; // Validation gates if (step >= 2 && !document.getElementById('briefType').value) return; if (step >= 4 && !document.getElementById('kickOffDate').value) { alert('Please enter the Project Kick-Off Date before proceeding.'); return; } currentStep = step; // Show/hide step content document.querySelectorAll('.step-content').forEach(s => s.classList.add('hidden')); const target = document.getElementById('step' + step); if (target) { target.classList.remove('hidden'); target.classList.add('fade-in'); } // Update stepper UI document.querySelectorAll('.step-item').forEach(item => { const s = parseInt(item.dataset.step); const num = item.querySelector('.step-number'); if (s < step || (s === step && step === 4)) { num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-green-500 text-white text-sm font-semibold shrink-0'; num.innerHTML = ''; } else if (s === step) { num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-brand-600 text-white text-sm font-semibold shrink-0'; num.textContent = s; } else { num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-sm font-semibold shrink-0'; num.textContent = s; } }); // Run calculation when entering step 4 if (step === 4) { calculateSLA(); } window.scrollTo({ top: 0, behavior: 'smooth' }); } // ---- Date Pickers ---- function initDatePickers() { const fpConfig = { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd M Y', disableMobile: true, minDate: 'today', disable: [ function(date) { // Disable weekends (Sat=6, Sun=0) return date.getDay() === 0 || date.getDay() === 6; } ] }; flatpickr('#kickOffDate', { ...fpConfig, onChange: () => validateStep3() }); flatpickr('#goLiveDate', { ...fpConfig, onChange: () => validateStep3() }); } function validateStep3() { const kick = document.getElementById('kickOffDate').value; const btn = document.getElementById('btnStep3Next'); btn.disabled = !kick; } // ---- Event Bindings ---- function bindEvents() { // Brief type change document.getElementById('briefType').addEventListener('change', onBriefTypeChange); // Stage toggles document.querySelectorAll('.stage-toggle').forEach(toggle => { toggle.addEventListener('change', onStageToggle); }); // Production complexity/volume change -> update base days and opera days const stage6Inputs = ['stage6Complexity', 'stage6AssetVolume']; stage6Inputs.forEach(id => { document.getElementById(id).addEventListener('change', () => { updateProductionBaseDays(); updateOperaDays(); }); }); // Syndication type change -> update base days document.getElementById('stage8SyndicationType').addEventListener('change', updateSyndicationBaseDays); } // ---- Brief Type Change ---- function onBriefTypeChange() { const briefId = document.getElementById('briefType').value; if (!briefId) { document.getElementById('projectTypeDisplay').textContent = 'Select a Brief Type first'; document.getElementById('briefDescription').classList.add('hidden'); document.getElementById('stageMatrixPreview').classList.add('hidden'); document.getElementById('btnStep1Next').disabled = true; return; } const bt = CONFIG.briefTypes.find(b => b.id === briefId); document.getElementById('projectTypeDisplay').textContent = bt.projectType; document.getElementById('projectTypeDisplay').classList.remove('text-gray-500'); document.getElementById('projectTypeDisplay').classList.add('font-semibold'); // Description const descEl = document.getElementById('briefDescription'); if (Array.isArray(bt.description)) { descEl.innerHTML = ''; } else { descEl.textContent = bt.description; } descEl.classList.remove('hidden'); // Stage matrix const matrix = CONFIG.stageMatrix[briefId]; activeStages = [...matrix]; // Force always-active stages (e.g. Opera Upload) CONFIG.stages.forEach((stage, i) => { const cfg = CONFIG.stageConfigs['stage' + stage.number]; if (cfg && cfg.alwaysActive) activeStages[i] = true; }); renderStageMatrixBadges(activeStages); document.getElementById('stageMatrixPreview').classList.remove('hidden'); document.getElementById('btnStep1Next').disabled = false; // Update stage cards visibility and toggles updateStageCards(); updateProductionBaseDays(); updateOperaDays(); updateSyndicationBaseDays(); } function renderStageMatrixBadges(matrix) { const container = document.getElementById('stageMatrixBadges'); container.innerHTML = ''; CONFIG.stages.forEach((stage, i) => { const active = matrix[i]; const cfg = CONFIG.stageConfigs['stage' + stage.number]; const locked = cfg && cfg.alwaysActive; const badge = document.createElement('div'); badge.className = `flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium select-none transition-colors ${ locked ? 'cursor-default' : 'cursor-pointer' } ${ active ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40' : 'bg-gray-50 dark:bg-gray-700/50 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600/50' }`; badge.innerHTML = ` ${active ? 'Y' : 'N'} ${stage.shortLabel}${locked ? ' (Required)' : ''} `; if (!locked) { badge.addEventListener('click', () => { // Stages 4 & 5 (Translation) toggle together if (i === 3 || i === 4) { activeStages[3] = !activeStages[3]; activeStages[4] = activeStages[3]; } else { activeStages[i] = !activeStages[i]; } renderStageMatrixBadges(activeStages); updateStageCards(); updateProductionBaseDays(); updateOperaDays(); updateSyndicationBaseDays(); }); } container.appendChild(badge); }); } // ---- Stage Cards ---- function updateStageCards() { const cardMap = { 0: 'stage1Card', 1: 'stage2Card', 2: 'stage3Card', 3: 'stage45Card', // stage 4 4: 'stage45Card', // stage 5 (same card) 5: 'stage6Card', 6: 'stage7Card', 7: 'stage8Card' }; // Show/hide and set toggles const shownCards = new Set(); for (let i = 0; i < 8; i++) { const cardId = cardMap[i]; if (shownCards.has(cardId)) continue; shownCards.add(cardId); const card = document.getElementById(cardId); if (!card) continue; // Determine if this card's stages are active let isActive; if (cardId === 'stage45Card') { isActive = activeStages[3] || activeStages[4]; } else { isActive = activeStages[i]; } card.classList.remove('hidden'); // Check if this stage is always-active (locked) const stageNum = CONFIG.stages[i] ? CONFIG.stages[i].number : null; const stageCfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null; const locked = stageCfg && stageCfg.alwaysActive; // Set toggle const toggle = card.querySelector('.stage-toggle'); if (toggle) { toggle.checked = isActive; // Disable toggle for always-active stages if (locked) { toggle.disabled = true; toggle.closest('label').classList.add('opacity-50', 'cursor-not-allowed'); toggle.closest('label').classList.remove('cursor-pointer'); } // Update card appearance const fields = card.querySelector('.stage-fields'); if (fields) { if (isActive) { card.classList.remove('inactive'); fields.style.display = ''; } else { card.classList.add('inactive'); fields.style.display = 'none'; } } } } } function onStageToggle(e) { const stageKey = e.target.dataset.stage; const checked = e.target.checked; // Prevent disabling always-active stages if (!checked) { const idx = parseInt(stageKey); const stageNum = CONFIG.stages[idx] ? CONFIG.stages[idx].number : null; const cfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null; if (cfg && cfg.alwaysActive) { e.target.checked = true; return; } } if (stageKey === '3_4') { // Translation stages 4 and 5 activeStages[3] = checked; activeStages[4] = checked; } else { const idx = parseInt(stageKey); activeStages[idx] = checked; } // Update card appearance const card = e.target.closest('.stage-card'); const fields = card.querySelector('.stage-fields'); if (fields) { if (checked) { card.classList.remove('inactive'); fields.style.display = ''; } else { card.classList.add('inactive'); fields.style.display = 'none'; } } // Update badges renderStageMatrixBadges(activeStages); } // ---- Dynamic Lookups ---- function updateProductionBaseDays() { const complexity = document.getElementById('stage6Complexity').value; const volume = document.getElementById('stage6AssetVolume').value; const key = complexity + '_' + volume; const table = CONFIG.stageConfigs.stage6.fields.crossReferenceTable; const days = table[key]; document.getElementById('stage6BaseDays').textContent = days !== undefined ? days + ' days' : '--'; } function updateOperaDays() { const volume = document.getElementById('stage6AssetVolume').value; const daysMap = CONFIG.stageConfigs.stage7.daysByAssetVolume; const days = daysMap[volume] || CONFIG.stageConfigs.stage7.defaultDays; document.getElementById('stage7Days').textContent = days + ' day' + (days > 1 ? 's' : ''); } function updateSyndicationBaseDays() { const value = document.getElementById('stage8SyndicationType').value; const opt = CONFIG.stageConfigs.stage8.fields.syndicationType.options.find(o => o.value === value); const days = opt ? opt.days : undefined; document.getElementById('stage8BaseDays').textContent = days !== undefined ? days + ' days' : '--'; } // ---- Business Day Arithmetic ---- function addBusinessDays(startDate, days) { const result = new Date(startDate); let added = 0; while (added < days) { result.setDate(result.getDate() + 1); const dow = result.getDay(); if (dow !== 0 && dow !== 6) { added++; } } return result; } function businessDaysBetween(start, end) { let count = 0; const d = new Date(start); while (d < end) { d.setDate(d.getDate() + 1); const dow = d.getDay(); if (dow !== 0 && dow !== 6) count++; } return count; } function formatDate(date) { if (!date) return 'n.a.'; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return date.getDate().toString().padStart(2, '0') + '-' + months[date.getMonth()]; } function formatDateLong(date) { if (!date) return '--'; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return date.getDate() + ' ' + months[date.getMonth()] + ' ' + date.getFullYear(); } // ---- SLA Calculation Engine ---- function calculateSLA() { const kickOffStr = document.getElementById('kickOffDate').value; const goLiveStr = document.getElementById('goLiveDate').value; const briefName = document.getElementById('briefName').value || 'Unnamed Brief'; if (!kickOffStr) return; const kickOff = new Date(kickOffStr + 'T00:00:00'); const goLive = goLiveStr ? new Date(goLiveStr + 'T00:00:00') : null; const cfg = CONFIG.stageConfigs; const results = []; let currentDate = new Date(kickOff); // Stage 1: Missing DMI Asset Creation results.push(calcGenericStage(0, activeStages[0], currentDate, () => { const complexity = document.getElementById('stage1Complexity').value; const opt = cfg.stage1.fields.complexity.options.find(o => o.value === complexity); const wip = opt ? opt.days : 0; const feedback = intVal('stage1MarketApproval'); const rounds = intVal('stage1RevisionRounds'); const revDays = intVal('stage1RevisionDays'); return { handover: 0, wip, feedback, revisions: rounds * revDays, rounds }; })); currentDate = results[0].completeDate; // Stage 2: Mastering, Copy Creation / Extraction results.push(calcGenericStage(1, activeStages[1], currentDate, () => { const masterOpt = cfg.stage2.fields.masteringComplexity.options.find(o => o.value === document.getElementById('stage2MasteringComplexity').value); const copyOpt = cfg.stage2.fields.copyComplexity.options.find(o => o.value === document.getElementById('stage2CopyComplexity').value); const masterDays = masterOpt ? masterOpt.days : 0; const copyDays = copyOpt ? copyOpt.days : 0; const wip = masterDays + copyDays; return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0 }; })); currentDate = results[1].completeDate; // Stage 3: Global Rollout Invitation results.push(calcGenericStage(2, activeStages[2], currentDate, () => { const wip = intVal('stage3RolloutDays'); return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true }; })); currentDate = results[2].completeDate; // Stages 4 & 5: Translation (run in parallel, so counted once) const translationActive = activeStages[3] || activeStages[4]; const translationResult = calcGenericStage(3, translationActive, currentDate, () => { const wcOpt = cfg.stage4_5.fields.wordCount.options.find(o => o.value === document.getElementById('stage45WordCount').value); const wip = wcOpt ? wcOpt.days : 0; const feedback = intVal('stage45MarketApproval'); const rounds = intVal('stage45RevisionRounds'); const revDays = intVal('stage45RevisionDays'); return { handover: 0, wip, feedback, revisions: rounds * revDays, rounds }; }); // Stage 4 result results.push({ ...translationResult, stageIndex: 3, label: CONFIG.stages[3].label }); // Stage 5 result (same dates since parallel) results.push({ ...translationResult, stageIndex: 4, label: CONFIG.stages[4].label, active: activeStages[4] }); currentDate = translationResult.completeDate; // Stage 6: Production results.push(calcGenericStage(5, activeStages[5], currentDate, () => { const complexity = document.getElementById('stage6Complexity').value; const volume = document.getElementById('stage6AssetVolume').value; const key = complexity + '_' + volume; const table = cfg.stage6.fields.crossReferenceTable; let wip = table[key] || 0; // Apply speed-up const speedUp = intVal('stage6SpeedUp'); if (speedUp > 0) { wip = Math.ceil(wip * (1 - speedUp / 100)); } const feedback = intVal('stage6MarketApproval'); const rounds = intVal('stage6RevisionRounds'); const revDays = intVal('stage6RevisionDays'); return { handover: 0, wip, feedback, revisions: rounds * revDays, rounds }; })); currentDate = results[5].completeDate; // Stage 7: Opera Upload results.push(calcGenericStage(6, activeStages[6], currentDate, () => { const volume = document.getElementById('stage6AssetVolume').value; const daysMap = cfg.stage7.daysByAssetVolume; const wip = daysMap[volume] || cfg.stage7.defaultDays; return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true }; })); currentDate = results[6].completeDate; // Stage 8: Syndication results.push(calcGenericStage(7, activeStages[7], currentDate, () => { const value = document.getElementById('stage8SyndicationType').value; const opt = cfg.stage8.fields.syndicationType.options.find(o => o.value === value); const wip = opt ? opt.days : 0; return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true }; })); const projectEndDate = results[7].completeDate; // Suggested go-live: add syndication buffer if syndication is active let suggestedGoLive = new Date(projectEndDate); if (activeStages[7]) { suggestedGoLive = addBusinessDays(projectEndDate, CONFIG.meta.syndicationBufferDays); } // Verdict: compare against suggestedGoLive (includes syndication buffer) // so that retailer lead times are factored into the Y/N decision const canMeet = goLive ? suggestedGoLive <= goLive : null; renderResults({ briefName, kickOff, goLive, projectEndDate, suggestedGoLive, canMeet, stages: results }); } function calcGenericStage(stageIndex, isActive, prevDate, getParams) { const stage = CONFIG.stages[stageIndex]; if (!isActive) { return { stageIndex, label: stage.label, active: false, handover: 0, wip: 0, feedback: 0, revisions: 0, rounds: 0, noFeedback: false, completeDate: new Date(prevDate), kickOffDate: null, wipCompleteDate: null, feedbackByDate: null }; } const params = getParams(); const totalDays = params.handover + params.wip + params.feedback + params.revisions; const kickOffDate = addBusinessDays(prevDate, 0); // Next business day check not needed; start from prev // Actually kickoff is the day after previous stage completes const stageKickOff = addBusinessDays(prevDate, params.handover); const wipComplete = addBusinessDays(stageKickOff, params.wip); const feedbackBy = params.feedback > 0 ? addBusinessDays(wipComplete, params.feedback) : null; const completeDate = addBusinessDays(prevDate, totalDays); // Build sub-phase array for Gantt detail view const phases = []; if (params.handover > 0) { phases.push({ label: 'Handover', type: 'handover', start: new Date(prevDate), end: new Date(stageKickOff) }); } if (params.wip > 0) { phases.push({ label: 'WIP 1', type: 'wip', start: new Date(stageKickOff), end: new Date(wipComplete) }); } if (!params.noFeedback && params.rounds > 0) { const revDaysPerRound = params.revisions / params.rounds; let cursor = new Date(wipComplete); // First feedback period phases.push({ label: 'Feedback 1', type: 'feedback', start: new Date(cursor), end: new Date(feedbackBy) }); cursor = new Date(feedbackBy); // Each revision round (WIP 2, WIP 3, ...) for (let r = 1; r <= params.rounds; r++) { const revEnd = addBusinessDays(cursor, revDaysPerRound); phases.push({ label: `WIP ${r + 1}`, type: 'revision', start: new Date(cursor), end: revEnd }); cursor = revEnd; } } else if (!params.noFeedback && params.feedback > 0) { phases.push({ label: 'Feedback', type: 'feedback', start: new Date(wipComplete), end: new Date(completeDate) }); } return { stageIndex, label: stage.label, active: true, handover: params.handover, wip: params.wip, feedback: params.feedback, revisions: params.revisions, rounds: params.rounds || 0, noFeedback: params.noFeedback || false, completeDate, kickOffDate: stageKickOff, wipCompleteDate: wipComplete, feedbackByDate: feedbackBy, phases }; } function intVal(id) { const el = document.getElementById(id); return el ? parseFloat(el.value) || 0 : 0; } // ---- Render Results ---- function renderResults(data) { lastCalculationData = data; const briefType = document.getElementById('briefType').selectedOptions[0]?.text; trackEvent('show_results', { briefType }); // Verdict banner const banner = document.getElementById('verdictBanner'); const badge = document.getElementById('verdictBadge'); const msg = document.getElementById('verdictMessage'); document.getElementById('verdictBriefName').textContent = data.briefName; const roundel = 'w-20 h-20 sm:w-24 sm:h-24 rounded-full flex items-center justify-center flex-shrink-0 shadow-lg'; const icon = 'w-10 h-10 sm:w-12 sm:h-12'; if (data.canMeet === null) { banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-600'; badge.className = `${roundel} bg-gray-300 dark:bg-gray-600`; badge.innerHTML = `?`; msg.textContent = 'Enter a Go-Live Date to see the verdict.'; } else if (data.canMeet) { banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-800'; badge.className = `${roundel} bg-green-500 dark:bg-green-600`; badge.innerHTML = ``; msg.textContent = CONFIG.verdictMessages.canMeet; } else { banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-800'; badge.className = `${roundel} bg-red-500 dark:bg-red-600`; badge.innerHTML = ``; const gap = businessDaysBetween(data.goLive, data.suggestedGoLive); const prodGap = businessDaysBetween(data.goLive, data.projectEndDate); if (prodGap > 0) { msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (${gap} business day${gap !== 1 ? 's' : ''} over deadline)`; } else { msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (production completes in time, but ${gap} business day${gap !== 1 ? 's' : ''} short for syndication retailer lead times)`; } } // Key dates document.getElementById('resultKickOff').textContent = formatDateLong(data.kickOff); document.getElementById('resultGoLive').textContent = data.goLive ? formatDateLong(data.goLive) : 'Not set'; document.getElementById('resultEndDate').textContent = formatDateLong(data.projectEndDate); document.getElementById('resultSuggestedGoLive').textContent = formatDateLong(data.suggestedGoLive); // PM instruction const pmEl = document.getElementById('pmInstruction'); if (data.canMeet === null) { pmEl.textContent = 'Enter a Required Go-Live Date to see the full verdict and PM instructions.'; } else { pmEl.textContent = data.canMeet ? CONFIG.pmInstructions.canMeet : CONFIG.pmInstructions.cannotMeet; } // Calculation table const calcBody = document.getElementById('calculationTable'); calcBody.innerHTML = ''; data.stages.forEach(s => { const tr = document.createElement('tr'); tr.className = 'border-b border-gray-100 dark:border-gray-700/50' + (!s.active ? ' text-gray-400 dark:text-gray-500' : ''); tr.innerHTML = ` ${s.label} ${s.active ? 'Y' : 'N'} ${s.active ? s.wip : 0} ${s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0} ${s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0} ${formatDate(s.completeDate)} `; calcBody.appendChild(tr); }); // Key dates table const datesBody = document.getElementById('keyDatesTable'); datesBody.innerHTML = ''; data.stages.forEach(s => { const tr = document.createElement('tr'); tr.className = 'border-b border-gray-100 dark:border-gray-700/50' + (!s.active ? ' text-gray-400 dark:text-gray-500' : ''); tr.innerHTML = ` ${s.label} ${s.active ? 'Y' : 'N'} ${s.active ? formatDate(s.kickOffDate) : 'n.a.'} ${s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.'} ${s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.'} ${s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.'} ${s.active ? formatDate(s.completeDate) : 'n.a.'} `; datesBody.appendChild(tr); }); } // ---- View Toggle (List / Gantt) ---- function setView(view) { const listView = document.getElementById('listView'); const ganttView = document.getElementById('ganttView'); const btnList = document.getElementById('btnViewList'); const btnGantt = document.getElementById('btnViewGantt'); const activeClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'; const inactiveClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'; if (view === 'gantt') { listView.classList.add('hidden'); ganttView.classList.remove('hidden'); btnGantt.className = activeClass; btnList.className = inactiveClass; renderGantt(); } else { listView.classList.remove('hidden'); ganttView.classList.add('hidden'); btnList.className = activeClass; btnGantt.className = inactiveClass; } } function renderGantt() { if (!lastCalculationData) return; const data = lastCalculationData; const container = document.getElementById('ganttChart'); // Filter active stages const activeStgs = data.stages.filter(s => s.active); if (activeStgs.length === 0) { container.innerHTML = '

No active stages to display.

'; return; } // ── Day-grid setup ────────────────────────────────────────────────────────── const DAY_W = 28; // px per calendar day const LABEL_W = 192; // px for left stage-label column const minDate = data.kickOff; const maxDate = data.suggestedGoLive > data.projectEndDate ? data.suggestedGoLive : data.projectEndDate; // Snap grid start to the Sunday of the kick-off week const gridStart = new Date(minDate); gridStart.setHours(0, 0, 0, 0); gridStart.setDate(gridStart.getDate() - gridStart.getDay()); // Pad end by one full week const gridEnd = new Date(maxDate); gridEnd.setHours(0, 0, 0, 0); gridEnd.setDate(gridEnd.getDate() + 7); // All calendar days in range const days = []; const cur = new Date(gridStart); while (cur <= gridEnd) { days.push(new Date(cur)); cur.setDate(cur.getDate() + 1); } const timelineWidth = days.length * DAY_W; // Day-index from gridStart (0-based) const toDayIdx = (date) => { const d = new Date(date); d.setHours(0, 0, 0, 0); return Math.max(0, Math.round((d - gridStart) / 86400000)); }; // ── Phase colours ─────────────────────────────────────────────────────────── const phaseColors = { wip: 'bg-violet-600 dark:bg-violet-700', revision: 'bg-violet-400 dark:bg-violet-500', feedback: 'bg-amber-400 dark:bg-amber-500', }; // ── Header rows ───────────────────────────────────────────────────────────── const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const DAY_LETTERS = ['S','M','T','W','T','F','S']; // Sun=0 // Row 1: week-start date labels (every 7 days) let weekRow = ''; for (let i = 0; i < days.length; i += 7) { const day = days[i]; weekRow += `
${day.getDate()} ${MONTHS[day.getMonth()]}
`; } // Row 2: individual day letters + weekend band collector let dayRow = ''; let weekendBands = ''; days.forEach((day, i) => { const isWeekend = day.getDay() === 0 || day.getDay() === 6; const letter = DAY_LETTERS[day.getDay()]; const textCls = isWeekend ? 'text-gray-300 dark:text-gray-600 font-medium' : 'text-gray-400 dark:text-gray-500 font-medium'; dayRow += `
${letter}
`; if (isWeekend) { weekendBands += `
`; } }); // ── Stage rows ────────────────────────────────────────────────────────────── let rows = ''; activeStgs.forEach((s, idx) => { let barsHtml = ''; if (s.phases && s.phases.length > 0) { s.phases.forEach(phase => { const leftPx = toDayIdx(phase.start) * DAY_W; const widthDays = Math.max(toDayIdx(phase.end) - toDayIdx(phase.start), 1); const widthPx = widthDays * DAY_W; const color = phaseColors[phase.type] || 'bg-violet-600 dark:bg-violet-700'; barsHtml += `
${phase.label}
`; }); } else { const leftPx = toDayIdx(s.kickOffDate || minDate) * DAY_W; const widthPx = Math.max((toDayIdx(s.completeDate) - toDayIdx(s.kickOffDate || minDate)) * DAY_W, DAY_W); barsHtml = `
${formatDate(s.kickOffDate)}
`; } rows += `
${s.label}
${weekendBands} ${barsHtml}
`; }); // ── Suggested go-live marker ──────────────────────────────────────────────── const suggestedGoLivePx = LABEL_W + toDayIdx(data.suggestedGoLive) * DAY_W; const goLiveMarker = `
SUGGESTED LIVE
`; container.innerHTML = `
${weekRow}
${dayRow}
${goLiveMarker} ${rows}
WIP Feedback Revision WIP Weekend Suggested Live
`; } // ---- CSV Export ---- function exportCSV() { if (!lastCalculationData) return; const data = lastCalculationData; const lines = []; // Summary section lines.push('SLA Calculator Report'); lines.push(''); lines.push('Brief Name,' + csvEscape(data.briefName)); lines.push('Brief Type,' + csvEscape(document.getElementById('briefType').selectedOptions[0]?.text || '')); lines.push('Kick-Off Date,' + formatDateLong(data.kickOff)); lines.push('Required Go-Live Date,' + (data.goLive ? formatDateLong(data.goLive) : 'Not set')); lines.push('Planned End Date (SLA),' + formatDateLong(data.projectEndDate)); lines.push('Suggested Go-Live (SLA),' + formatDateLong(data.suggestedGoLive)); lines.push('Can Meet Deadline?,' + (data.canMeet === null ? 'N/A' : data.canMeet ? 'YES' : 'NO')); if (data.canMeet === false && data.goLive) { const gap = businessDaysBetween(data.goLive, data.suggestedGoLive); lines.push('Business Days Over Deadline,' + gap); } // Calculation of Days table lines.push(''); lines.push('Calculation of Days'); lines.push('Stage,Active,WIP 1,Feedback,Revisions,Complete By'); data.stages.forEach(s => { lines.push([ csvEscape(s.label), s.active ? 'Y' : 'N', s.active ? s.wip : 0, s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0, s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0, formatDate(s.completeDate) ].join(',')); }); // Key Dates table lines.push(''); lines.push('Key Dates'); lines.push('Stage,Active,Stage Kick-Off,WIP 1 To Approval,Receive Feedback By,Rounds,Stage Complete By'); data.stages.forEach(s => { lines.push([ csvEscape(s.label), s.active ? 'Y' : 'N', s.active ? formatDate(s.kickOffDate) : 'n.a.', s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.', s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.', s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.', s.active ? formatDate(s.completeDate) : 'n.a.' ].join(',')); }); const blob = new Blob([lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (data.briefName || 'SLA_Report').replace(/\s+/g, '_') + '_SLA.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ---- Download Gantt as PNG ---- function downloadGanttPNG() { const el = document.getElementById('ganttChart'); if (!el || !lastCalculationData) return; const isDark = document.documentElement.classList.contains('dark'); const btn = el.closest('#ganttView').querySelector('button'); if (btn) btn.style.visibility = 'hidden'; // hide button from screenshot html2canvas(el, { backgroundColor: isDark ? '#1f2937' : '#ffffff', scale: 2, useCORS: true, logging: false, }).then(canvas => { if (btn) btn.style.visibility = ''; const a = document.createElement('a'); a.download = (lastCalculationData.briefName || 'SLA_Gantt').replace(/\s+/g, '_') + '_Gantt.png'; a.href = canvas.toDataURL('image/png'); document.body.appendChild(a); a.click(); document.body.removeChild(a); }).catch(() => { if (btn) btn.style.visibility = ''; alert('Could not generate image. Please try again.'); }); } function csvEscape(str) { if (!str) return ''; str = String(str); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } // ---- Copy for Email (Rich HTML) ---- function copyForEmail() { if (!lastCalculationData) return; const briefType = document.getElementById('briefType').selectedOptions[0]?.text || ''; trackEvent('copy_email', { briefType }); const data = lastCalculationData; // Inline styles for Outlook compatibility const tbl = 'border-collapse:collapse;width:100%;font-family:Calibri,Arial,sans-serif;font-size:13px;'; const th = 'border:1px solid #d1d5db;padding:6px 10px;background:#f3f4f6;font-weight:600;text-align:center;'; const thL = 'border:1px solid #d1d5db;padding:6px 10px;background:#f3f4f6;font-weight:600;text-align:left;'; const td = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;'; const tdL = 'border:1px solid #d1d5db;padding:5px 10px;text-align:left;'; const tdActive = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;color:#16a34a;font-weight:600;'; const tdInactive = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;color:#9ca3af;'; // Verdict let verdictText, verdictColor; if (data.canMeet === null) { verdictText = 'N/A — No Go-Live date set'; verdictColor = '#6b7280'; } else if (data.canMeet) { verdictText = 'YES — Can meet the deadline'; verdictColor = '#16a34a'; } else { const gap = businessDaysBetween(data.goLive, data.suggestedGoLive); verdictText = `NO — Cannot meet deadline (${gap} business days over)`; verdictColor = '#dc2626'; } let html = `
`; html += `

SLA Summary — ${data.briefName || 'Untitled'}

`; html += `

Brief Type: ${briefType}

`; html += `

Kick-Off Date: ${formatDateLong(data.kickOff)}

`; html += `

Required Go-Live: ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}

`; html += `

Planned End Date (SLA): ${formatDateLong(data.projectEndDate)}

`; html += `

Suggested Go-Live (SLA): ${formatDateLong(data.suggestedGoLive)}

`; html += `

Verdict: ${verdictText}

`; // Key Dates table html += `

Key Dates

`; html += ``; html += ``; data.stages.forEach(s => { const aStyle = s.active ? tdActive : tdInactive; const aText = s.active ? 'Y' : 'N'; const kickOff = s.active ? formatDate(s.kickOffDate) : 'n.a.'; const wip = s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.'; const feedback = s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.'; const rounds = s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.'; const complete = s.active ? formatDate(s.completeDate) : 'n.a.'; html += ``; }); html += `
StageActiveStage Kick-OffWIP 1 To ApprovalReceive Feedback ByRoundsStage Complete By
${s.label}${aText}${kickOff}${wip}${feedback}${rounds}${complete}
`; // Calculation of Days table html += `

Calculation of Days

`; html += ``; html += ``; data.stages.forEach(s => { const aStyle = s.active ? tdActive : tdInactive; const aText = s.active ? 'Y' : 'N'; const wip = s.active ? s.wip : 0; const feedback = s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0; const revisions = s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0; const complete = formatDate(s.completeDate); html += ``; }); html += `
StageActiveWIP 1FeedbackRevisionsComplete By
${s.label}${aText}${wip}${feedback}${revisions}${complete}
`; // Plain text fallback let plain = `SLA Summary — ${data.briefName || 'Untitled'}\n`; plain += `Brief Type: ${briefType}\n`; plain += `Kick-Off: ${formatDateLong(data.kickOff)}\n`; plain += `Go-Live: ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}\n`; plain += `Planned End: ${formatDateLong(data.projectEndDate)}\n`; plain += `Verdict: ${verdictText}\n`; const showCopied = () => { const btn = document.getElementById('btnCopyEmail'); const originalHTML = btn.innerHTML; btn.innerHTML = ` Copied!`; setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); }; // Use ClipboardItem API for rich HTML copy if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { const htmlBlob = new Blob([html], { type: 'text/html' }); const textBlob = new Blob([plain], { type: 'text/plain' }); navigator.clipboard.write([new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob })]).then(showCopied).catch(() => { fallbackCopyHtml(html); showCopied(); }); } else { fallbackCopyHtml(html); showCopied(); } } function fallbackCopyHtml(html) { const el = document.createElement('div'); el.innerHTML = html; el.style.position = 'fixed'; el.style.left = '-9999px'; document.body.appendChild(el); const range = document.createRange(); range.selectNodeContents(el); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand('copy'); sel.removeAllRanges(); document.body.removeChild(el); } // ---- iCal Export ---- function exportICal() { if (!lastCalculationData) return; const data = lastCalculationData; const formatICalDate = (date) => { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return y + m + d; }; const now = new Date(); const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, ''); const uid = () => 'sla-' + Math.random().toString(36).substr(2, 9) + '@ecf'; let events = []; // Project kick-off event events.push({ summary: `[Kick-Off] ${data.briefName}`, date: formatICalDate(data.kickOff), description: `Project kick-off for ${data.briefName}\\nBrief Type: ${document.getElementById('briefType').value.replace(/_/g, ' ')}` }); // Active stage completion milestones data.stages.forEach(s => { if (s.active && s.completeDate) { events.push({ summary: `[${s.label} Complete] ${data.briefName}`, date: formatICalDate(s.completeDate), description: `Stage "${s.label}" estimated completion for ${data.briefName}` }); } }); // Go-live deadline (if set) if (data.goLive) { events.push({ summary: `[Go-Live Deadline] ${data.briefName}`, date: formatICalDate(data.goLive), description: `Required go-live date for ${data.briefName}\\nVerdict: ${data.canMeet ? 'CAN meet deadline' : 'CANNOT meet deadline'}` }); } // Project planned end date events.push({ summary: `[SLA End Date] ${data.briefName}`, date: formatICalDate(data.projectEndDate), description: `Planned end date based on SLA calculation for ${data.briefName}` }); // Build .ics content let ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//eCom Content Factory//SLA Calculator//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH' ]; events.forEach(evt => { ics.push( 'BEGIN:VEVENT', `DTSTART;VALUE=DATE:${evt.date}`, `DTEND;VALUE=DATE:${evt.date}`, `DTSTAMP:${stamp}`, `UID:${uid()}`, `SUMMARY:${evt.summary}`, `DESCRIPTION:${evt.description}`, 'END:VEVENT' ); }); ics.push('END:VCALENDAR'); const blob = new Blob([ics.join('\r\n')], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (data.briefName || 'SLA_Milestones').replace(/\s+/g, '_') + '.ics'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }