loreal-sla-calculator/market-script.js
Phil Dore 586cb57155 Simplify Stage 8 syndication and derive Advisor Stage 6 complexity
- Stage 8: replace 3x3 complexity x EAN grid with single syndicationType
  dropdown -- Salsify Prep Only (4d), Syndication PDP (5d), Syndication
  Non-PDP (3d). Advisor maps contentType to PDP/Non-PDP only; Salsify
  Prep Only is not surfaced in the Advisor (team decision).
- Stage 6 (Advisor): derive complexity from staticWorkType / videoWorkType
  / needsHTML toggles via deriveProductionComplexity helper. Precedence
  bespoke > creation > complex > simple, max across enabled toggles.
  HTML-only falls back to complex (placeholder, may revisit).
- Bump config.json cache-bust to 2026050801.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:24:02 +01:00

930 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// =============================================================================
// 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 + 030 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 = '<ul class="list-disc list-inside space-y-0.5">' +
descriptions.map(item => `<li>${item}</li>`).join('') + '</ul>';
} else {
descEl.textContent = descriptions;
}
descEl.classList.remove('hidden');
// Show "Suggested" or "Overridden" hint
if (data.overrideBriefId && data.overrideBriefId !== data.suggestedBriefId) {
descEl.innerHTML += `<p class="mt-1.5 text-xs italic text-amber-600 dark:text-amber-400">You changed from the suggested type: ${CONFIG.briefTypes.find(b => b.id === data.suggestedBriefId)?.label || ''}</p>`;
}
// 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 = `
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-full bg-red-500 dark:bg-red-600 flex items-center justify-center flex-shrink-0 shadow">
<svg class="w-8 h-8" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</div>
<div>
<p class="font-bold text-red-800 dark:text-red-300 text-lg">The brief acceptance deadline has already passed</p>
<p class="text-sm text-red-600 dark:text-red-400 mt-0.5">To complete work by <strong>${formatDate(data.goLive)}</strong>, the brief needed to be accepted by <strong>${formatDate(data.briefAcceptedBy)}</strong>.</p>
<p class="text-xs text-red-500 dark:text-red-400 mt-1">Please discuss an adjusted timeline with your project manager.</p>
</div>
</div>`;
} 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 = `
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-full bg-green-500 dark:bg-green-600 flex items-center justify-center flex-shrink-0 shadow">
<svg class="w-8 h-8" fill="none" stroke="white" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</div>
<div>
<p class="font-bold text-green-800 dark:text-green-300 text-lg">Brief must be accepted by ${formatDate(data.briefAcceptedBy)}</p>
<p class="text-sm text-green-600 dark:text-green-400 mt-0.5">Submit by <strong>${formatDate(data.submitBy)}</strong> to allow processing time. This gives us <strong>${data.totalBizDays} business days</strong> to complete by <strong>${formatDate(data.goLive)}</strong>.</p>
</div>
</div>`;
}
} // 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 = `
<div class="flex items-start gap-2.5">
<svg class="w-4 h-4 text-orange-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<p class="text-xs text-orange-800 dark:text-orange-200"><strong>Urgent Brief:</strong> Brief production time is <strong>6 hours</strong> + <strong>${confirmDays} day(s)</strong> brief confirmation time.</p>
</div>`;
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 = '<a href="#" id="showHideTimeline" class="text-xs text-brand-600 dark:text-brand-400 underline hover:text-brand-800">Show timeline details</a>';
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 = '<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">What we need to do</h3>';
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 = `
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-brand-500"></span>
<span class="font-medium text-gray-700 dark:text-gray-300">${s.label}</span>
</div>
<div class="text-right">
<span class="text-gray-500 dark:text-gray-400 text-xs">${s.days} day${s.days !== 1 ? 's' : ''}</span>
<span class="text-gray-400 dark:text-gray-500 text-xs ml-2">${formatDate(s.startDate)}${formatDate(s.endDate)}</span>
</div>
`;
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 = `<div style="font-family:Calibri,Arial,sans-serif;font-size:14px;color:#1f2937;">`;
html += `<h2 style="margin:0 0 12px 0;font-size:18px;">SLA Brief Advisor — Summary</h2>`;
html += `<p style="margin:3px 0;"><strong>Brief Type:</strong> ${data.briefType.label}${manualNote}</p>`;
html += `<p style="margin:3px 0;"><strong>Oliver Tasks Completed By:</strong> ${formatDate(data.goLive)}</p>`;
html += `<p style="margin:3px 0;"><strong>Submit Brief By:</strong> ${formatDate(data.submitBy)}</p>`;
html += `<p style="margin:3px 0;"><strong>Brief Accepted By:</strong> ${formatDate(data.briefAcceptedBy)}</p>`;
html += `<p style="margin:3px 0;"><strong>Estimated Completion:</strong> ${formatDate(data.estimatedCompletion)}</p>`;
html += `<p style="margin:3px 0;"><strong>Total Business Days:</strong> ${data.totalBizDays}</p>`;
html += `<p style="margin:3px 0 16px 0;"><strong>Earliest Completion (if accepted today):</strong> ${formatDate(data.earliestCompletion)}</p>`;
// Timeline table
html += `<h3 style="margin:0 0 6px 0;font-size:15px;">Timeline Breakdown</h3>`;
html += `<table style="${tbl}">`;
html += `<tr><th style="${thL}">Stage</th><th style="${th}">Days</th><th style="${th}">Start</th><th style="${th}">End</th></tr>`;
data.stages.forEach(s => {
html += `<tr><td style="${tdL}">${s.label}</td><td style="${td}">${s.days}</td><td style="${td}">${formatDate(s.startDate)}</td><td style="${td}">${formatDate(s.endDate)}</td></tr>`;
});
html += `</table>`;
// Disclaimer
html += `<p style="margin:12px 0 0;font-size:12px;color:#92400e;"><strong>These timings are for reference only and assume suitable working files have been provided.</strong></p>`;
html += `<p style="margin:4px 0;font-size:12px;color:#92400e;"><strong>Please confirm final deadlines with your BCM after studio evaluation. Creative requests follow ad-hoc production timelines.</strong></p>`;
html += `</div>`;
// 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 = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 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 = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/></svg> Show Gantt`;
} else {
btn.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/></svg> 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 = '<p class="text-gray-500 text-sm">No active stages to display.</p>';
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 += `<div class="absolute border-l border-gray-200 dark:border-gray-600 text-[10px] font-semibold text-gray-500 dark:text-gray-400 pl-1 truncate leading-4" style="left:${i * DAY_W}px;width:${7 * DAY_W}px">${day.getDate()} ${MONTHS[day.getMonth()]}</div>`;
}
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 += `<div class="absolute border-l border-gray-100 dark:border-gray-700/60 text-[9px] text-center leading-4 ${textCls}" style="left:${i * DAY_W}px;width:${DAY_W}px">${letter}</div>`;
if (isWeekend) {
weekendBands += `<div class="absolute top-0 bottom-0 bg-gray-100 dark:bg-gray-800/40 pointer-events-none" style="left:${i * DAY_W}px;width:${DAY_W}px"></div>`;
}
});
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 += `<div class="absolute ${color} rounded h-full flex items-center px-1.5 text-white text-[10px] font-semibold shadow-sm overflow-hidden" style="left:${leftPx}px;width:${widthPx}px" title="${phase.label}: ${formatDate(phase.start)}${formatDate(phase.end)}">${phase.label}</div>`;
});
} else {
const leftPx = toDayIdx(s.startDate) * DAY_W;
const widthPx = Math.max((toDayIdx(s.endDate) - toDayIdx(s.startDate)) * DAY_W, DAY_W);
barsHtml = `<div class="absolute bg-violet-600 dark:bg-violet-700 rounded h-full flex items-center px-1.5 text-white text-[10px] font-semibold shadow-sm overflow-hidden" style="left:${leftPx}px;width:${widthPx}px" title="${s.label}: ${formatDate(s.startDate)}${formatDate(s.endDate)}">${s.label}</div>`;
}
rows += `
<div class="flex items-center ${idx > 0 ? 'border-t border-gray-100 dark:border-gray-700/50' : ''}">
<div class="shrink-0 text-right pr-3 py-2" style="width:${LABEL_W}px">
<span class="text-[11px] font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wide leading-tight">${s.label}</span>
</div>
<div class="relative overflow-hidden" style="width:${timelineWidth}px;height:28px">
${weekendBands}
${barsHtml}
</div>
</div>`;
});
const estCompletionPx = LABEL_W + toDayIdx(data.estimatedCompletion) * DAY_W;
const goLiveMarker = `
<div class="absolute top-0 bottom-0 z-10 pointer-events-none" style="left:${estCompletionPx}px">
<div class="w-0 h-full border-l-2 border-dashed border-red-400 dark:border-red-500"></div>
<div class="absolute -top-5 -translate-x-1/2 text-[9px] font-bold text-red-500 dark:text-red-400 whitespace-nowrap">EST. COMPLETION</div>
</div>`;
container.innerHTML = `
<div class="overflow-x-auto">
<div style="min-width:${LABEL_W + timelineWidth}px">
<div class="flex items-stretch">
<div class="shrink-0" style="width:${LABEL_W}px"></div>
<div class="relative" style="width:${timelineWidth}px;height:16px">${weekRow}</div>
</div>
<div class="flex items-stretch mb-1">
<div class="shrink-0" style="width:${LABEL_W}px"></div>
<div class="relative" style="width:${timelineWidth}px;height:14px">${dayRow}</div>
</div>
<div class="relative border-t border-gray-200 dark:border-gray-700">
${goLiveMarker}
${rows}
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-400 dark:text-gray-500">
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-violet-600 inline-block"></span> WIP</span>
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-amber-400 inline-block"></span> Feedback</span>
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-violet-400 inline-block"></span> Revision WIP</span>
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-gray-100 border border-gray-200 inline-block"></span> Weekend</span>
<span class="flex items-center gap-1.5"><span class="w-4 border-t-2 border-dashed border-red-400 inline-block"></span> Est. Completion</span>
</div>
`;
}
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);
}