// ============================================================================= // 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 = '
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.
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)}.
Urgent Brief: Brief production time is 6 hours + ${confirmDays} day(s) brief confirmation time.
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 += `| Stage | Days | Start | End |
|---|---|---|---|
| ${s.label} | ${s.days} | ${formatDate(s.startDate)} | ${formatDate(s.endDate)} |
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 += `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 += `