diff --git a/config.json b/config.json index ee15020..21822a1 100755 --- a/config.json +++ b/config.json @@ -350,7 +350,9 @@ "daysByAssetVolume": { "0_50": 1, "50_100": 2, - "100_200": 3 + "100_200": 3, + "200_300": 4, + "300_400": 5 }, "defaultDays": 1 }, diff --git a/market-script.js b/market-script.js index dc9d958..ff1daf8 100644 --- a/market-script.js +++ b/market-script.js @@ -162,7 +162,7 @@ function resetForm() { const panel = document.getElementById(cb.dataset.feedback); if (panel) panel.classList.add('hidden'); }); - const feedbackDefaults = { feedbackDaysStatic: 3, feedbackDaysVideo: 3, feedbackDaysHTML: 3, feedbackDaysTranslation: 5 }; + 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; @@ -265,17 +265,22 @@ function formatDate(date) { return date.getDate() + ' ' + months[date.getMonth()] + ' ' + date.getFullYear(); } -// ---- Read user-supplied feedback days (defaults to 0 if toggle is off) ---- +// ---- 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, - video: videoChecked ? parseFloat(document.getElementById('feedbackDaysVideo').value) || 0 : 0, + 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, - translation: document.getElementById('needsTranslation').checked ? parseFloat(document.getElementById('feedbackDaysTranslation').value) || 0 : 0 + 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, }; } @@ -292,7 +297,6 @@ function calculateAndRender(overrideBriefId) { 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); @@ -329,10 +333,14 @@ function calculateAndRender(overrideBriefId) { 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); - 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 }; + 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; @@ -341,14 +349,14 @@ function calculateAndRender(overrideBriefId) { 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 }; + 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 { handover: handoverDays, wip: cfg.stage3.fields.rolloutDays.default, feedback: 0, revisions: 0 }; + return { wip: cfg.stage3.fields.rolloutDays.default, feedback: 0, revisions: 0, rounds: 0 }; }); stages.push(s3); currentDate = s3.end; @@ -358,7 +366,8 @@ function calculateAndRender(overrideBriefId) { 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; - return { handover: handoverDays, wip: wcOpt ? wcOpt.days : 0, feedback: translationFeedback, revisions: cfg.stage4_5.fields.revisionRounds.default * cfg.stage4_5.fields.revisionDays.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; @@ -366,14 +375,14 @@ function calculateAndRender(overrideBriefId) { // Stage 6: Production — use user feedback days from production toggles const s6 = calcStage(5, matrix[5], currentDate, () => { if (isUrgentScenario) { - // 6-hour production (1 business day) + brief confirmation time - return { handover: 0, wip: 1, feedback: fb.static, revisions: 0 }; + return { wip: 1, feedback: fb.static, revisions: 0, rounds: 0 }; } const complexity = cfg.stage6.fields.complexity.default; const key = complexity + '_' + assetVolume; const table = cfg.stage6.fields.crossReferenceTable; const prodFeedback = productionFeedback > 0 ? productionFeedback : cfg.stage6.fields.marketApprovalDays.default; - return { handover: handoverDays, wip: (table[key] || 0) + (fb.videoWorkDays || 0), feedback: prodFeedback, revisions: cfg.stage6.fields.revisionRounds.default * cfg.stage6.fields.revisionDays.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); @@ -382,7 +391,7 @@ function calculateAndRender(overrideBriefId) { // 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 }; + return { wip: daysMap[assetVolume] || cfg.stage7.defaultDays, feedback: 0, revisions: 0, rounds: 0 }; }); stages.push(s7); currentDate = s7.end; @@ -393,33 +402,29 @@ function calculateAndRender(overrideBriefId) { 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 }; + return { wip: table[key] || 0, feedback: 0, revisions: 0, rounds: 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 — no assumed syndication buffer + const briefAcceptedBy = subtractBusinessDays(goLive, totalBizDays); + const submitBy = subtractBusinessDays(briefAcceptedBy, 2); + const estimatedCompletion = addBusinessDays(briefAcceptedBy, totalBizDays); - // Work backwards from go-live - const submitBy = subtractBusinessDays(goLive, totalWithBuffer); - const estimatedCompletion = addBusinessDays(submitBy, totalBizDays); - - // Earliest go-live: if brief submitted today, when could we finish? + // Earliest completion: if brief accepted today, when could we finish? const today = new Date(); today.setHours(0, 0, 0, 0); - const earliestGoLive = addBusinessDays(today, totalWithBuffer); + const earliestCompletion = addBusinessDays(today, totalBizDays); - // Compute real calendar dates for each active stage (relative to submitBy) + // Compute real calendar dates + phases for each active stage (relative to briefAcceptedBy) const activeStages = stages.filter(s => s.active); - let runningDate = new Date(submitBy); + 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; }); @@ -429,12 +434,11 @@ function calculateAndRender(overrideBriefId) { suggestedBriefId: determineBriefType(), stages: activeStages, goLive, + briefAcceptedBy, submitBy, estimatedCompletion, totalBizDays, - totalWithBuffer, - syndicationActive: matrix[7], - earliestGoLive, + earliestCompletion, needsVideo: document.getElementById('needsVideo').checked, isUrgentScenario }); @@ -443,11 +447,41 @@ function calculateAndRender(overrideBriefId) { 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) }; + return { index: stageIndex, label: stage ? stage.label : '', active: false, days: 0, end: new Date(prevDate), params: null, phases: [] }; } 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) }; + 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 ---- @@ -494,8 +528,8 @@ function renderResults(data) { banner.innerHTML = ''; } else { 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; + 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'; @@ -505,8 +539,8 @@ function renderResults(data) {
The submission deadline has already passed
-To go live on ${formatDate(data.goLive)}, the brief needed to be submitted by ${formatDate(data.submitBy)}.
+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.
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.
` : ''} +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)}.
Brief Type: ${data.briefType.label}${manualNote}
`; - html += `Required Go-Live: ${formatDate(data.goLive)}
`; + 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.totalWithBuffer}
`; - html += `Earliest Go-Live (if submitted today): ${formatDate(data.earliestGoLive)}
`; + html += `Total Business Days: ${data.totalBizDays}
`; + html += `Earliest Completion (if accepted today): ${formatDate(data.earliestCompletion)}
`; // Timeline table html += `Assumes 2-week lead time between syndication and Live Date.
`; - html += `These timings are for reference only. Please confirm final deadlines with your BCM after studio evaluation. Creative requests follow ad-hoc production timelines.
`; + 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 += `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 += `