loreal-sla-calculator/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

1272 lines
51 KiB
JavaScript
Executable file

// =============================================================================
// SLA Calculator - Script
// All business rules loaded from config.json. Zero hard-coded logic.
// =============================================================================
let CONFIG = null;
// ── API fetch helper ──────────────────────────────────────────────────────────
// Automatically includes the in-memory access token as a Bearer header.
// On 401, attempts one silent token refresh before giving up.
async function apiFetch(path, options = {}) {
const url = `/loreal-sla-calculator/api${path}`;
const headers = { ...(options.headers || {}) };
if (currentAccessToken) headers['Authorization'] = `Bearer ${currentAccessToken}`;
let res = await fetch(url, { ...options, credentials: 'include', headers });
if (res.status === 401) {
// Try to refresh and retry once
const refreshed = await tryRefresh();
if (refreshed && currentAccessToken) {
headers['Authorization'] = `Bearer ${currentAccessToken}`;
res = await fetch(url, { ...options, credentials: 'include', headers });
}
}
return res;
}
let currentStep = 1;
let activeStages = [false, false, false, false, false, false, false, false];
let lastCalculationData = null;
// ---- Usage Tracking ----
const TRACK_API = (() => {
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return null;
const base = location.pathname.replace(/\/[^/]*$/, '');
return `${base}/api/events`;
})();
async function getVisitorId() {
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;
getVisitorId().then(visitor_id => {
fetch(TRACK_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, page: 'calculator', visitor_id, metadata }),
}).catch(() => {});
});
}
// ---- Bootstrap ----
document.addEventListener('DOMContentLoaded', async () => {
await initAuth();
await loadConfig();
initDarkMode();
initStepper();
initDatePickers();
bindEvents();
trackEvent('page_view');
});
async function loadConfig() {
try {
const res = await fetch('config.json?v=2026050801');
CONFIG = await res.json();
populateBriefTypes();
populateStageDropdowns();
} catch (e) {
console.error('Failed to load config.json:', e);
alert('Error loading configuration. Please ensure config.json is in the same directory.');
}
}
// ---- Populate Dropdowns from Config ----
function populateBriefTypes() {
const sel = document.getElementById('briefType');
CONFIG.briefTypes.forEach(bt => {
const opt = document.createElement('option');
opt.value = bt.id;
opt.textContent = bt.label;
sel.appendChild(opt);
});
}
function populateStageDropdowns() {
const cfg = CONFIG.stageConfigs;
// Stage 1 complexity
populateSelect('stage1Complexity', cfg.stage1.fields.complexity.options, 'value', 'label');
// Stage 2
populateSelect('stage2MasteringComplexity', cfg.stage2.fields.masteringComplexity.options, 'value', 'label');
populateSelect('stage2CopyComplexity', cfg.stage2.fields.copyComplexity.options, 'value', 'label');
// Stage 4/5 word count
populateSelect('stage45WordCount', cfg.stage4_5.fields.wordCount.options, 'value', 'label');
// Stage 6
populateSelect('stage6Complexity', cfg.stage6.fields.complexity.options, 'value', 'label');
populateSelect('stage6AssetVolume', cfg.stage6.fields.assetVolume.options, 'value', 'label');
// Stage 8
populateSelect('stage8SyndicationType', cfg.stage8.fields.syndicationType.options, 'value', 'label');
document.getElementById('stage8SyndicationType').value = cfg.stage8.fields.syndicationType.default;
}
function populateSelect(id, options, valueKey, labelKey) {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = '';
options.forEach(opt => {
const o = document.createElement('option');
o.value = opt[valueKey];
o.textContent = opt[labelKey];
sel.appendChild(o);
});
}
// ---- 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');
}
document.getElementById('darkModeToggle').addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark'));
});
}
// ---- Stepper ----
function initStepper() {
document.querySelectorAll('.step-item').forEach(item => {
item.addEventListener('click', () => {
const step = parseInt(item.dataset.step);
if (step <= currentStep || canAdvanceTo(step)) {
goToStep(step);
}
});
});
}
function canAdvanceTo(step) {
if (step >= 2 && !document.getElementById('briefType').value) return false;
if (step >= 4 && !document.getElementById('kickOffDate').value) return false;
return true;
}
function goToStep(step) {
if (step > currentStep + 1) return;
// Validation gates
if (step >= 2 && !document.getElementById('briefType').value) return;
if (step >= 4 && !document.getElementById('kickOffDate').value) {
alert('Please enter the Project Kick-Off Date before proceeding.');
return;
}
currentStep = step;
// Show/hide step content
document.querySelectorAll('.step-content').forEach(s => s.classList.add('hidden'));
const target = document.getElementById('step' + step);
if (target) {
target.classList.remove('hidden');
target.classList.add('fade-in');
}
// Update stepper UI
document.querySelectorAll('.step-item').forEach(item => {
const s = parseInt(item.dataset.step);
const num = item.querySelector('.step-number');
if (s < step || (s === step && step === 4)) {
num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-green-500 text-white text-sm font-semibold shrink-0';
num.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="3" d="M5 13l4 4L19 7"/></svg>';
} else if (s === step) {
num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-brand-600 text-white text-sm font-semibold shrink-0';
num.textContent = s;
} else {
num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-sm font-semibold shrink-0';
num.textContent = s;
}
});
// Run calculation when entering step 4
if (step === 4) {
calculateSLA();
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ---- Date Pickers ----
function initDatePickers() {
const fpConfig = {
dateFormat: 'Y-m-d',
altInput: true,
altFormat: 'd M Y',
disableMobile: true,
minDate: 'today',
disable: [
function(date) {
// Disable weekends (Sat=6, Sun=0)
return date.getDay() === 0 || date.getDay() === 6;
}
]
};
flatpickr('#kickOffDate', { ...fpConfig, onChange: () => validateStep3() });
flatpickr('#goLiveDate', { ...fpConfig, onChange: () => validateStep3() });
}
function validateStep3() {
const kick = document.getElementById('kickOffDate').value;
const btn = document.getElementById('btnStep3Next');
btn.disabled = !kick;
}
// ---- Event Bindings ----
function bindEvents() {
// Brief type change
document.getElementById('briefType').addEventListener('change', onBriefTypeChange);
// Stage toggles
document.querySelectorAll('.stage-toggle').forEach(toggle => {
toggle.addEventListener('change', onStageToggle);
});
// Production complexity/volume change -> update base days and opera days
const stage6Inputs = ['stage6Complexity', 'stage6AssetVolume'];
stage6Inputs.forEach(id => {
document.getElementById(id).addEventListener('change', () => {
updateProductionBaseDays();
updateOperaDays();
});
});
// Syndication type change -> update base days
document.getElementById('stage8SyndicationType').addEventListener('change', updateSyndicationBaseDays);
}
// ---- Brief Type Change ----
function onBriefTypeChange() {
const briefId = document.getElementById('briefType').value;
if (!briefId) {
document.getElementById('projectTypeDisplay').textContent = 'Select a Brief Type first';
document.getElementById('briefDescription').classList.add('hidden');
document.getElementById('stageMatrixPreview').classList.add('hidden');
document.getElementById('btnStep1Next').disabled = true;
return;
}
const bt = CONFIG.briefTypes.find(b => b.id === briefId);
document.getElementById('projectTypeDisplay').textContent = bt.projectType;
document.getElementById('projectTypeDisplay').classList.remove('text-gray-500');
document.getElementById('projectTypeDisplay').classList.add('font-semibold');
// Description
const descEl = document.getElementById('briefDescription');
if (Array.isArray(bt.description)) {
descEl.innerHTML = '<ul class="list-disc list-inside space-y-1">' +
bt.description.map(item => `<li>${item}</li>`).join('') + '</ul>';
} else {
descEl.textContent = bt.description;
}
descEl.classList.remove('hidden');
// Stage matrix
const matrix = CONFIG.stageMatrix[briefId];
activeStages = [...matrix];
// Force always-active stages (e.g. Opera Upload)
CONFIG.stages.forEach((stage, i) => {
const cfg = CONFIG.stageConfigs['stage' + stage.number];
if (cfg && cfg.alwaysActive) activeStages[i] = true;
});
renderStageMatrixBadges(activeStages);
document.getElementById('stageMatrixPreview').classList.remove('hidden');
document.getElementById('btnStep1Next').disabled = false;
// Update stage cards visibility and toggles
updateStageCards();
updateProductionBaseDays();
updateOperaDays();
updateSyndicationBaseDays();
}
function renderStageMatrixBadges(matrix) {
const container = document.getElementById('stageMatrixBadges');
container.innerHTML = '';
CONFIG.stages.forEach((stage, i) => {
const active = matrix[i];
const cfg = CONFIG.stageConfigs['stage' + stage.number];
const locked = cfg && cfg.alwaysActive;
const badge = document.createElement('div');
badge.className = `flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium select-none transition-colors ${
locked ? 'cursor-default' : 'cursor-pointer'
} ${
active
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40'
: 'bg-gray-50 dark:bg-gray-700/50 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600/50'
}`;
badge.innerHTML = `
<span class="w-5 h-5 flex items-center justify-center rounded-full text-[10px] font-bold ${
active ? 'bg-green-500 text-white' : 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
}">${active ? 'Y' : 'N'}</span>
${stage.shortLabel}${locked ? ' <span class="text-[9px] opacity-60">(Required)</span>' : ''}
`;
if (!locked) {
badge.addEventListener('click', () => {
// Stages 4 & 5 (Translation) toggle together
if (i === 3 || i === 4) {
activeStages[3] = !activeStages[3];
activeStages[4] = activeStages[3];
} else {
activeStages[i] = !activeStages[i];
}
renderStageMatrixBadges(activeStages);
updateStageCards();
updateProductionBaseDays();
updateOperaDays();
updateSyndicationBaseDays();
});
}
container.appendChild(badge);
});
}
// ---- Stage Cards ----
function updateStageCards() {
const cardMap = {
0: 'stage1Card',
1: 'stage2Card',
2: 'stage3Card',
3: 'stage45Card', // stage 4
4: 'stage45Card', // stage 5 (same card)
5: 'stage6Card',
6: 'stage7Card',
7: 'stage8Card'
};
// Show/hide and set toggles
const shownCards = new Set();
for (let i = 0; i < 8; i++) {
const cardId = cardMap[i];
if (shownCards.has(cardId)) continue;
shownCards.add(cardId);
const card = document.getElementById(cardId);
if (!card) continue;
// Determine if this card's stages are active
let isActive;
if (cardId === 'stage45Card') {
isActive = activeStages[3] || activeStages[4];
} else {
isActive = activeStages[i];
}
card.classList.remove('hidden');
// Check if this stage is always-active (locked)
const stageNum = CONFIG.stages[i] ? CONFIG.stages[i].number : null;
const stageCfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null;
const locked = stageCfg && stageCfg.alwaysActive;
// Set toggle
const toggle = card.querySelector('.stage-toggle');
if (toggle) {
toggle.checked = isActive;
// Disable toggle for always-active stages
if (locked) {
toggle.disabled = true;
toggle.closest('label').classList.add('opacity-50', 'cursor-not-allowed');
toggle.closest('label').classList.remove('cursor-pointer');
}
// Update card appearance
const fields = card.querySelector('.stage-fields');
if (fields) {
if (isActive) {
card.classList.remove('inactive');
fields.style.display = '';
} else {
card.classList.add('inactive');
fields.style.display = 'none';
}
}
}
}
}
function onStageToggle(e) {
const stageKey = e.target.dataset.stage;
const checked = e.target.checked;
// Prevent disabling always-active stages
if (!checked) {
const idx = parseInt(stageKey);
const stageNum = CONFIG.stages[idx] ? CONFIG.stages[idx].number : null;
const cfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null;
if (cfg && cfg.alwaysActive) {
e.target.checked = true;
return;
}
}
if (stageKey === '3_4') {
// Translation stages 4 and 5
activeStages[3] = checked;
activeStages[4] = checked;
} else {
const idx = parseInt(stageKey);
activeStages[idx] = checked;
}
// Update card appearance
const card = e.target.closest('.stage-card');
const fields = card.querySelector('.stage-fields');
if (fields) {
if (checked) {
card.classList.remove('inactive');
fields.style.display = '';
} else {
card.classList.add('inactive');
fields.style.display = 'none';
}
}
// Update badges
renderStageMatrixBadges(activeStages);
}
// ---- Dynamic Lookups ----
function updateProductionBaseDays() {
const complexity = document.getElementById('stage6Complexity').value;
const volume = document.getElementById('stage6AssetVolume').value;
const key = complexity + '_' + volume;
const table = CONFIG.stageConfigs.stage6.fields.crossReferenceTable;
const days = table[key];
document.getElementById('stage6BaseDays').textContent = days !== undefined ? days + ' days' : '--';
}
function updateOperaDays() {
const volume = document.getElementById('stage6AssetVolume').value;
const daysMap = CONFIG.stageConfigs.stage7.daysByAssetVolume;
const days = daysMap[volume] || CONFIG.stageConfigs.stage7.defaultDays;
document.getElementById('stage7Days').textContent = days + ' day' + (days > 1 ? 's' : '');
}
function updateSyndicationBaseDays() {
const value = document.getElementById('stage8SyndicationType').value;
const opt = CONFIG.stageConfigs.stage8.fields.syndicationType.options.find(o => o.value === value);
const days = opt ? opt.days : undefined;
document.getElementById('stage8BaseDays').textContent = days !== undefined ? days + ' days' : '--';
}
// ---- Business Day Arithmetic ----
function addBusinessDays(startDate, days) {
const result = new Date(startDate);
let added = 0;
while (added < days) {
result.setDate(result.getDate() + 1);
const dow = result.getDay();
if (dow !== 0 && dow !== 6) {
added++;
}
}
return result;
}
function businessDaysBetween(start, end) {
let count = 0;
const d = new Date(start);
while (d < end) {
d.setDate(d.getDate() + 1);
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
}
return count;
}
function formatDate(date) {
if (!date) return 'n.a.';
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return date.getDate().toString().padStart(2, '0') + '-' + months[date.getMonth()];
}
function formatDateLong(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();
}
// ---- SLA Calculation Engine ----
function calculateSLA() {
const kickOffStr = document.getElementById('kickOffDate').value;
const goLiveStr = document.getElementById('goLiveDate').value;
const briefName = document.getElementById('briefName').value || 'Unnamed Brief';
if (!kickOffStr) return;
const kickOff = new Date(kickOffStr + 'T00:00:00');
const goLive = goLiveStr ? new Date(goLiveStr + 'T00:00:00') : null;
const cfg = CONFIG.stageConfigs;
const results = [];
let currentDate = new Date(kickOff);
// Stage 1: Missing DMI Asset Creation
results.push(calcGenericStage(0, activeStages[0], currentDate, () => {
const complexity = document.getElementById('stage1Complexity').value;
const opt = cfg.stage1.fields.complexity.options.find(o => o.value === complexity);
const wip = opt ? opt.days : 0;
const feedback = intVal('stage1MarketApproval');
const rounds = intVal('stage1RevisionRounds');
const revDays = intVal('stage1RevisionDays');
return { handover: 0, wip, feedback, revisions: rounds * revDays, rounds };
}));
currentDate = results[0].completeDate;
// Stage 2: Mastering, Copy Creation / Extraction
results.push(calcGenericStage(1, activeStages[1], currentDate, () => {
const masterOpt = cfg.stage2.fields.masteringComplexity.options.find(o => o.value === document.getElementById('stage2MasteringComplexity').value);
const copyOpt = cfg.stage2.fields.copyComplexity.options.find(o => o.value === document.getElementById('stage2CopyComplexity').value);
const masterDays = masterOpt ? masterOpt.days : 0;
const copyDays = copyOpt ? copyOpt.days : 0;
const wip = masterDays + copyDays;
return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0 };
}));
currentDate = results[1].completeDate;
// Stage 3: Global Rollout Invitation
results.push(calcGenericStage(2, activeStages[2], currentDate, () => {
const wip = intVal('stage3RolloutDays');
return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true };
}));
currentDate = results[2].completeDate;
// Stages 4 & 5: Translation (run in parallel, so counted once)
const translationActive = activeStages[3] || activeStages[4];
const translationResult = calcGenericStage(3, translationActive, currentDate, () => {
const wcOpt = cfg.stage4_5.fields.wordCount.options.find(o => o.value === document.getElementById('stage45WordCount').value);
const wip = wcOpt ? wcOpt.days : 0;
const feedback = intVal('stage45MarketApproval');
const rounds = intVal('stage45RevisionRounds');
const revDays = intVal('stage45RevisionDays');
return { handover: 0, wip, feedback, revisions: rounds * revDays, rounds };
});
// Stage 4 result
results.push({
...translationResult,
stageIndex: 3,
label: CONFIG.stages[3].label
});
// Stage 5 result (same dates since parallel)
results.push({
...translationResult,
stageIndex: 4,
label: CONFIG.stages[4].label,
active: activeStages[4]
});
currentDate = translationResult.completeDate;
// Stage 6: Production
results.push(calcGenericStage(5, activeStages[5], currentDate, () => {
const complexity = document.getElementById('stage6Complexity').value;
const volume = document.getElementById('stage6AssetVolume').value;
const key = complexity + '_' + volume;
const table = cfg.stage6.fields.crossReferenceTable;
let wip = table[key] || 0;
// Apply speed-up
const speedUp = intVal('stage6SpeedUp');
if (speedUp > 0) {
wip = Math.ceil(wip * (1 - speedUp / 100));
}
const feedback = intVal('stage6MarketApproval');
const rounds = intVal('stage6RevisionRounds');
const revDays = intVal('stage6RevisionDays');
return { handover: 0, wip, feedback, revisions: rounds * revDays, rounds };
}));
currentDate = results[5].completeDate;
// Stage 7: Opera Upload
results.push(calcGenericStage(6, activeStages[6], currentDate, () => {
const volume = document.getElementById('stage6AssetVolume').value;
const daysMap = cfg.stage7.daysByAssetVolume;
const wip = daysMap[volume] || cfg.stage7.defaultDays;
return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true };
}));
currentDate = results[6].completeDate;
// Stage 8: Syndication
results.push(calcGenericStage(7, activeStages[7], currentDate, () => {
const value = document.getElementById('stage8SyndicationType').value;
const opt = cfg.stage8.fields.syndicationType.options.find(o => o.value === value);
const wip = opt ? opt.days : 0;
return { handover: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true };
}));
const projectEndDate = results[7].completeDate;
// Suggested go-live: add syndication buffer if syndication is active
let suggestedGoLive = new Date(projectEndDate);
if (activeStages[7]) {
suggestedGoLive = addBusinessDays(projectEndDate, CONFIG.meta.syndicationBufferDays);
}
// Verdict: compare against suggestedGoLive (includes syndication buffer)
// so that retailer lead times are factored into the Y/N decision
const canMeet = goLive ? suggestedGoLive <= goLive : null;
renderResults({
briefName,
kickOff,
goLive,
projectEndDate,
suggestedGoLive,
canMeet,
stages: results
});
}
function calcGenericStage(stageIndex, isActive, prevDate, getParams) {
const stage = CONFIG.stages[stageIndex];
if (!isActive) {
return {
stageIndex,
label: stage.label,
active: false,
handover: 0,
wip: 0,
feedback: 0,
revisions: 0,
rounds: 0,
noFeedback: false,
completeDate: new Date(prevDate),
kickOffDate: null,
wipCompleteDate: null,
feedbackByDate: null
};
}
const params = getParams();
const totalDays = params.handover + params.wip + params.feedback + params.revisions;
const kickOffDate = addBusinessDays(prevDate, 0); // Next business day check not needed; start from prev
// Actually kickoff is the day after previous stage completes
const stageKickOff = addBusinessDays(prevDate, params.handover);
const wipComplete = addBusinessDays(stageKickOff, params.wip);
const feedbackBy = params.feedback > 0 ? addBusinessDays(wipComplete, params.feedback) : null;
const completeDate = addBusinessDays(prevDate, totalDays);
// Build sub-phase array for Gantt detail view
const phases = [];
if (params.handover > 0) {
phases.push({ label: 'Handover', type: 'handover', start: new Date(prevDate), end: new Date(stageKickOff) });
}
if (params.wip > 0) {
phases.push({ label: 'WIP 1', type: 'wip', start: new Date(stageKickOff), end: new Date(wipComplete) });
}
if (!params.noFeedback && params.rounds > 0) {
const revDaysPerRound = params.revisions / params.rounds;
let cursor = new Date(wipComplete);
// First feedback period
phases.push({ label: 'Feedback 1', type: 'feedback', start: new Date(cursor), end: new Date(feedbackBy) });
cursor = new Date(feedbackBy);
// Each revision round (WIP 2, WIP 3, ...)
for (let r = 1; r <= params.rounds; r++) {
const revEnd = addBusinessDays(cursor, revDaysPerRound);
phases.push({ label: `WIP ${r + 1}`, type: 'revision', start: new Date(cursor), end: revEnd });
cursor = revEnd;
}
} else if (!params.noFeedback && params.feedback > 0) {
phases.push({ label: 'Feedback', type: 'feedback', start: new Date(wipComplete), end: new Date(completeDate) });
}
return {
stageIndex,
label: stage.label,
active: true,
handover: params.handover,
wip: params.wip,
feedback: params.feedback,
revisions: params.revisions,
rounds: params.rounds || 0,
noFeedback: params.noFeedback || false,
completeDate,
kickOffDate: stageKickOff,
wipCompleteDate: wipComplete,
feedbackByDate: feedbackBy,
phases
};
}
function intVal(id) {
const el = document.getElementById(id);
return el ? parseFloat(el.value) || 0 : 0;
}
// ---- Render Results ----
function renderResults(data) {
lastCalculationData = data;
const briefType = document.getElementById('briefType').selectedOptions[0]?.text;
trackEvent('show_results', { briefType });
// Verdict banner
const banner = document.getElementById('verdictBanner');
const badge = document.getElementById('verdictBadge');
const msg = document.getElementById('verdictMessage');
document.getElementById('verdictBriefName').textContent = data.briefName;
const roundel = 'w-20 h-20 sm:w-24 sm:h-24 rounded-full flex items-center justify-center flex-shrink-0 shadow-lg';
const icon = 'w-10 h-10 sm:w-12 sm:h-12';
if (data.canMeet === null) {
banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-600';
badge.className = `${roundel} bg-gray-300 dark:bg-gray-600`;
badge.innerHTML = `<span class="text-3xl sm:text-4xl font-black text-white">?</span>`;
msg.textContent = 'Enter a Go-Live Date to see the verdict.';
} else if (data.canMeet) {
banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-800';
badge.className = `${roundel} bg-green-500 dark:bg-green-600`;
badge.innerHTML = `<svg class="${icon}" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
msg.textContent = CONFIG.verdictMessages.canMeet;
} else {
banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-800';
badge.className = `${roundel} bg-red-500 dark:bg-red-600`;
badge.innerHTML = `<svg class="${icon}" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`;
const gap = businessDaysBetween(data.goLive, data.suggestedGoLive);
const prodGap = businessDaysBetween(data.goLive, data.projectEndDate);
if (prodGap > 0) {
msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (${gap} business day${gap !== 1 ? 's' : ''} over deadline)`;
} else {
msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (production completes in time, but ${gap} business day${gap !== 1 ? 's' : ''} short for syndication retailer lead times)`;
}
}
// Key dates
document.getElementById('resultKickOff').textContent = formatDateLong(data.kickOff);
document.getElementById('resultGoLive').textContent = data.goLive ? formatDateLong(data.goLive) : 'Not set';
document.getElementById('resultEndDate').textContent = formatDateLong(data.projectEndDate);
document.getElementById('resultSuggestedGoLive').textContent = formatDateLong(data.suggestedGoLive);
// PM instruction
const pmEl = document.getElementById('pmInstruction');
if (data.canMeet === null) {
pmEl.textContent = 'Enter a Required Go-Live Date to see the full verdict and PM instructions.';
} else {
pmEl.textContent = data.canMeet ? CONFIG.pmInstructions.canMeet : CONFIG.pmInstructions.cannotMeet;
}
// Calculation table
const calcBody = document.getElementById('calculationTable');
calcBody.innerHTML = '';
data.stages.forEach(s => {
const tr = document.createElement('tr');
tr.className = 'border-b border-gray-100 dark:border-gray-700/50' + (!s.active ? ' text-gray-400 dark:text-gray-500' : '');
tr.innerHTML = `
<td class="py-2 pr-3 text-xs sm:text-sm">${s.label}</td>
<td class="py-2 px-2 text-center"><span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${s.active ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-400'}">${s.active ? 'Y' : 'N'}</span></td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? s.wip : 0}</td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0}</td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0}</td>
<td class="py-2 pl-2 text-center text-xs sm:text-sm font-medium">${formatDate(s.completeDate)}</td>
`;
calcBody.appendChild(tr);
});
// Key dates table
const datesBody = document.getElementById('keyDatesTable');
datesBody.innerHTML = '';
data.stages.forEach(s => {
const tr = document.createElement('tr');
tr.className = 'border-b border-gray-100 dark:border-gray-700/50' + (!s.active ? ' text-gray-400 dark:text-gray-500' : '');
tr.innerHTML = `
<td class="py-2 pr-3 text-xs sm:text-sm">${s.label}</td>
<td class="py-2 px-2 text-center"><span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${s.active ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-400'}">${s.active ? 'Y' : 'N'}</span></td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? formatDate(s.kickOffDate) : 'n.a.'}</td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.'}</td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.'}</td>
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.'}</td>
<td class="py-2 pl-2 text-center text-xs sm:text-sm font-medium">${s.active ? formatDate(s.completeDate) : 'n.a.'}</td>
`;
datesBody.appendChild(tr);
});
}
// ---- View Toggle (List / Gantt) ----
function setView(view) {
const listView = document.getElementById('listView');
const ganttView = document.getElementById('ganttView');
const btnList = document.getElementById('btnViewList');
const btnGantt = document.getElementById('btnViewGantt');
const activeClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm';
const inactiveClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200';
if (view === 'gantt') {
listView.classList.add('hidden');
ganttView.classList.remove('hidden');
btnGantt.className = activeClass;
btnList.className = inactiveClass;
renderGantt();
} else {
listView.classList.remove('hidden');
ganttView.classList.add('hidden');
btnList.className = activeClass;
btnGantt.className = inactiveClass;
}
}
function renderGantt() {
if (!lastCalculationData) return;
const data = lastCalculationData;
const container = document.getElementById('ganttChart');
// Filter active stages
const activeStgs = data.stages.filter(s => s.active);
if (activeStgs.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-sm">No active stages to display.</p>';
return;
}
// ── Day-grid setup ──────────────────────────────────────────────────────────
const DAY_W = 28; // px per calendar day
const LABEL_W = 192; // px for left stage-label column
const minDate = data.kickOff;
const maxDate = data.suggestedGoLive > data.projectEndDate ? data.suggestedGoLive : data.projectEndDate;
// Snap grid start to the Sunday of the kick-off week
const gridStart = new Date(minDate);
gridStart.setHours(0, 0, 0, 0);
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
// Pad end by one full week
const gridEnd = new Date(maxDate);
gridEnd.setHours(0, 0, 0, 0);
gridEnd.setDate(gridEnd.getDate() + 7);
// All calendar days in range
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;
// Day-index from gridStart (0-based)
const toDayIdx = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return Math.max(0, Math.round((d - gridStart) / 86400000));
};
// ── Phase colours ───────────────────────────────────────────────────────────
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',
};
// ── Header rows ─────────────────────────────────────────────────────────────
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const DAY_LETTERS = ['S','M','T','W','T','F','S']; // Sun=0
// Row 1: week-start date labels (every 7 days)
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>`;
}
// Row 2: individual day letters + weekend band collector
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-50 dark:bg-gray-800/40 pointer-events-none" style="left:${i * DAY_W}px;width:${DAY_W}px"></div>`;
}
});
// ── Stage rows ──────────────────────────────────────────────────────────────
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.kickOffDate || minDate) * DAY_W;
const widthPx = Math.max((toDayIdx(s.completeDate) - toDayIdx(s.kickOffDate || minDate)) * 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.kickOffDate)}${formatDate(s.completeDate)}">${formatDate(s.kickOffDate)}</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>`;
});
// ── Suggested go-live marker ────────────────────────────────────────────────
const suggestedGoLivePx = LABEL_W + toDayIdx(data.suggestedGoLive) * DAY_W;
const goLiveMarker = `
<div class="absolute top-0 bottom-0 z-10 pointer-events-none" style="left:${suggestedGoLivePx}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">SUGGESTED LIVE</div>
</div>`;
container.innerHTML = `
<div class="overflow-x-auto">
<div style="min-width:${LABEL_W + timelineWidth}px">
<!-- Header row 1: week dates -->
<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>
<!-- Header row 2: day letters -->
<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>
<!-- Stage rows -->
<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> Suggested Live</span>
</div>
`;
}
// ---- CSV Export ----
function exportCSV() {
if (!lastCalculationData) return;
const data = lastCalculationData;
const lines = [];
// Summary section
lines.push('SLA Calculator Report');
lines.push('');
lines.push('Brief Name,' + csvEscape(data.briefName));
lines.push('Brief Type,' + csvEscape(document.getElementById('briefType').selectedOptions[0]?.text || ''));
lines.push('Kick-Off Date,' + formatDateLong(data.kickOff));
lines.push('Required Go-Live Date,' + (data.goLive ? formatDateLong(data.goLive) : 'Not set'));
lines.push('Planned End Date (SLA),' + formatDateLong(data.projectEndDate));
lines.push('Suggested Go-Live (SLA),' + formatDateLong(data.suggestedGoLive));
lines.push('Can Meet Deadline?,' + (data.canMeet === null ? 'N/A' : data.canMeet ? 'YES' : 'NO'));
if (data.canMeet === false && data.goLive) {
const gap = businessDaysBetween(data.goLive, data.suggestedGoLive);
lines.push('Business Days Over Deadline,' + gap);
}
// Calculation of Days table
lines.push('');
lines.push('Calculation of Days');
lines.push('Stage,Active,WIP 1,Feedback,Revisions,Complete By');
data.stages.forEach(s => {
lines.push([
csvEscape(s.label),
s.active ? 'Y' : 'N',
s.active ? s.wip : 0,
s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0,
s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0,
formatDate(s.completeDate)
].join(','));
});
// Key Dates table
lines.push('');
lines.push('Key Dates');
lines.push('Stage,Active,Stage Kick-Off,WIP 1 To Approval,Receive Feedback By,Rounds,Stage Complete By');
data.stages.forEach(s => {
lines.push([
csvEscape(s.label),
s.active ? 'Y' : 'N',
s.active ? formatDate(s.kickOffDate) : 'n.a.',
s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.',
s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.',
s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.',
s.active ? formatDate(s.completeDate) : 'n.a.'
].join(','));
});
const blob = new Blob([lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (data.briefName || 'SLA_Report').replace(/\s+/g, '_') + '_SLA.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ---- Download Gantt as PNG ----
function downloadGanttPNG() {
const el = document.getElementById('ganttChart');
if (!el || !lastCalculationData) return;
const isDark = document.documentElement.classList.contains('dark');
const btn = el.closest('#ganttView').querySelector('button');
if (btn) btn.style.visibility = 'hidden'; // hide button from screenshot
html2canvas(el, {
backgroundColor: isDark ? '#1f2937' : '#ffffff',
scale: 2,
useCORS: true,
logging: false,
}).then(canvas => {
if (btn) btn.style.visibility = '';
const a = document.createElement('a');
a.download = (lastCalculationData.briefName || 'SLA_Gantt').replace(/\s+/g, '_') + '_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 csvEscape(str) {
if (!str) return '';
str = String(str);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
// ---- Copy for Email (Rich HTML) ----
function copyForEmail() {
if (!lastCalculationData) return;
const briefType = document.getElementById('briefType').selectedOptions[0]?.text || '';
trackEvent('copy_email', { briefType });
const data = lastCalculationData;
// Inline styles for Outlook compatibility
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 tdActive = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;color:#16a34a;font-weight:600;';
const tdInactive = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;color:#9ca3af;';
// Verdict
let verdictText, verdictColor;
if (data.canMeet === null) {
verdictText = 'N/A — No Go-Live date set';
verdictColor = '#6b7280';
} else if (data.canMeet) {
verdictText = 'YES — Can meet the deadline';
verdictColor = '#16a34a';
} else {
const gap = businessDaysBetween(data.goLive, data.suggestedGoLive);
verdictText = `NO — Cannot meet deadline (${gap} business days over)`;
verdictColor = '#dc2626';
}
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 Summary — ${data.briefName || 'Untitled'}</h2>`;
html += `<p style="margin:3px 0;"><strong>Brief Type:</strong> ${briefType}</p>`;
html += `<p style="margin:3px 0;"><strong>Kick-Off Date:</strong> ${formatDateLong(data.kickOff)}</p>`;
html += `<p style="margin:3px 0;"><strong>Required Go-Live:</strong> ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}</p>`;
html += `<p style="margin:3px 0;"><strong>Planned End Date (SLA):</strong> ${formatDateLong(data.projectEndDate)}</p>`;
html += `<p style="margin:3px 0;"><strong>Suggested Go-Live (SLA):</strong> ${formatDateLong(data.suggestedGoLive)}</p>`;
html += `<p style="margin:3px 0 16px 0;"><strong>Verdict:</strong> <span style="color:${verdictColor};font-weight:700;">${verdictText}</span></p>`;
// Key Dates table
html += `<h3 style="margin:0 0 6px 0;font-size:15px;">Key Dates</h3>`;
html += `<table style="${tbl}">`;
html += `<tr><th style="${thL}">Stage</th><th style="${th}">Active</th><th style="${th}">Stage Kick-Off</th><th style="${th}">WIP 1 To Approval</th><th style="${th}">Receive Feedback By</th><th style="${th}">Rounds</th><th style="${th}">Stage Complete By</th></tr>`;
data.stages.forEach(s => {
const aStyle = s.active ? tdActive : tdInactive;
const aText = s.active ? 'Y' : 'N';
const kickOff = s.active ? formatDate(s.kickOffDate) : 'n.a.';
const wip = s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.';
const feedback = s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.';
const rounds = s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.';
const complete = s.active ? formatDate(s.completeDate) : 'n.a.';
html += `<tr><td style="${tdL}">${s.label}</td><td style="${aStyle}">${aText}</td><td style="${td}">${kickOff}</td><td style="${td}">${wip}</td><td style="${td}">${feedback}</td><td style="${td}">${rounds}</td><td style="${td}">${complete}</td></tr>`;
});
html += `</table>`;
// Calculation of Days table
html += `<h3 style="margin:16px 0 6px 0;font-size:15px;">Calculation of Days</h3>`;
html += `<table style="${tbl}">`;
html += `<tr><th style="${thL}">Stage</th><th style="${th}">Active</th><th style="${th}">WIP 1</th><th style="${th}">Feedback</th><th style="${th}">Revisions</th><th style="${th}">Complete By</th></tr>`;
data.stages.forEach(s => {
const aStyle = s.active ? tdActive : tdInactive;
const aText = s.active ? 'Y' : 'N';
const wip = s.active ? s.wip : 0;
const feedback = s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0;
const revisions = s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0;
const complete = formatDate(s.completeDate);
html += `<tr><td style="${tdL}">${s.label}</td><td style="${aStyle}">${aText}</td><td style="${td}">${wip}</td><td style="${td}">${feedback}</td><td style="${td}">${revisions}</td><td style="${td}">${complete}</td></tr>`;
});
html += `</table></div>`;
// Plain text fallback
let plain = `SLA Summary — ${data.briefName || 'Untitled'}\n`;
plain += `Brief Type: ${briefType}\n`;
plain += `Kick-Off: ${formatDateLong(data.kickOff)}\n`;
plain += `Go-Live: ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}\n`;
plain += `Planned End: ${formatDateLong(data.projectEndDate)}\n`;
plain += `Verdict: ${verdictText}\n`;
const showCopied = () => {
const btn = document.getElementById('btnCopyEmail');
const originalHTML = btn.innerHTML;
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);
};
// Use ClipboardItem API for rich HTML copy
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();
}
}
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);
}
// ---- iCal Export ----
function exportICal() {
if (!lastCalculationData) return;
const data = lastCalculationData;
const formatICalDate = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return y + m + d;
};
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const uid = () => 'sla-' + Math.random().toString(36).substr(2, 9) + '@ecf';
let events = [];
// Project kick-off event
events.push({
summary: `[Kick-Off] ${data.briefName}`,
date: formatICalDate(data.kickOff),
description: `Project kick-off for ${data.briefName}\\nBrief Type: ${document.getElementById('briefType').value.replace(/_/g, ' ')}`
});
// Active stage completion milestones
data.stages.forEach(s => {
if (s.active && s.completeDate) {
events.push({
summary: `[${s.label} Complete] ${data.briefName}`,
date: formatICalDate(s.completeDate),
description: `Stage "${s.label}" estimated completion for ${data.briefName}`
});
}
});
// Go-live deadline (if set)
if (data.goLive) {
events.push({
summary: `[Go-Live Deadline] ${data.briefName}`,
date: formatICalDate(data.goLive),
description: `Required go-live date for ${data.briefName}\\nVerdict: ${data.canMeet ? 'CAN meet deadline' : 'CANNOT meet deadline'}`
});
}
// Project planned end date
events.push({
summary: `[SLA End Date] ${data.briefName}`,
date: formatICalDate(data.projectEndDate),
description: `Planned end date based on SLA calculation for ${data.briefName}`
});
// Build .ics content
let ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//eCom Content Factory//SLA Calculator//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH'
];
events.forEach(evt => {
ics.push(
'BEGIN:VEVENT',
`DTSTART;VALUE=DATE:${evt.date}`,
`DTEND;VALUE=DATE:${evt.date}`,
`DTSTAMP:${stamp}`,
`UID:${uid()}`,
`SUMMARY:${evt.summary}`,
`DESCRIPTION:${evt.description}`,
'END:VEVENT'
);
});
ics.push('END:VCALENDAR');
const blob = new Blob([ics.join('\r\n')], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (data.briefName || 'SLA_Milestones').replace(/\s+/g, '_') + '.ics';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}