// ============================================================================= // SLA Brief Advisor — Client-Facing Form // Client answers practical questions → we determine the brief type // and calculate the submission deadline from their go-live date. // All business rules loaded from config.json. // ============================================================================= let CONFIG = null; let lastResultData = null; // Brief types available when Video / Motion is selected const VIDEO_BRIEF_TYPE_IDS = ['country_pull_adaptation', 'country_pull_creation']; // Video-specific descriptions override config.json descriptions const VIDEO_BRIEF_DESCRIPTIONS = { country_pull_adaptation: [ 'Minor localisation and copy updates applied to existing motion timelines.', 'No structural changes.' ], country_pull_creation: [ 'Product swaps and dynamic content updates (up to 4 elements) made within existing motion structure' ] }; // ---- Usage Tracking ---- const TRACK_API = (() => { // In production behind reverse proxy, use relative path if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return null; const base = location.pathname.replace(/\/[^/]*$/, ''); return `${base}/api/events`; })(); async function getVisitorId() { // Stable anonymous fingerprint — no PII, no cookies 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; // skip on localhost getVisitorId().then(visitor_id => { fetch(TRACK_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event, page: 'market', visitor_id, metadata }), }).catch(() => {}); // fire-and-forget, never block UI }); } // ---- Bootstrap ---- document.addEventListener('DOMContentLoaded', async () => { await loadConfig(); initDarkMode(); initDatePicker(); bindEvents(); trackEvent('page_view'); }); async function loadConfig() { try { const res = await fetch('config.json?v=2026050801'); CONFIG = await res.json(); } catch (e) { console.error('Failed to load config.json:', e); } } // ---- 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'); } updateDarkIcons(); document.getElementById('darkToggle').addEventListener('click', () => { document.documentElement.classList.toggle('dark'); localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark')); updateDarkIcons(); }); } function updateDarkIcons() { const isDark = document.documentElement.classList.contains('dark'); document.getElementById('sunIcon').classList.toggle('hidden', !isDark); document.getElementById('moonIcon').classList.toggle('hidden', isDark); } // ---- Date Picker ---- function initDatePicker() { flatpickr('#golive', { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd M Y', disableMobile: true, minDate: 'today', disable: [function(date) { return date.getDay() === 0 || date.getDay() === 6; }], onChange: () => validateForm() }); } // ---- Event Bindings ---- function bindEvents() { document.getElementById('contentType').addEventListener('change', validateForm); document.getElementById('golive').addEventListener('change', validateForm); document.getElementById('clientForm').addEventListener('submit', onSubmit); document.getElementById('resetBtn').addEventListener('click', resetForm); // Toggle expand/collapse for feedback day fields document.querySelectorAll('.toggle-with-feedback').forEach(cb => { cb.addEventListener('change', () => { const panel = document.getElementById(cb.dataset.feedback); if (panel) panel.classList.toggle('hidden', !cb.checked); }); }); // Brief type override dropdown in results → recalculate document.getElementById('briefTypeOverride').addEventListener('change', (e) => { calculateAndRender(e.target.value); }); } function validateForm() { const contentType = document.getElementById('contentType').value; const golive = document.getElementById('golive').value; document.getElementById('calcBtn').disabled = !(contentType && golive); } function onSubmit(e) { e.preventDefault(); calculateAndRender(); // Track after render so we can capture the resolved brief type if (lastResultData) { trackEvent('show_results', { briefType: lastResultData.briefType?.label || '', contentType: document.getElementById('contentType').value, }); } } function resetForm() { document.getElementById('contentType').value = ''; document.getElementById('assetVolume').value = '0_50'; document.getElementById('needsStatic').checked = false; document.getElementById('needsVideo').checked = false; document.getElementById('needsHTML').checked = false; document.getElementById('needsTranslation').checked = false; document.getElementById('needsSyndication').checked = false; // Reset static work type sub-selector const defaultRadio = document.querySelector('input[name="staticWorkType"][value="creation"]'); if (defaultRadio) defaultRadio.checked = true; // Reset video work type sub-selector const defaultVideoRadio = document.querySelector('input[name="videoWorkType"][value="new_asset"]'); if (defaultVideoRadio) defaultVideoRadio.checked = true; // Reset feedback day inputs and collapse panels document.querySelectorAll('.toggle-with-feedback').forEach(cb => { const panel = document.getElementById(cb.dataset.feedback); if (panel) panel.classList.add('hidden'); }); const feedbackDefaults = { feedbackDaysStatic: 3, feedbackDaysVideo: 3, feedbackDaysHTML: 3, feedbackDaysTranslation: 5, amendRoundsStatic: 1, amendRoundsVideo: 1, amendRoundsHTML: 1, amendRoundsTranslation: 1 }; Object.entries(feedbackDefaults).forEach(([id, val]) => { const el = document.getElementById(id); if (el) el.value = val; }); const goliveEl = document.getElementById('golive'); if (goliveEl._flatpickr) goliveEl._flatpickr.clear(); document.getElementById('calcBtn').disabled = true; document.getElementById('results').classList.add('hidden'); window.scrollTo({ top: 0, behavior: 'smooth' }); } // ---- Determine Brief Type from Form Answers ---- // Maps practical client needs → config brief type ID function determineBriefType() { const contentType = document.getElementById('contentType').value; // pdp | eventing const needsStatic = document.getElementById('needsStatic').checked; const needsVideo = document.getElementById('needsVideo').checked; const needsHTML = document.getElementById('needsHTML').checked; const needsTranslation = document.getElementById('needsTranslation').checked; const needsSyndication = document.getElementById('needsSyndication').checked; const needsProduction = needsStatic || needsVideo || needsHTML; // Syndication only — no production, no translation if (needsSyndication && !needsProduction && !needsTranslation) { return 'country_retailer_request'; } // Has production needs if (needsProduction) { // HTML → always Creation if (needsHTML) return 'country_pull_creation'; const staticRadio = document.querySelector('input[name="staticWorkType"]:checked'); const staticWorkType = staticRadio ? staticRadio.value : 'creation'; const videoRadio = document.querySelector('input[name="videoWorkType"]:checked'); const videoWorkType = videoRadio ? videoRadio.value : 'adapting'; // If any "new asset" selection exists across static or video → Creation if ((needsStatic && staticWorkType === 'creation') || (needsVideo && videoWorkType === 'new_asset')) { return 'country_pull_creation'; } // Resizing/cropping only (static, no video) → Simple if (needsStatic && !needsVideo && staticWorkType === 'simple') { return 'country_pull_simple'; } // Everything else → Adaptation return 'country_pull_adaptation'; } // Translation only or simple resize/crop if (needsTranslation) { return 'country_pull_simple'; } // Syndication + translation but no production if (needsSyndication) { if (contentType === 'pdp') return 'local_push_pdp'; return 'local_push_eventing'; } // Fallback: simple pull return 'country_pull_simple'; } // ---- Derive Stage 6 production complexity from Advisor toggles ---- // Precedence: bespoke > creation > complex > simple — take the max across enabled toggles. // HTML has no sub-type, so when HTML is the ONLY production toggle it falls back to 'complex' // (team decision 2026-05-08; revisit if HTML needs its own production track). function deriveProductionComplexity({ needsStatic, needsVideo, needsHTML, staticWorkType, videoWorkType }) { const order = ['simple', 'complex', 'creation', 'bespoke']; const rank = { simple: 0, complex: 1, creation: 2, bespoke: 3 }; let level = -1; if (needsStatic) { const map = { simple: 'simple', adaptation: 'complex', creation: 'creation' }; level = Math.max(level, rank[map[staticWorkType] || 'simple']); } if (needsVideo) { const map = { adapting: 'complex', new_asset: 'creation' }; level = Math.max(level, rank[map[videoWorkType] || 'complex']); } if (needsHTML && level < 0) { level = rank.complex; } return level >= 0 ? order[level] : 'simple'; } // ---- Business Day Arithmetic ---- function addBusinessDays(startDate, days) { const result = new Date(startDate); let added = 0; while (added < days) { result.setDate(result.getDate() + 1); if (result.getDay() !== 0 && result.getDay() !== 6) added++; } return result; } function subtractBusinessDays(endDate, days) { const result = new Date(endDate); let removed = 0; while (removed < days) { result.setDate(result.getDate() - 1); if (result.getDay() !== 0 && result.getDay() !== 6) removed++; } return result; } function businessDaysBetween(start, end) { let count = 0; const d = new Date(start); while (d < end) { d.setDate(d.getDate() + 1); if (d.getDay() !== 0 && d.getDay() !== 6) count++; } return count; } function formatDate(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(); } // ---- Read user-supplied feedback days and amend rounds (defaults to 0/1 if toggle is off) ---- function getUserFeedbackDays() { const videoChecked = document.getElementById('needsVideo').checked; const videoRadio = document.querySelector('input[name="videoWorkType"]:checked'); const videoWorkDays = videoChecked ? (videoRadio && videoRadio.value === 'adapting' ? 4 : 6) : 0; const getRounds = (id) => parseInt(document.getElementById(id)?.value) || 1; return { static: document.getElementById('needsStatic').checked ? parseFloat(document.getElementById('feedbackDaysStatic').value) || 0 : 0, staticRounds: document.getElementById('needsStatic').checked ? getRounds('amendRoundsStatic') : 0, video: videoChecked ? parseFloat(document.getElementById('feedbackDaysVideo').value) || 0 : 0, videoRounds: videoChecked ? getRounds('amendRoundsVideo') : 0, videoWorkDays, html: document.getElementById('needsHTML').checked ? parseFloat(document.getElementById('feedbackDaysHTML').value) || 0 : 0, htmlRounds: document.getElementById('needsHTML').checked ? getRounds('amendRoundsHTML') : 0, translation: document.getElementById('needsTranslation').checked ? parseFloat(document.getElementById('feedbackDaysTranslation').value) || 0 : 0, translationRounds: document.getElementById('needsTranslation').checked ? getRounds('amendRoundsTranslation') : 0, }; } // ---- Calculation Engine ---- function calculateAndRender(overrideBriefId) { const briefId = overrideBriefId || determineBriefType(); const assetVolume = document.getElementById('assetVolume').value; const goliveStr = document.getElementById('golive').value; const needsTranslation = document.getElementById('needsTranslation').checked; const needsSyndication = document.getElementById('needsSyndication').checked; const fb = getUserFeedbackDays(); if (!briefId || !goliveStr) return; const goLive = new Date(goliveStr + 'T00:00:00'); const cfg = CONFIG.stageConfigs; // Get brief type info const bt = CONFIG.briefTypes.find(b => b.id === briefId); // Start from the stage matrix, then overlay client toggles const matrix = [...CONFIG.stageMatrix[briefId]]; // Force always-active stages (Opera Upload) CONFIG.stages.forEach((stage, i) => { const sCfg = cfg['stage' + stage.number]; if (sCfg && sCfg.alwaysActive) matrix[i] = true; }); // In the Brief Advisor the user's toggles are the source of truth // for translation and syndication — override the base matrix matrix[3] = needsTranslation; // Translation (Salsify PDP) matrix[4] = needsTranslation; // Translation (Asset) matrix[7] = needsSyndication; // Syndication // Production toggles + work-type radios — needed for Stage 6 complexity derivation const contentType = document.getElementById('contentType').value; const needsStatic = document.getElementById('needsStatic').checked; const needsVideo = document.getElementById('needsVideo').checked; const needsHTML = document.getElementById('needsHTML').checked; const staticWorkType = document.querySelector('input[name="staticWorkType"]:checked')?.value; const videoWorkType = document.querySelector('input[name="videoWorkType"]:checked')?.value; // Urgent Brief scenario: eventing + 0–30 assets + static resizing/cropping only const isUrgentScenario = briefId === 'urgent_brief' && contentType === 'eventing' && assetVolume === '0_50' && needsStatic && staticWorkType === 'simple'; // Combined feedback days from production toggles (static/video/html) // Uses the max of the user-supplied feedback values for production stages const productionFeedback = Math.max(fb.static, fb.video, fb.html); // Calculate total project days using a temp start date const tempStart = new Date('2026-01-05T00:00:00'); // Arbitrary Monday const stages = []; let currentDate = new Date(tempStart); // Combined rounds from production toggles — use the max across static/video/html const productionRounds = Math.max(fb.staticRounds || 0, fb.videoRounds || 0, fb.htmlRounds || 0); // Stage 1: DMI Asset Creation const s1 = calcStage(0, matrix[0], currentDate, () => { const opt = cfg.stage1.fields.complexity.options.find(o => o.value === cfg.stage1.fields.complexity.default); const rounds = cfg.stage1.fields.revisionRounds.default; return { wip: opt ? opt.days : 0, feedback: cfg.stage1.fields.marketApprovalDays.default, revisions: rounds * cfg.stage1.fields.revisionDays.default, rounds }; }); stages.push(s1); currentDate = s1.end; // Stage 2: Mastering / Copy const s2 = calcStage(1, matrix[1], currentDate, () => { const masterOpt = cfg.stage2.fields.masteringComplexity.options.find(o => o.value === cfg.stage2.fields.masteringComplexity.default); const copyOpt = cfg.stage2.fields.copyComplexity.options.find(o => o.value === cfg.stage2.fields.copyComplexity.default); return { wip: (masterOpt ? masterOpt.days : 0) + (copyOpt ? copyOpt.days : 0), feedback: 0, revisions: 0, rounds: 0 }; }); stages.push(s2); currentDate = s2.end; // Stage 3: Global Rollout const s3 = calcStage(2, matrix[2], currentDate, () => { return { wip: cfg.stage3.fields.rolloutDays.default, feedback: 0, revisions: 0, rounds: 0 }; }); stages.push(s3); currentDate = s3.end; // Stages 4 & 5: Translation (parallel) — use user feedback days if translation is toggled const translationActive = matrix[3] || matrix[4]; const s45 = calcStage(3, translationActive, currentDate, () => { const wcOpt = cfg.stage4_5.fields.wordCount.options.find(o => o.value === cfg.stage4_5.fields.wordCount.default); const translationFeedback = fb.translation > 0 ? fb.translation : cfg.stage4_5.fields.marketApprovalDays.default; const rounds = fb.translationRounds || cfg.stage4_5.fields.revisionRounds.default; return { wip: wcOpt ? wcOpt.days : 0, feedback: translationFeedback, revisions: rounds * cfg.stage4_5.fields.revisionDays.default, rounds }; }); stages.push({ ...s45, label: 'Translation' }); currentDate = s45.end; // Stage 6: Production — use user feedback days from production toggles const s6 = calcStage(5, matrix[5], currentDate, () => { if (isUrgentScenario) { return { wip: 1, feedback: fb.static, revisions: 0, rounds: 0 }; } const complexity = deriveProductionComplexity({ needsStatic, needsVideo, needsHTML, staticWorkType, videoWorkType }); const key = complexity + '_' + assetVolume; const table = cfg.stage6.fields.crossReferenceTable; const prodFeedback = productionFeedback > 0 ? productionFeedback : cfg.stage6.fields.marketApprovalDays.default; const rounds = productionRounds > 0 ? productionRounds : cfg.stage6.fields.revisionRounds.default; return { wip: (table[key] || 0) + (fb.videoWorkDays || 0), feedback: prodFeedback, revisions: rounds * cfg.stage6.fields.revisionDays.default, rounds }; }); if (isUrgentScenario) s6.label = 'Production (6 hrs + confirmation)'; stages.push(s6); currentDate = s6.end; // Stage 7: Opera Upload const s7 = calcStage(6, matrix[6], currentDate, () => { const daysMap = cfg.stage7.daysByAssetVolume; return { wip: daysMap[assetVolume] || cfg.stage7.defaultDays, feedback: 0, revisions: 0, rounds: 0 }; }); stages.push(s7); currentDate = s7.end; // Stage 8: Syndication — Advisor only chooses PDP vs Non-PDP from contentType. // Salsify Prep Only is not surfaced here (team decision 2026-05-08). const s8 = calcStage(7, matrix[7], currentDate, () => { const typeKey = contentType === 'pdp' ? 'syndication_pdp' : 'syndication_non_pdp'; const opt = cfg.stage8.fields.syndicationType.options.find(o => o.value === typeKey); return { wip: opt ? opt.days : 0, feedback: 0, revisions: 0, rounds: 0 }; }); stages.push(s8); // Total business days const totalBizDays = businessDaysBetween(tempStart, s8.end); // Work backwards from go-live — no assumed syndication buffer const briefAcceptedBy = subtractBusinessDays(goLive, totalBizDays); const submitBy = subtractBusinessDays(briefAcceptedBy, 2); const estimatedCompletion = addBusinessDays(briefAcceptedBy, totalBizDays); // Earliest completion: if brief accepted today, when could we finish? const today = new Date(); today.setHours(0, 0, 0, 0); const earliestCompletion = addBusinessDays(today, totalBizDays); // Compute real calendar dates + phases for each active stage (relative to briefAcceptedBy) const activeStages = stages.filter(s => s.active); let runningDate = new Date(briefAcceptedBy); activeStages.forEach(s => { s.startDate = new Date(runningDate); s.endDate = addBusinessDays(runningDate, s.days); s.phases = buildPhases(s.params, s.startDate); runningDate = s.endDate; }); renderResults({ briefType: bt, overrideBriefId: overrideBriefId || null, suggestedBriefId: determineBriefType(), stages: activeStages, goLive, briefAcceptedBy, submitBy, estimatedCompletion, totalBizDays, earliestCompletion, needsVideo: document.getElementById('needsVideo').checked, isUrgentScenario }); } function calcStage(stageIndex, isActive, prevDate, getParams) { const stage = CONFIG.stages[stageIndex]; if (!isActive) { return { index: stageIndex, label: stage ? stage.label : '', active: false, days: 0, end: new Date(prevDate), params: null, phases: [] }; } const p = getParams(); const totalDays = p.wip + p.feedback + p.revisions; return { index: stageIndex, label: stage ? stage.label : '', active: true, days: totalDays, end: addBusinessDays(prevDate, totalDays), params: p, phases: [] }; } function buildPhases(params, startDate) { if (!params) return []; const phases = []; let cursor = new Date(startDate); if (params.wip > 0) { const wipEnd = addBusinessDays(cursor, params.wip); phases.push({ label: 'WIP 1', type: 'wip', start: new Date(cursor), end: wipEnd }); cursor = wipEnd; } const rounds = params.rounds || 0; if (params.feedback > 0) { const feedbackEnd = addBusinessDays(cursor, params.feedback); phases.push({ label: rounds > 0 ? 'Feedback 1' : 'Feedback', type: 'feedback', start: new Date(cursor), end: feedbackEnd }); cursor = feedbackEnd; } if (rounds > 0 && params.revisions > 0) { const revDaysPerRound = params.revisions / rounds; for (let r = 1; r <= rounds; r++) { const revEnd = addBusinessDays(cursor, revDaysPerRound); phases.push({ label: `WIP ${r + 1}`, type: 'revision', start: new Date(cursor), end: revEnd }); cursor = revEnd; } } return phases; } // ---- Render Results ---- function renderResults(data) { lastResultData = data; const isUrgentBrief = data.briefType?.id === 'urgent_brief'; const resultsEl = document.getElementById('results'); resultsEl.classList.remove('hidden'); // Brief type dropdown — show all brief types for all content types const overrideSelect = document.getElementById('briefTypeOverride'); overrideSelect.innerHTML = ''; const briefTypesToShow = CONFIG.briefTypes; briefTypesToShow.forEach(bt => { const opt = document.createElement('option'); opt.value = bt.id; opt.textContent = bt.label; if (bt.id === data.briefType.id) opt.selected = true; overrideSelect.appendChild(opt); }); // Show description in blue info box — use video-specific descriptions when video is active const descEl = document.getElementById('resultBriefDesc'); const descriptions = (data.needsVideo && VIDEO_BRIEF_DESCRIPTIONS[data.briefType.id]) ? VIDEO_BRIEF_DESCRIPTIONS[data.briefType.id] : data.briefType.description; if (Array.isArray(descriptions)) { descEl.innerHTML = ''; } else { descEl.textContent = descriptions; } descEl.classList.remove('hidden'); // Show "Suggested" or "Overridden" hint if (data.overrideBriefId && data.overrideBriefId !== data.suggestedBriefId) { descEl.innerHTML += `

You changed from the suggested type: ${CONFIG.briefTypes.find(b => b.id === data.suggestedBriefId)?.label || ''}

`; } // Deadline banner — hidden for urgent brief const banner = document.getElementById('deadlineBanner'); if (isUrgentBrief) { banner.className = 'hidden'; banner.innerHTML = ''; } else { const today = new Date(); today.setHours(0, 0, 0, 0); const acceptDate = new Date(data.briefAcceptedBy); acceptDate.setHours(0, 0, 0, 0); const isPast = acceptDate < today; if (isPast) { banner.className = 'rounded-xl p-5 mb-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'; banner.innerHTML = `

The brief acceptance deadline has already passed

To complete work by ${formatDate(data.goLive)}, the brief needed to be accepted by ${formatDate(data.briefAcceptedBy)}.

Please discuss an adjusted timeline with your project manager.

`; } else { banner.className = 'rounded-xl p-5 mb-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'; banner.innerHTML = `

Brief must be accepted by ${formatDate(data.briefAcceptedBy)}

Submit by ${formatDate(data.submitBy)} to allow processing time. This gives us ${data.totalBizDays} business days to complete by ${formatDate(data.goLive)}.

`; } } // end isUrgentBrief banner guard // Urgent brief callout const urgentNote = document.getElementById('urgentNote'); if (urgentNote) { if (data.isUrgentScenario) { const confirmDays = lastResultData ? document.getElementById('feedbackDaysStatic').value : 3; urgentNote.innerHTML = `

Urgent Brief: Brief production time is 6 hours + ${confirmDays} day(s) brief confirmation time.

`; urgentNote.className = 'rounded-lg border border-orange-300 dark:border-orange-700 bg-orange-50 dark:bg-orange-900/20 px-3 py-2.5 mb-4'; } else { urgentNote.className = 'hidden'; urgentNote.innerHTML = ''; } } // Timeline summary + summary cards — hidden for urgent brief const summary = document.getElementById('timelineSummary'); const summaryCards = document.getElementById('summaryCards'); const toggleDetails = document.getElementById('toggleDetails'); if (isUrgentBrief) { summary.classList.add('hidden'); summaryCards.classList.add('hidden'); toggleDetails.classList.remove('hidden'); toggleDetails.innerHTML = 'Show timeline details'; document.getElementById('showHideTimeline').addEventListener('click', (e) => { e.preventDefault(); const isHidden = summary.classList.contains('hidden'); summary.classList.toggle('hidden', !isHidden); summaryCards.classList.toggle('hidden', !isHidden); e.target.textContent = isHidden ? 'Hide timeline details' : 'Show timeline details'; }); } else { summary.classList.remove('hidden'); summaryCards.classList.remove('hidden'); toggleDetails.classList.add('hidden'); toggleDetails.innerHTML = ''; summary.innerHTML = '

What we need to do

'; data.stages.forEach(s => { const row = document.createElement('div'); row.className = 'flex items-center justify-between py-2.5 px-3 rounded-lg bg-gray-50 dark:bg-gray-700/40 text-sm'; row.innerHTML = `
${s.label}
${s.days} day${s.days !== 1 ? 's' : ''} ${formatDate(s.startDate)} → ${formatDate(s.endDate)}
`; summary.appendChild(row); }); } // Summary cards — always populated (used by copy-for-email) document.getElementById('summarySubmitBy').textContent = formatDate(data.submitBy); document.getElementById('summaryBriefAcceptedBy').textContent = formatDate(data.briefAcceptedBy); document.getElementById('summaryCompletion').textContent = formatDate(data.estimatedCompletion); document.getElementById('summaryDays').textContent = data.totalBizDays; document.getElementById('summaryEarliest').textContent = formatDate(data.earliestCompletion); renderGantt(); resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // ---- Copy for Email (Rich HTML) ---- function copyForEmail() { if (!lastResultData) return; trackEvent('copy_email', { briefType: lastResultData.briefType?.label || '', contentType: document.getElementById('contentType').value, }); const data = lastResultData; 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 manualNote = (data.overrideBriefId && data.overrideBriefId !== data.suggestedBriefId) ? ' (manually selected)' : ''; let html = `
`; html += `

SLA Brief Advisor — Summary

`; html += `

Brief Type: ${data.briefType.label}${manualNote}

`; html += `

Oliver Tasks Completed By: ${formatDate(data.goLive)}

`; html += `

Submit Brief By: ${formatDate(data.submitBy)}

`; html += `

Brief Accepted By: ${formatDate(data.briefAcceptedBy)}

`; html += `

Estimated Completion: ${formatDate(data.estimatedCompletion)}

`; html += `

Total Business Days: ${data.totalBizDays}

`; html += `

Earliest Completion (if accepted today): ${formatDate(data.earliestCompletion)}

`; // Timeline table html += `

Timeline Breakdown

`; html += ``; html += ``; data.stages.forEach(s => { html += ``; }); html += `
StageDaysStartEnd
${s.label}${s.days}${formatDate(s.startDate)}${formatDate(s.endDate)}
`; // Disclaimer html += `

These timings are for reference only and assume suitable working files have been provided.

`; html += `

Please confirm final deadlines with your BCM after studio evaluation. Creative requests follow ad-hoc production timelines.

`; html += `
`; // Plain text fallback let plain = `SLA Brief Advisor — Summary\n`; plain += `Brief Type: ${data.briefType.label}${manualNote}\n`; plain += `Oliver Tasks Completed By: ${formatDate(data.goLive)}\n`; plain += `Submit Brief By: ${formatDate(data.submitBy)}\n`; plain += `Brief Accepted By: ${formatDate(data.briefAcceptedBy)}\n`; plain += `Estimated Completion: ${formatDate(data.estimatedCompletion)}\n`; plain += `Total Business Days: ${data.totalBizDays}\n`; plain += `Earliest Completion (if accepted today): ${formatDate(data.earliestCompletion)}\n\n`; plain += `Timeline:\n`; data.stages.forEach(s => { plain += ` ${s.label}: ${s.days} days (${formatDate(s.startDate)} → ${formatDate(s.endDate)})\n`; }); plain += `\nThese timings are for reference only and assume suitable working files have been provided.\n`; plain += `Please confirm final deadlines with your BCM after studio evaluation.\n`; const btn = document.getElementById('btnCopyEmail'); const originalHTML = btn.innerHTML; const showCopied = () => { btn.innerHTML = ` Copied!`; setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); }; try { 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(); } } catch (e) { console.error('Copy failed:', e); fallbackCopyHtml(html); showCopied(); } } // ---- Gantt Chart ---- function toggleGantt() { const panel = document.getElementById('ganttView'); const btn = document.getElementById('toggleGanttBtn'); const isHidden = panel.classList.contains('hidden'); panel.classList.toggle('hidden', !isHidden); btn.textContent = isHidden ? 'Hide Gantt' : 'Show Gantt'; if (!isHidden) { btn.innerHTML = ` Show Gantt`; } else { btn.innerHTML = ` Hide Gantt`; } } function renderGantt() { if (!lastResultData) return; const data = lastResultData; const container = document.getElementById('ganttChart'); if (!container) return; const activeStgs = data.stages; if (!activeStgs || activeStgs.length === 0) { container.innerHTML = '

No active stages to display.

'; return; } const DAY_W = 28; const LABEL_W = 176; const minDate = data.briefAcceptedBy; const maxDate = data.goLive; const gridStart = new Date(minDate); gridStart.setHours(0, 0, 0, 0); gridStart.setDate(gridStart.getDate() - gridStart.getDay()); const gridEnd = new Date(maxDate); gridEnd.setHours(0, 0, 0, 0); gridEnd.setDate(gridEnd.getDate() + 7); 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; const toDayIdx = (date) => { const d = new Date(date); d.setHours(0, 0, 0, 0); return Math.max(0, Math.round((d - gridStart) / 86400000)); }; 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', }; const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const DAY_LETTERS = ['S','M','T','W','T','F','S']; let weekRow = ''; for (let i = 0; i < days.length; i += 7) { const day = days[i]; weekRow += `
${day.getDate()} ${MONTHS[day.getMonth()]}
`; } 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 += `
`; } }); 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.startDate) * DAY_W; const widthPx = Math.max((toDayIdx(s.endDate) - toDayIdx(s.startDate)) * DAY_W, DAY_W); barsHtml = `
${s.label}
`; } rows += `
${s.label}
${weekendBands} ${barsHtml}
`; }); const estCompletionPx = LABEL_W + toDayIdx(data.estimatedCompletion) * DAY_W; const goLiveMarker = `
EST. COMPLETION
`; container.innerHTML = `
${weekRow}
${dayRow}
${goLiveMarker} ${rows}
WIP Feedback Revision WIP Weekend Est. Completion
`; } function downloadGanttPNG() { const el = document.getElementById('ganttChart'); if (!el || !lastResultData) return; const isDark = document.documentElement.classList.contains('dark'); const btn = el.closest('#ganttView')?.querySelector('button[onclick="downloadGanttPNG()"]'); if (btn) btn.style.visibility = 'hidden'; html2canvas(el, { backgroundColor: isDark ? '#1f2937' : '#ffffff', scale: 2, useCORS: true, logging: false, }).then(canvas => { if (btn) btn.style.visibility = ''; const a = document.createElement('a'); const briefLabel = (lastResultData.briefType?.label || 'SLA_Brief').replace(/\s+/g, '_'); a.download = briefLabel + '_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 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); }