diff --git a/client-script.js b/client-script.js new file mode 100644 index 0000000..ef5dc18 --- /dev/null +++ b/client-script.js @@ -0,0 +1,384 @@ +// ============================================================================= +// 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; + +// ---- Bootstrap ---- +document.addEventListener('DOMContentLoaded', async () => { + await loadConfig(); + initDarkMode(); + initDatePicker(); + bindEvents(); +}); + +async function loadConfig() { + try { + const res = await fetch('config.json?v=2026031302'); + 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); +} + +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(); +} + +function resetForm() { + document.getElementById('contentType').value = ''; + document.getElementById('assetVolume').value = '0_30'; + document.getElementById('needsVideo').checked = false; + document.getElementById('needsHTML').checked = false; + document.getElementById('needsTranslation').checked = false; + document.getElementById('needsSyndication').checked = false; + 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 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 = needsVideo || needsHTML; + + // Syndication only — no production, no translation + if (needsSyndication && !needsProduction && !needsTranslation) { + return 'country_retailer_request'; + } + + // Has production needs + if (needsProduction) { + // Video/motion → Adaptation (master exists, motion content) + 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'; +} + +// ---- 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(); +} + +// ---- Calculation Engine ---- +function calculateAndRender() { + const briefId = 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; + + if (!briefId || !goliveStr) return; + + const goLive = new Date(goliveStr + 'T00:00:00'); + const cfg = CONFIG.stageConfigs; + const handoverDays = CONFIG.meta.handoverDays; + + // 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; + }); + + // Client toggles can activate translation/syndication even if the base + // brief type doesn't include them + if (needsTranslation) { + matrix[3] = true; // Translation (Salsify PDP) + matrix[4] = true; // Translation (Asset) + } + if (needsSyndication && !matrix[7]) { + matrix[7] = true; // Syndication + } + + // 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); + + // 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); + return { handover: handoverDays, wip: opt ? opt.days : 0, feedback: cfg.stage1.fields.marketApprovalDays.default, revisions: cfg.stage1.fields.revisionRounds.default * cfg.stage1.fields.revisionDays.default }; + }); + 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 { handover: handoverDays, wip: (masterOpt ? masterOpt.days : 0) + (copyOpt ? copyOpt.days : 0), feedback: 0, revisions: 0 }; + }); + stages.push(s2); + currentDate = s2.end; + + // Stage 3: Global Rollout + const s3 = calcStage(2, matrix[2], currentDate, () => { + return { handover: handoverDays, wip: cfg.stage3.fields.rolloutDays.default, feedback: 0, revisions: 0 }; + }); + stages.push(s3); + currentDate = s3.end; + + // Stages 4 & 5: Translation (parallel) + 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); + return { handover: handoverDays, wip: wcOpt ? wcOpt.days : 0, feedback: cfg.stage4_5.fields.marketApprovalDays.default, revisions: cfg.stage4_5.fields.revisionRounds.default * cfg.stage4_5.fields.revisionDays.default }; + }); + stages.push({ ...s45, label: 'Translation' }); + currentDate = s45.end; + + // Stage 6: Production + const s6 = calcStage(5, matrix[5], currentDate, () => { + const complexity = cfg.stage6.fields.complexity.default; + const key = complexity + '_' + assetVolume; + const table = cfg.stage6.fields.crossReferenceTable; + return { handover: handoverDays, wip: table[key] || 0, feedback: cfg.stage6.fields.marketApprovalDays.default, revisions: cfg.stage6.fields.revisionRounds.default * cfg.stage6.fields.revisionDays.default }; + }); + stages.push(s6); + currentDate = s6.end; + + // Stage 7: Opera Upload + const s7 = calcStage(6, matrix[6], currentDate, () => { + const daysMap = cfg.stage7.daysByAssetVolume; + return { handover: handoverDays, wip: daysMap[assetVolume] || cfg.stage7.defaultDays, feedback: 0, revisions: 0 }; + }); + stages.push(s7); + currentDate = s7.end; + + // Stage 8: Syndication + const s8 = calcStage(7, matrix[7], currentDate, () => { + const complexity = cfg.stage8.fields.complexity.default; + const eanVolume = cfg.stage8.fields.eanVolume.default; + const key = complexity + '_' + eanVolume; + const table = cfg.stage8.fields.crossReferenceTable; + return { handover: handoverDays, wip: table[key] || 0, feedback: 0, revisions: 0 }; + }); + stages.push(s8); + + // Total business days + const totalBizDays = businessDaysBetween(tempStart, s8.end); + + // Add syndication buffer if applicable + let totalWithBuffer = totalBizDays; + if (matrix[7]) { + totalWithBuffer += CONFIG.meta.syndicationBufferDays; + } + + // Work backwards from go-live + const submitBy = subtractBusinessDays(goLive, totalWithBuffer); + const estimatedCompletion = addBusinessDays(submitBy, totalBizDays); + + renderResults({ + briefType: bt, + stages: stages.filter(s => s.active), + goLive, + submitBy, + estimatedCompletion, + totalBizDays, + totalWithBuffer, + syndicationActive: matrix[7] + }); +} + +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) }; + } + const p = getParams(); + const totalDays = p.handover + p.wip + p.feedback + p.revisions; + return { index: stageIndex, label: stage ? stage.label : '', active: true, days: totalDays, end: addBusinessDays(prevDate, totalDays) }; +} + +// ---- Render Results ---- +function renderResults(data) { + const resultsEl = document.getElementById('results'); + resultsEl.classList.remove('hidden'); + + // Brief type + document.getElementById('resultBriefType').textContent = data.briefType.label; + const desc = Array.isArray(data.briefType.description) ? data.briefType.description.join(' · ') : data.briefType.description; + document.getElementById('resultBriefDesc').textContent = desc; + + // Deadline banner + const banner = document.getElementById('deadlineBanner'); + const today = new Date(); today.setHours(0, 0, 0, 0); + const submitDate = new Date(data.submitBy); submitDate.setHours(0, 0, 0, 0); + const isPast = submitDate < 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 submission deadline has already passed
+To go live on ${formatDate(data.goLive)}, the brief needed to be submitted by ${formatDate(data.submitBy)}.
+Please discuss an adjusted timeline with your project manager.
+Submit your brief by ${formatDate(data.submitBy)}
+This gives us ${data.totalBizDays} business days to deliver before your go-live on ${formatDate(data.goLive)}.
+ ${data.syndicationActive ? `Includes ${CONFIG.meta.syndicationBufferDays}-day syndication buffer for retailer requirements.
` : ''} +