Market Brief Advisor: copy for email, timeline dates, syndication default, verdict fix

- Add Copy for Email button with rich HTML table (Outlook-compatible)
- Show start → end dates on each timeline stage row
- Syndication toggled ON by default
- Fix translation feedback default to 5 days (matches config.json)
- Restore form validation (disabled button until all fields filled)
- Fix verdict logic in full calculator to include syndication buffer
  in deadline comparison (suggestedGoLive vs goLive)
- Differentiate verdict message: production-over vs syndication-short
- Update README with client estimator docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alessandro Benedetti Admin 2026-03-16 12:00:46 +00:00
parent b261df2387
commit 80d90da95e
4 changed files with 151 additions and 18 deletions

View file

@ -2,6 +2,10 @@
A web-based SLA (Service Level Agreement) calculator for the eCom Content Factory. Replaces the Excel-based workflow with a 4-step wizard that PMs use to estimate project timelines and determine if deadlines can be met.
There are **two versions**:
- **Full Calculator** (`index.html`) — For PMs: 4-step wizard with full stage-by-stage configuration, Gantt chart, CSV/iCal export
- **Client Estimator** (`client.html`) — For clients: single-page form with 4 inputs, uses SLA defaults automatically
## How It Works
1. **Select Brief Type** - Choose from 9 brief types (Country Pull, Global Push, Local Push, Urgent, etc.). Each brief type displays a scope description and auto-populates which of the 8 project stages apply.
@ -12,9 +16,11 @@ A web-based SLA (Service Level Agreement) calculator for the eCom Content Factor
## Project Structure
```
index.html - UI (Tailwind CSS, Flatpickr date pickers, 4-step wizard)
script.js - Calculation engine, form dynamics, PDF + iCal export
config.json - All business rules (zero hard-coded logic in JS)
index.html - Full PM calculator (4-step wizard, Gantt chart, CSV/iCal export)
script.js - Full calculator engine + form dynamics
client.html - Simplified client-facing estimator (single page)
client-script.js - Client estimator engine (uses config defaults)
config.json - All business rules (shared by both versions)
```
## Editing Business Rules
@ -110,3 +116,12 @@ The calculator has been verified against the original Excel workbook example:
- Opera Upload stage is now fully locked: toggle disabled, badge non-clickable, "Required" label shown in both Step 1 badges and Step 2 stage card
- Uses config-driven `alwaysActive` flag — no hard-coded stage index in JS
- Cache-busting version bumped to `v=2026031302`
### 2026-03-13 (Client Estimator)
- Added simplified client-facing calculator (`client.html` + `client-script.js`)
- Single-page form: project type, asset volume, kick-off date, go-live date
- Uses config.json default values for all stage parameters (complexity, revisions, approval days)
- Shows active stages with day counts, estimated completion date, Y/N verdict
- Includes syndication buffer note when syndication is active
- PDF export, dark mode, non-contractual disclaimer
- Links to full calculator for detailed configuration

View file

@ -6,6 +6,7 @@
// =============================================================================
let CONFIG = null;
let lastResultData = null;
// ---- Bootstrap ----
document.addEventListener('DOMContentLoaded', async () => {
@ -97,9 +98,10 @@ function resetForm() {
const panel = document.getElementById(cb.dataset.feedback);
if (panel) panel.classList.add('hidden');
});
['feedbackDaysStatic', 'feedbackDaysVideo', 'feedbackDaysHTML', 'feedbackDaysTranslation'].forEach(id => {
const feedbackDefaults = { feedbackDaysStatic: 3, feedbackDaysVideo: 3, feedbackDaysHTML: 3, feedbackDaysTranslation: 5 };
Object.entries(feedbackDefaults).forEach(([id, val]) => {
const el = document.getElementById(id);
if (el) el.value = '3';
if (el) el.value = val;
});
const goliveEl = document.getElementById('golive');
if (goliveEl._flatpickr) goliveEl._flatpickr.clear();
@ -321,9 +323,18 @@ function calculateAndRender() {
const today = new Date(); today.setHours(0, 0, 0, 0);
const earliestGoLive = addBusinessDays(today, totalWithBuffer);
// Compute real calendar dates for each active stage (relative to submitBy)
const activeStages = stages.filter(s => s.active);
let runningDate = new Date(submitBy);
activeStages.forEach(s => {
s.startDate = new Date(runningDate);
s.endDate = addBusinessDays(runningDate, s.days);
runningDate = s.endDate;
});
renderResults({
briefType: bt,
stages: stages.filter(s => s.active),
stages: activeStages,
goLive,
submitBy,
estimatedCompletion,
@ -346,6 +357,7 @@ function calcStage(stageIndex, isActive, prevDate, getParams) {
// ---- Render Results ----
function renderResults(data) {
lastResultData = data;
const resultsEl = document.getElementById('results');
resultsEl.classList.remove('hidden');
@ -400,7 +412,10 @@ function renderResults(data) {
<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>
<span class="text-gray-500 dark:text-gray-400 text-xs">${s.days} business day${s.days !== 1 ? 's' : ''}</span>
<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);
});
@ -426,3 +441,96 @@ function renderResults(data) {
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ---- Copy for Email (Rich HTML) ----
function copyForEmail() {
if (!lastResultData) return;
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;';
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}</p>`;
html += `<p style="margin:3px 0;"><strong>Required Go-Live:</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>Estimated Completion:</strong> ${formatDate(data.estimatedCompletion)}</p>`;
html += `<p style="margin:3px 0;"><strong>Total Business Days:</strong> ${data.totalWithBuffer}</p>`;
html += `<p style="margin:3px 0 16px 0;"><strong>Earliest Go-Live (if submitted today):</strong> ${formatDate(data.earliestGoLive)}</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>`;
});
if (data.syndicationActive) {
html += `<tr><td style="${tdL}">Syndication Buffer</td><td style="${td}">${CONFIG.meta.syndicationBufferDays}</td></tr>`;
}
html += `</table>`;
// Disclaimer
html += `<p style="margin:12px 0 0;font-size:12px;color:#92400e;"><strong>Assumes 2-week lead time between syndication and Live Date.</strong></p>`;
html += `<p style="margin:4px 0;font-size:12px;color:#92400e;"><strong>These timings are for reference only. 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}\n`;
plain += `Required Go-Live: ${formatDate(data.goLive)}\n`;
plain += `Submit Brief By: ${formatDate(data.submitBy)}\n`;
plain += `Estimated Completion: ${formatDate(data.estimatedCompletion)}\n`;
plain += `Total Business Days: ${data.totalWithBuffer}\n`;
plain += `Earliest Go-Live: ${formatDate(data.earliestGoLive)}\n\n`;
plain += `Timeline:\n`;
data.stages.forEach(s => { plain += ` ${s.label}: ${s.days} days (${formatDate(s.startDate)}${formatDate(s.endDate)})\n`; });
if (data.syndicationActive) plain += ` Syndication Buffer: ${CONFIG.meta.syndicationBufferDays} days\n`;
plain += `\nAssumes 2-week lead time between syndication and Live Date.\n`;
plain += `These timings are for reference only. 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();
}
}
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);
}

View file

@ -172,7 +172,7 @@
<div id="feedbackTranslation" class="hidden border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700/30 px-3 py-2.5">
<label class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Days needed for feedback</span>
<input type="number" id="feedbackDaysTranslation" min="0" max="30" value="3" class="w-16 text-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1 text-sm focus:ring-2 focus:ring-brand-500 outline-none">
<input type="number" id="feedbackDaysTranslation" min="0" max="30" value="5" class="w-16 text-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1 text-sm focus:ring-2 focus:ring-brand-500 outline-none">
</label>
</div>
</div>
@ -185,7 +185,7 @@
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Syndication</span>
</div>
<div class="relative">
<input type="checkbox" id="needsSyndication" class="sr-only peer">
<input type="checkbox" id="needsSyndication" class="sr-only peer" checked>
<div class="w-10 h-5 bg-gray-300 dark:bg-gray-600 rounded-full peer-checked:bg-brand-500 transition-colors"></div>
<div class="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow peer-checked:translate-x-5 transition-transform"></div>
</div>
@ -262,8 +262,12 @@
</div>
</div>
<!-- Start Over -->
<div class="mt-6 text-center no-print">
<!-- Actions -->
<div class="mt-6 flex flex-wrap justify-center gap-3 no-print">
<button type="button" id="btnCopyEmail" onclick="copyForEmail()" class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium text-sm transition-colors">
<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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
Copy for Email
</button>
<button id="resetBtn" type="button" class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Start Over

View file

@ -41,7 +41,7 @@ document.addEventListener('DOMContentLoaded', async () => {
async function loadConfig() {
try {
const res = await fetch('config.json?v=2026031302');
const res = await fetch('config.json?v=2026031303');
CONFIG = await res.json();
populateBriefTypes();
populateStageDropdowns();
@ -594,8 +594,9 @@ function calculateSLA() {
suggestedGoLive = addBusinessDays(projectEndDate, CONFIG.meta.syndicationBufferDays);
}
// Verdict
const canMeet = goLive ? projectEndDate <= goLive : null;
// 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,
@ -687,8 +688,13 @@ function renderResults(data) {
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.projectEndDate);
msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (${gap} business day${gap !== 1 ? 's' : ''} over deadline)`;
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
@ -883,7 +889,7 @@ function exportCSV() {
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.projectEndDate);
const gap = businessDaysBetween(data.goLive, data.suggestedGoLive);
lines.push('Business Days Over Deadline,' + gap);
}
@ -963,7 +969,7 @@ function copyForEmail() {
verdictText = 'YES — Can meet the deadline';
verdictColor = '#16a34a';
} else {
const gap = businessDaysBetween(data.goLive, data.projectEndDate);
const gap = businessDaysBetween(data.goLive, data.suggestedGoLive);
verdictText = `NO — Cannot meet deadline (${gap} business days over)`;
verdictColor = '#dc2626';
}