diff --git a/index.html b/index.html
index 566b541..c6687d5 100644
--- a/index.html
+++ b/index.html
@@ -295,6 +295,43 @@
.wizard-input:focus { border-color: var(--accent); }
.wizard-input::placeholder { color: var(--text-faint); }
+ /* Home screen tabs */
+ .home-tab-btn {
+ background:none; border:none; padding:10px 18px; font-size:14px; font-weight:600;
+ color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent;
+ margin-bottom:-2px; transition:color 0.15s, border-color 0.15s;
+ }
+ .home-tab-btn:hover { color:var(--text); }
+ .home-tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); }
+
+ /* About page */
+ .about-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px; }
+ @media(max-width:700px){ .about-grid { grid-template-columns:1fr; } }
+ .about-card { background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:20px 22px; }
+ .about-pillar-row { display:flex; gap:12px; align-items:flex-start; padding:10px 0; border-bottom:1px solid var(--border-sub); }
+ .about-pillar-row:last-child { border-bottom:none; }
+ .level-row { display:flex; gap:12px; align-items:flex-start; padding:8px 0; border-bottom:1px solid var(--border-sub); }
+ .level-row:last-child { border-bottom:none; }
+
+ /* Sort/group controls */
+ .ctrl-btn {
+ font-size:12px; font-weight:600; padding:5px 12px; border-radius:6px;
+ border:1px solid var(--border); background:var(--bg-card); color:var(--text-sub); cursor:pointer;
+ }
+ .ctrl-btn.active { background:var(--accent); color:#fff; border-color:var(--accent); }
+ .ctrl-select {
+ font-size:12px; padding:5px 10px; border-radius:6px; border:1px solid var(--border);
+ background:var(--bg-card); color:var(--text); cursor:pointer; outline:none;
+ }
+
+ /* Heatmap */
+ .heatmap-table { width:100%; border-collapse:collapse; font-size:13px; }
+ .heatmap-table th { padding:10px 14px; font-size:11px; font-weight:700; color:var(--text-muted);
+ text-transform:uppercase; letter-spacing:0.05em; border-bottom:2px solid var(--border); }
+ .heatmap-table td { padding:10px 14px; border-bottom:1px solid var(--border-sub); }
+ .heatmap-table tr:hover td { background:var(--bg-inset) !important; }
+ .heatmap-cell { border-radius:6px; padding:6px 10px; font-weight:700; font-size:14px; display:inline-block; min-width:44px; text-align:center; }
+
/* Data quality flag indicators */
.entity-flag-badge {
font-size: 10px; font-weight: 700; color: #E65100;
@@ -342,6 +379,7 @@
+
+
Export all
@@ -385,6 +431,11 @@
+
+
+
diff --git a/script.js b/script.js
index 96d7f8d..257fa57 100644
--- a/script.js
+++ b/script.js
@@ -6,6 +6,8 @@ let activeClient = null; // { config, data }
let activeTab = 'entities';
let detailEntity = null;
const selectedForCompare = new Set();
+let sortPillar = 'overall'; // 'overall' | pillar name
+let groupByGroup = false;
// ── Pillar short labels ───────────────────────────────────────────────────────
const PILLAR_SHORT = {
@@ -61,6 +63,101 @@ async function init() {
}
}
+// ── Home screen tabs ──────────────────────────────────────────────────────────
+function showHomeTab(name) {
+ ['clients', 'about'].forEach(t => {
+ document.getElementById(`homeTab-${t}`).style.display = t === name ? '' : 'none';
+ document.getElementById(`home-tab-${t}`).classList.toggle('active', t === name);
+ });
+ if (name === 'about') renderAbout();
+}
+
+function renderAbout() {
+ const el = document.getElementById('homeTab-about');
+ // Build content from all loaded clients' config.about (use first client that has it)
+ const client = allClients.find(c => c.about) || allClients[0];
+ const about = client && client.about;
+ const scoring = client && client.scoring;
+
+ const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' };
+
+ const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, name]) => {
+ const desc = about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : '';
+ return `
+
+
${escHtml(name)}
+
+
Level ${escHtml(lvl)}
+ ${desc ? `
${escHtml(desc)}
` : ''}
+
+
`;
+ }).join('') : '';
+
+ const pillarRows = (client && client.pillars || []).map((pname, i) => {
+ const desc = about && about.pillar_descriptions ? about.pillar_descriptions[pname] : '';
+ const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛'];
+ return `
+
+
${icons[i] || '▪'}
+
+
${escHtml(pname)}
+ ${desc ? `
${escHtml(desc)}
` : ''}
+
+
`;
+ }).join('');
+
+ el.innerHTML = `
+
+
+
+
+
+
+ ${about ? escHtml(about.summary) : 'A content maturity assessment dashboard for evaluating and comparing business units across key marketing capability pillars.'}
+
+ ${about && about.question_count ? `
+
+
${about.question_count}
Questions
+
${(client.pillars || []).length}
Pillars
+
${allClients[0] && allClients[0].entity_count || '—'}
Markets
+
` : ''}
+
+
+
+
+
+
+
+ ${levelRows}
+
+
+
+
+
+ ${[
+ ['Markets tab', 'Browse all markets sorted by overall score. Click any card to drill into pillar scores and individual question rationale.'],
+ ['Heatmap tab', 'See all markets and pillars in one grid — instantly spot which pillars are strong or weak across the board.'],
+ ['Compare tab', 'Select 2 or more markets to put them side by side. Green = highest score in that pillar, red = lowest.'],
+ ['Radar chart', 'Each market detail view shows a spider chart of its 7 pillar scores — useful for understanding the shape of a market\'s maturity.'],
+ ['⚠ Flag badge', 'An amber warning on a card means that market has incomplete data (missing scores, rationale, or gap analysis). Click the card to see detail.'],
+ ['Update Data tab', 'Re-sync data from Box source files or upload a new CSV/XLSX directly.'],
+ ].map(([title, desc]) => `
+
+
${escHtml(title)}
+
${escHtml(desc)}
+
`).join('')}
+
+
+
+
+
+ ${pillarRows}
+
+
+
+
`;
+}
+
// ── Home screen ───────────────────────────────────────────────────────────────
function renderHome() {
document.getElementById('homeBtn').style.display = 'none';
@@ -609,12 +706,13 @@ function enterClientView(config) {
// ── Tab navigation ────────────────────────────────────────────────────────────
function showTab(name) {
- ['entities', 'compare', 'update'].forEach(t => {
+ ['entities', 'compare', 'heatmap', 'update'].forEach(t => {
document.getElementById(`tab-${t}-content`).style.display = t === name ? '' : 'none';
document.getElementById(`tab-${t}`).classList.toggle('active', t === name);
});
activeTab = name;
if (name === 'update') populateImportEntitySelect();
+ if (name === 'heatmap') renderHeatmap();
}
// ── Summary bar ───────────────────────────────────────────────────────────────
@@ -651,6 +749,44 @@ function renderSummaryBar() {
`;
+ renderCardControls();
+}
+
+// ── Card controls (sort + group) ──────────────────────────────────────────────
+function renderCardControls() {
+ const controls = document.getElementById('cardControls');
+ if (!controls) return;
+ const pillars = activeClient.config.pillars;
+
+ const pillarOpts = pillars.map(p =>
+ `
${escHtml(p)} `
+ ).join('');
+
+ controls.innerHTML = `
+
Sort by:
+
+ Overall Score
+ ${pillarOpts}
+
+
+ Group by brand
+
+ `;
+
+ controls.style.display = 'flex';
+}
+
+function onSortChange(val) {
+ sortPillar = val;
+ renderEntityCards();
+}
+
+function onGroupToggle() {
+ groupByGroup = !groupByGroup;
+ renderEntityCards();
+ // Update button appearance
+ const btn = document.getElementById('groupBtn');
+ if (btn) btn.className = 'ctrl-btn' + (groupByGroup ? ' active' : '');
}
// ── Score helpers ─────────────────────────────────────────────────────────────
@@ -673,49 +809,54 @@ function renderEntityCards() {
document.getElementById('entityGrid').style.display = '';
document.getElementById('detailPanel').style.display = 'none';
- const entities = activeClient.data.entities;
- const pillars = activeClient.config.pillars;
+ // Sort entities
+ let entities = [...activeClient.data.entities];
+ if (sortPillar === 'overall') {
+ entities.sort((a, b) => b.overall_score - a.overall_score);
+ } else {
+ entities.sort((a, b) => {
+ const pa = a.pillars.find(x => x.name === sortPillar);
+ const pb = b.pillars.find(x => x.name === sortPillar);
+ if (!pa && !pb) return 0;
+ if (!pa) return 1;
+ if (!pb) return -1;
+ return pb.avg - pa.avg;
+ });
+ }
- document.getElementById('entityGrid').innerHTML = entities.map((entity, i) => {
- const lvlCls = scoreClass(entity.overall_level);
- const bgCls = scoreBgClass(entity.overall_level);
- const flags = getEntityFlags(entity);
- const flagTip = flags.map(f => f.label).join(' · ');
+ const grid = document.getElementById('entityGrid');
- const pillarBars = pillars.map(pname => {
- const p = entity.pillars.find(x => x.name === pname);
- if (!p) return '';
- const pct = pillarBarPct(p.avg).toFixed(1);
- const short = pillarShort(pname);
+ if (!groupByGroup) {
+ // Normal flat grid
+ grid.style.display = '';
+ grid.className = 'cards-grid';
+ grid.innerHTML = entities.map((entity, i) => buildEntityCard(entity, i)).join('');
+ } else {
+ // Group by entity.group
+ const groups = [];
+ const seen = {};
+ entities.forEach(e => {
+ if (!seen[e.group]) { seen[e.group] = []; groups.push(e.group); }
+ seen[e.group].push(e);
+ });
+
+ grid.className = '';
+ grid.style.display = 'flex';
+ grid.style.flexDirection = 'column';
+ grid.style.gap = '20px';
+
+ grid.innerHTML = groups.map(gname => {
+ const groupEntities = seen[gname];
+ const cards = groupEntities.map((entity, i) => buildEntityCard(entity, i)).join('');
return `
-
-
${escHtml(short)}
-
-
${p.avg.toFixed(1)}
+
`;
}).join('');
-
- return `
-
-
- ${escHtml(entity.group)}
- #${i + 1}
-
-
-
${escHtml(entity.label)}
-
- ${entity.overall_score.toFixed(2)}
- ${escHtml(entity.overall_label)}
-
-
-
${pillarBars}
-
- ${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions
- ${flags.length ? `⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''} ` : ''}
- View detail →
-
-
`;
- }).join('');
+ }
document.querySelectorAll('.entity-card').forEach(card => {
card.addEventListener('click', () => {
@@ -725,6 +866,129 @@ function renderEntityCards() {
});
}
+function buildEntityCard(entity, i) {
+ const pillars = activeClient.config.pillars;
+ const lvlCls = scoreClass(entity.overall_level);
+ const bgCls = scoreBgClass(entity.overall_level);
+ const flags = getEntityFlags(entity);
+ const flagTip = flags.map(f => f.label).join(' · ');
+
+ const pillarBars = pillars.map(pname => {
+ const p = entity.pillars.find(x => x.name === pname);
+ if (!p) return '';
+ const pct = pillarBarPct(p.avg).toFixed(1);
+ const short = pillarShort(pname);
+ return `
+
+
${escHtml(short)}
+
+
${p.avg.toFixed(1)}
+
`;
+ }).join('');
+
+ return `
+
+
+ ${escHtml(entity.group)}
+ #${i + 1}
+
+
+
${escHtml(entity.label)}
+
+ ${entity.overall_score.toFixed(2)}
+ ${escHtml(entity.overall_label)}
+
+
+
${pillarBars}
+
+ ${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions
+ ${flags.length ? `⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''} ` : ''}
+ View detail →
+
+
`;
+}
+
+// ── Radar chart (pure SVG) ────────────────────────────────────────────────────
+function radarSvg(entity, config) {
+ const pillars = config.pillars;
+ const n = pillars.length;
+ const cx = 110, cy = 110, r = 80;
+ const { min, max } = config.scoring;
+ const range = max - min;
+
+ // Get scores for each pillar
+ const scores = pillars.map(pname => {
+ const p = entity.pillars.find(x => x.name === pname);
+ return p ? p.avg : min;
+ });
+
+ // Polygon point helper
+ function pt(idx, val) {
+ const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
+ const ratio = Math.max(0, Math.min(1, (val - min) / range));
+ const rr = ratio * r;
+ return { x: cx + rr * Math.cos(angle), y: cy + rr * Math.sin(angle) };
+ }
+
+ function ptEdge(idx) {
+ const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
+ }
+
+ // Grid rings (4 levels)
+ const levels = Object.keys(config.scoring.labels).map(Number).sort((a, b) => a - b);
+ const ringValues = levels.map(l => l);
+
+ const gridRings = ringValues.map(val => {
+ const pts = pillars.map((_, i) => {
+ const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
+ const ratio = Math.max(0, Math.min(1, (val - min) / range));
+ const rr = ratio * r;
+ return `${(cx + rr * Math.cos(angle)).toFixed(2)},${(cy + rr * Math.sin(angle)).toFixed(2)}`;
+ }).join(' ');
+ return `
`;
+ }).join('');
+
+ // Axis lines
+ const axisLines = pillars.map((_, i) => {
+ const e = ptEdge(i);
+ return `
`;
+ }).join('');
+
+ // Score polygon
+ const scorePoints = scores.map((s, i) => {
+ const p = pt(i, s);
+ return `${p.x.toFixed(2)},${p.y.toFixed(2)}`;
+ }).join(' ');
+
+ // Score dots
+ const scoreDots = scores.map((s, i) => {
+ const p = pt(i, s);
+ return `
`;
+ }).join('');
+
+ // Labels
+ const labels = pillars.map((pname, i) => {
+ const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
+ const labelR = r + 22;
+ const lx = cx + labelR * Math.cos(angle);
+ const ly = cy + labelR * Math.sin(angle);
+ let anchor = 'middle';
+ if (lx < cx - 8) anchor = 'end';
+ else if (lx > cx + 8) anchor = 'start';
+ const short = pillarShort(pname);
+ return `
${escHtml(short)} `;
+ }).join('');
+
+ return `
+ ${gridRings}
+ ${axisLines}
+
+ ${scoreDots}
+ ${labels}
+ `;
+}
+
// ── Entity detail ─────────────────────────────────────────────────────────────
function openEntityDetail(entity) {
detailEntity = entity;
@@ -816,6 +1080,7 @@ function openEntityDetail(entity) {
${escHtml(entity.group)}
${escHtml(entity.label)}
+ ${radarSvg(entity, activeClient.config)}
${entity.overall_score.toFixed(2)}
${escHtml(entity.overall_label)}
@@ -1119,6 +1384,95 @@ function renderCompareTable() {
`;
}
+// ── Heatmap tab ───────────────────────────────────────────────────────────────
+function renderHeatmap() {
+ const panel = document.getElementById('heatmapPanel');
+ if (!panel || !activeClient) return;
+
+ const entities = activeClient.data.entities;
+ const pillars = activeClient.config.pillars;
+ const scoring = activeClient.config.scoring;
+
+ // Helper: map numeric score to color level (1-4 buckets)
+ function heatLevel(score) {
+ return scoreToLevel(Math.round(score), scoring);
+ }
+
+ // Header row
+ const headerCells = entities.map(e =>
+ `
${escHtml(e.short)} `
+ ).join('');
+
+ // Pillar rows
+ const pillarRows = pillars.map((pname, ri) => {
+ const cells = entities.map(e => {
+ const p = e.pillars.find(x => x.name === pname);
+ if (!p) return `
— `;
+ const lvl = heatLevel(p.avg);
+ const bgCls = scoreBgClass(lvl);
+ return `
${p.avg.toFixed(2)} `;
+ }).join('');
+
+ // Row average
+ const vals = entities.map(e => { const p = e.pillars.find(x => x.name === pname); return p ? p.avg : null; }).filter(v => v !== null);
+ const rowAvg = vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length) : null;
+ const ravgLvl = rowAvg !== null ? heatLevel(rowAvg) : 1;
+ const ravgBg = scoreBgClass(ravgLvl);
+ const rowAvgCell = rowAvg !== null
+ ? `
${rowAvg.toFixed(2)} `
+ : `
— `;
+
+ const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);';
+ return `
+ ${escHtml(pname)}
+ ${cells}
+ ${rowAvgCell}
+ `;
+ }).join('');
+
+ // Overall row
+ const overallCells = entities.map(e => {
+ const lvl = heatLevel(e.overall_score);
+ const bgCls = scoreBgClass(lvl);
+ return `
${e.overall_score.toFixed(2)} `;
+ }).join('');
+
+ const allScores = entities.map(e => e.overall_score);
+ const grandAvg = allScores.length ? (allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0;
+ const grandLvl = heatLevel(grandAvg);
+ const grandBg = scoreBgClass(grandLvl);
+ const grandAvgCell = `
${grandAvg.toFixed(2)} `;
+
+ panel.innerHTML = `
+
+
+
+
+
+ Pillar
+ ${headerCells}
+ Avg
+
+
+
+ ${pillarRows}
+
+ Overall
+ ${overallCells}
+ ${grandAvgCell}
+
+
+
+
+
+ ${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => {
+ const bgCls = scoreBgClass(parseInt(n));
+ return `${n} ${escHtml(scoring.labels[n])} `;
+ }).join('')}
+
+ `;
+}
+
// ── Deliverable downloads (pre-existing client PDFs / XLSXs) ─────────────────
async function downloadDeliverable(entityId, type) {
diff --git a/server/index.js b/server/index.js
index f79439f..a8c00c7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -55,14 +55,17 @@ app.get('/api/clients', (_req, res) => {
const cfg = readJson(path.join(CLIENT_DIR, id, 'config.json'));
const dat = readJson(path.join(CLIENT_DIR, id, 'data.json'));
return {
- id: cfg.id,
- name: cfg.name,
- description: cfg.description,
+ id: cfg.id,
+ name: cfg.name,
+ description: cfg.description,
accent_color: cfg.accent_color,
entity_label: cfg.entity_label,
logo: cfg.logo || null,
entity_count: dat.entities ? dat.entities.length : 0,
- generated: dat.generated,
+ generated: dat.generated,
+ pillars: cfg.pillars || [],
+ scoring: cfg.scoring || {},
+ about: cfg.about || null,
};
});
res.json(clients);