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:
parent
b261df2387
commit
80d90da95e
4 changed files with 151 additions and 18 deletions
21
README.md
21
README.md
|
|
@ -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
|
||||
|
|
|
|||
116
market-script.js
116
market-script.js
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
12
market.html
12
market.html
|
|
@ -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
|
||||
|
|
|
|||
20
script.js
20
script.js
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue