CityIntel Logo
CityIntel

Reports

Library, draft generation, and monthly intelligence publishing. Use the library for finished PDFs and the builder to create first-pass draft reports from CityIntel event data.

Filters

Report Library

Finished and draft outputs. Monthly titles are generated from report metadata, so next month only requires a new file entry, not a hard-coded title rewrite.
Shared baseline reports curated by CityIntel. Use these as the starting point for org-specific tailoring.
Title Period Type Status Risk Focus Actions

Draft Builder

Auto-generated from alerts-data.js. Edit logic and export can be layered on later.
PDF export is being finalised later. Use “Open Report Preview” for the readable branded report view for now.

Regional intelligence

Open a country/region-specific draft or jump to Risk Detail for a separate dashboard view.

Next actions

  • Review the highlighted events and adjust wording.
  • Open a country / region draft where concentration looks highest.
  • Upload final PDF or DOCX once edited.
  • Keep the library entry metadata aligned with the publication month.

Build: Monthly Preview

Auto-build a next-month outlook from upcoming events in alerts-data.js, with high-focus events, regional breakdown, and operational recommendations.

Build: Monthly Review

Generate a month-end review framework using a selected month window, grouped by risk and geography, ready for analyst commentary and takeaways.

Build: Weekly Preview

Forward-looking weekly snapshot of high-priority events, watch items, and operational advance notice for the week ahead.

Build: Weekly Review

End-of-week retrospective using the same Mon–Sun window as the Weekly Preview, but using past-tense language — what occurred, what was confirmed, and what diverged from the outlook.

Upload & Distribute

Finished-file support stays hybrid: upload final PDFs manually, but use the builder for first-pass report drafting and month/title consistency.

`; } function normalizeAlerts(){ const src = Array.isArray(window.alertsData) ? window.alertsData : []; return src.map((a, idx) => { const rawWhen = a.time || a.when || a.date || a.start || a.datetime || a.eventDate || ''; const when = rawWhen ? new Date(rawWhen) : new Date(Date.now()); const riskRaw = String(a.risk || '').toLowerCase(); const risk = riskRaw.includes('high') ? 'High' : riskRaw.includes('med') ? 'Medium' : riskRaw.includes('low') ? 'Low' : 'Medium'; return { id: idx + 1, title: a.title || 'Untitled', city: a.city || '', country: a.country || '', continent: a.continent || '', source: a.source || '', summary: a.summary || '', when, monthKey: `${when.getFullYear()}-${String(when.getMonth()+1).padStart(2,'0')}`, risk, riskClass: riskClass(risk) }; }).filter(a => !isNaN(a.when.getTime())); } function summarizeThemes(events){ const buckets = [['Palestine / Gaza', /(palestin|gaza|ceasefire|israel)/i], ['Labour / Employment', /(strike|union|labour|workers|wage|employment)/i], ['Far-right / Counter-mobilisation', /(far-right|fascism|racism|nationalist|anti-racism)/i], ['Political opposition / democracy', /(election|democracy|president|opposition|political)/i], ['Environmental / local campaigning', /(climate|fishing|animal|housing|nuclear)/i]]; return buckets.map(([label, rx]) => ({ label, count: events.filter(e => rx.test(`${e.title} ${e.summary}`)).length })).filter(x => x.count > 0).sort((a,b) => b.count - a.count).slice(0,5); } function groupBy(items, keyFn){ const m = new Map(); items.forEach(item => { const k = keyFn(item); m.set(k, [...(m.get(k) || []), item]); }); return m; } function topEvents(events, limit=6){ return [...events].sort((a,b) => { const score = { High:3, Medium:2, Low:1 }; const diff = (score[b.risk] || 0) - (score[a.risk] || 0); if (diff) return diff; return a.when - b.when; }).slice(0, limit); } function eventCard(e){ return `
${esc(e.title)}
${esc(e.risk)}
${esc(e.city || '—')}${e.country ? ', ' + esc(e.country) : ''} · ${esc(e.when.toLocaleString('en-GB', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' }))}
${esc(e.summary || 'No summary provided.')}
`; } function dayKey(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function summarizeRegionalRisk(events){ const counts = {High:0, Medium:0, Low:0}; events.forEach(e => counts[e.risk] = (counts[e.risk] || 0) + 1); return `${counts.High || 0} high · ${counts.Medium || 0} medium · ${counts.Low || 0} low`; } function buildRiskLink(region){ return `risk-detail.html?region=${encodeURIComponent(region)}`; } function regionNameForEvent(e){ return e.country || e.continent || 'Unknown'; } function normalizeRegionList(list){ return [...new Set((Array.isArray(list) ? list : []).map(v => String(v || '').trim()).filter(Boolean))].sort((a,b) => a.localeCompare(b)); } function scopeSelectionKey(mode='global', selectedRegions=[]){ const picked = normalizeRegionList(selectedRegions); if (!picked.length || mode === 'global') return 'all'; return `${mode}:${picked.map(slugify).join('+')}`; } function applyCustomScope(events, mode='global', selectedRegions=[]){ const picked = normalizeRegionList(selectedRegions); if (!picked.length || mode === 'global') return [...events]; const set = new Set(picked); return events.filter(e => mode === 'include' ? set.has(regionNameForEvent(e)) : !set.has(regionNameForEvent(e))); } function scopeLabel(mode='global', selectedRegions=[]){ const picked = normalizeRegionList(selectedRegions); if (!picked.length || mode === 'global') return 'Global scope'; if (mode === 'include') return `Selected regions: ${picked.join(', ')}`; return `Excluding: ${picked.join(', ')}`; } function renderScopeTools(baseEvents, type, focusMonthKey, regionFilter='', scopeMode='global', selectedRegions=[]){ if (!el.draftScopeTools) return; if (regionFilter){ el.draftScopeTools.classList.remove('active'); el.draftScopeTools.innerHTML = ''; return; } const groups = [...groupBy(baseEvents, regionNameForEvent).entries()].sort((a,b) => b[1].length - a[1].length); const selected = normalizeRegionList(selectedRegions); const chips = groups.map(([region, items]) => ``).join(''); const modeText = scopeMode === 'include' ? 'Only the selected regions will be kept in the draft.' : scopeMode === 'exclude' ? 'The selected regions will be removed from the draft.' : 'All regions in the current report window are included.'; el.draftScopeTools.classList.add('active'); el.draftScopeTools.innerHTML = `
Custom scope
${esc(modeText)}
${chips || '
No country or region options are available for this time window.
'}
${esc(scopeLabel(scopeMode, selected))}
`; const modeSel = el.draftScopeTools.querySelector('#draftScopeMode'); if (modeSel) modeSel.addEventListener('change', () => { currentBuilderParams.scopeMode = modeSel.value; renderScopeTools(baseEvents, type, focusMonthKey, regionFilter, currentBuilderParams.scopeMode, currentBuilderParams.selectedRegions); }); el.draftScopeTools.querySelectorAll('[data-scope-region]').forEach(btn => btn.addEventListener('click', () => { const region = btn.dataset.scopeRegion; const set = new Set(normalizeRegionList(currentBuilderParams.selectedRegions)); if (set.has(region)) set.delete(region); else set.add(region); currentBuilderParams.selectedRegions = [...set]; renderScopeTools(baseEvents, type, focusMonthKey, regionFilter, currentBuilderParams.scopeMode, currentBuilderParams.selectedRegions); })); const applyBtn = el.draftScopeTools.querySelector('#applyDraftScopeBtn'); if (applyBtn) applyBtn.addEventListener('click', () => buildDraft(type, focusMonthKey, '', { scopeMode: currentBuilderParams.scopeMode, selectedRegions: currentBuilderParams.selectedRegions })); const clearBtn = el.draftScopeTools.querySelector('#clearDraftScopeBtn'); if (clearBtn) clearBtn.addEventListener('click', () => buildDraft(type, focusMonthKey, '', { scopeMode:'global', selectedRegions:[] })); } function renderRegionPanel(regional, type, focusMonthKey, regionFilter=''){ if (regionFilter){ const items = (regional.find(([name]) => name === regionFilter) || [regionFilter, []])[1]; const cities = topCitiesList(items, 3); const focusClass = riskClass(items.some(i => i.risk === 'High') ? 'High' : items.some(i => i.risk === 'Medium') ? 'Medium' : 'Low'); const focusText = items.some(i => i.risk === 'High') ? 'High focus' : items.some(i => i.risk === 'Medium') ? 'Medium focus' : 'Low focus'; el.regionPanel.innerHTML = `
${esc(regionFilter)}
${items.length} event${items.length===1?'':'s'} · ${esc(summarizeRegionalRisk(items))}
${focusText}
${cities.length ? `
Top cities: ${cities.map(c => `${esc(c.city)}${c.country ? ', ' + esc(c.country) : ''} (${c.count})`).join(' · ')}
` : '
No city concentration available for this region.
'}
Open Risk Detail
`; const back = el.regionPanel.querySelector('[data-back-full]'); if (back) back.addEventListener('click', () => buildDraft(type, focusMonthKey, '', { scopeMode: currentBuilderParams.scopeMode || 'global', selectedRegions: currentBuilderParams.selectedRegions || [] })); return; } el.regionPanel.innerHTML = regional.length ? regional.map(([region, items]) => `
${esc(region)}
${items.length} event${items.length===1?'':'s'} · ${esc(summarizeRegionalRisk(items))}
${items.some(i => i.risk === 'High') ? 'High focus' : items.some(i => i.risk === 'Medium') ? 'Medium focus' : 'Low focus'}
Open Risk Detail
`).join('') : '
No region intelligence available for this selection.
'; el.regionPanel.querySelectorAll('[data-open-region]').forEach(btn => btn.addEventListener('click', () => buildDraft(type, focusMonthKey, btn.dataset.openRegion))); } function strongestCluster(events){ const grouped = [...groupBy(events, e => dayKey(e.when)).entries()].sort((a,b) => b[1].length - a[1].length); return grouped[0] || null; } function topLocation(events){ const grouped = [...groupBy(events.filter(e => e.city), e => `${e.city}|${e.country || ''}`).entries()].sort((a,b) => b[1].length - a[1].length); if (!grouped.length) return null; const [key, vals] = grouped[0]; const [city, country] = key.split('|'); return { city, country, count: vals.length }; } function topCitiesList(events, limit=3){ return [...groupBy(events.filter(e => e.city), e => `${e.city}|${e.country || ''}`).entries()].sort((a,b) => b[1].length - a[1].length).slice(0, limit).map(([key, vals]) => { const [city, country] = key.split('|'); return { city, country, count: vals.length }; }); } function summaryParagraphs(events, type, focusLabel, regional, themes, regionFilter=''){ if (!events.length){ return `No events currently fall inside ${esc(regionFilter || focusLabel)}. This can still be used as a shell for an analyst-led report, but the underlying alert dataset is sparse for this window.`; } const riskCounts = {High:0, Medium:0, Low:0}; events.forEach(e => riskCounts[e.risk] = (riskCounts[e.risk] || 0) + 1); const highPct = ((riskCounts.High || 0) / events.length * 100).toFixed(1), mainRegion = regional[0] ? `${regional[0][0]} (${regional[0][1].length})` : 'no dominant region', mainTheme = themes[0] ? `${themes[0].label} (${themes[0].count})` : 'no dominant theme', mainLoc = topLocation(events), cluster = strongestCluster(events); const isWeeklyReview = type === 'weekly_review'; const isWeekly = type === 'weekly' || isWeeklyReview; const windowLabel = isWeekly ? focusLabel : focusLabel; // Weekly Review uses past tense — events occurred, not upcoming if (isWeeklyReview){ let p1 = `This weekly review covers ${events.length} protest-related or disruption-relevant events that occurred during ${esc(windowLabel)}${regionFilter ? `, filtered to ${esc(regionFilter)}` : ''}. ${riskCounts.High || 0} items were assessed as high risk (${highPct}% of the weekly dataset), with the greatest concentration recorded in ${esc(mainRegion)}.`; let p2 = `The dominant narrative cluster was ${esc(mainTheme)}, indicating that mobilisation during this period was driven by a recurring issue set rather than isolated single-event activity.`; let p3 = mainLoc ? `The highest city-level concentration was recorded in ${esc(mainLoc.city)}${mainLoc.country ? ', ' + esc(mainLoc.country) : ''}, where ${mainLoc.count} in-scope items were logged during the reporting window.` : `City-level concentration was limited, indicating a geographically dispersed pattern across the reporting period.`; if (cluster && cluster[1].length > 1){ const when = new Date(cluster[0]).toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'numeric' }); p3 += ` The densest single-day cluster occurred on ${esc(when)} with ${cluster[1].length} items recorded.`; } return `${p1}

${p2}

${p3}`; } // Standard weekly (future-facing) and monthly types const weeklyLabel = type === 'weekly' ? 'the current weekly window' : focusLabel; let p1 = `This draft captures ${events.length} protest-related or disruption-relevant events in ${esc(weeklyLabel)}${regionFilter ? `, filtered to ${esc(regionFilter)}` : ''}. ${riskCounts.High || 0} items are currently assessed as high risk (${highPct}% of the selected dataset), with the strongest concentration in ${esc(mainRegion)}.`; let p2 = `The dominant narrative cluster is ${esc(mainTheme)}, suggesting that mobilisation energy is being driven by a recurring issue set rather than isolated single-event activism.`; let p3 = mainLoc ? `The busiest city-level concentration sits in ${esc(mainLoc.city)}${mainLoc.country ? ', ' + esc(mainLoc.country) : ''}, where ${mainLoc.count} in-scope items appear in the current window.` : `City-level concentration is limited in the current selection, indicating a more geographically dispersed pattern.`; if (cluster && cluster[1].length > 1){ const when = new Date(cluster[0]).toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'numeric' }); p3 += ` The densest single-day cluster falls on ${esc(when)} with ${cluster[1].length} items, which may indicate front-loaded disruption pressure.`; } return `${p1}

${p2}

${p3}`; } function eventSignature(e){ return [String(e.title || '').trim().toLowerCase(), String(e.city || '').trim().toLowerCase(), String(e.country || '').trim().toLowerCase(), dayKey(e.when)].join('|'); } function buildCompareMeta(events, themes, regional, riskCounts, dominantRisk, focusLabel, type, monthKey, regionFilter='', scopeMode='global', selectedRegions=[]){ return { buildType:type, monthKey:monthKey || '', focusLabel:focusLabel || '', regionFilter:regionFilter || '', scopeMode:scopeMode || 'global', selectedRegions: normalizeRegionList(selectedRegions), selectionKey: scopeSelectionKey(scopeMode, selectedRegions), eventsCount:events.length, highRiskCount:riskCounts.High || 0, countriesCount:new Set(events.map(e => e.country || e.continent || 'Unknown')).size, dominantRisk, themeLabels:themes.map(t => t.label), topRegions:regional.slice(0,3).map(([region, items]) => ({ region, count:items.length })), topCities:topCitiesList(events, 3), eventSignatures:events.slice(0,120).map(eventSignature) }; } function classifyRiskTrajectory(highDelta, eventDelta){ if (highDelta >= 5) return { cls:'up', text:'Escalating risk environment' }; if (highDelta >= 2) return { cls:'up', text:'Rising pressure' }; if (highDelta <= -5) return { cls:'down', text:'De-escalating environment' }; if (highDelta <= -2) return { cls:'down', text:'Cooling risk trajectory' }; if (eventDelta >= 15) return { cls:'up', text:'Elevated mobilisation pressure' }; if (eventDelta <= -15) return { cls:'down', text:'Reduced mobilisation pressure' }; return { cls:'flat', text:'Stable risk posture' }; } function classifyTempo(eventDelta, prevEvents){ const base = Math.max(Number(prevEvents) || 0, 1); const pct = eventDelta / base; if (eventDelta >= 20 || pct >= 0.35) return 'Acceleration in mobilisation activity'; if (eventDelta >= 8 || pct >= 0.15) return 'Elevated operational tempo'; if (eventDelta <= -20 || pct <= -0.35) return 'Reduced mobilisation tempo'; if (eventDelta <= -8 || pct <= -0.15) return 'Easing operational tempo'; return 'Activity levels consistent with the prior cycle'; } function classifyGeographicSignal(countryDelta){ if (countryDelta >= 2) return 'Widening geographic footprint'; if (countryDelta <= -2) return 'Geographic contraction'; return 'Geographic footprint stable'; } function scopeReports(scope){ return scope === 'org' ? getOrgReports() : getMasterReports(); } function compareStamp(report){ const meta = report.compareMeta || {}; const bt = String(report.buildType || report.type || '').toLowerCase(); // For weekly types, derive stamp from periodLabel (Mon dd/mm/yyyy) or savedAt if (bt === 'weekly' || bt === 'weekly_review') { // Try to parse the start date from periodLabel ("DD/MM/YYYY to DD/MM/YYYY") const pl = String(meta.periodLabel || report.periodLabel || ''); const m = pl.match(/(\d{2})\/(\d{2})\/(\d{4})/); if (m) return new Date(Number(m[3]), Number(m[2])-1, Number(m[1])).getTime(); // Fallback: parse week number from idKey (YYYY-wNN) or savedAt const idKey = String(report.id || report.seedKey || ''); const wm = idKey.match(/(\d{4})-w(\d+)/); if (wm) { // ISO week to approximate date const y = Number(wm[1]), w = Number(wm[2]); const jan4 = new Date(y, 0, 4); const startOfW1 = new Date(jan4.getTime() - (jan4.getDay() || 7 - 1) * 86400000); return startOfW1.getTime() + (w - 1) * 7 * 86400000; } return Date.parse(report.savedAt || 0) || 0; } const key = meta.monthKey || report.monthKey || ''; const parts = String(key).split('-').map(Number); if (parts[0] && parts[1]) return new Date(parts[0], parts[1]-1, 1).getTime(); return Date.parse(report.savedAt || 0) || 0; } function previousComparableReport(state, scope){ const currentSelectionKey = (state.compareMeta && state.compareMeta.selectionKey) || scopeSelectionKey(state.scopeMode || 'global', state.selectedRegions || []); const bt = String(state.buildType || '').toLowerCase(); const reports = scopeReports(scope).filter(r => { if (r.archived) return false; // Must match buildType — weekly and weekly_review compare within their own type if (String(r.buildType || '').toLowerCase() !== bt) return false; if (String(r.regionFilter || '') !== String(state.regionFilter || '')) return false; const rSelKey = (r.compareMeta && r.compareMeta.selectionKey) || scopeSelectionKey(r.scopeMode || 'global', r.selectedRegions || []); return rSelKey === currentSelectionKey; }); if (!reports.length) return null; // Current stamp — for weekly types derive from periodLabel/idKey so we never self-match let currentStamp; if (bt === 'weekly' || bt === 'weekly_review') { // Use the state's own periodLabel if available const pl = String((state.compareMeta && state.compareMeta.periodLabel) || state.periodLabel || ''); const m = pl.match(/(\d{2})\/(\d{2})\/(\d{4})/); if (m) { currentStamp = new Date(Number(m[3]), Number(m[2])-1, Number(m[1])).getTime(); } else { currentStamp = Date.now() + 1; // fallback — anything saved is older } } else { currentStamp = compareStamp({ buildType: state.buildType, monthKey: state.monthKey, compareMeta: state.compareMeta }); } // Only consider reports strictly older than current — eliminates self-match const prior = reports .filter(r => compareStamp(r) < currentStamp) .sort((a, b) => compareStamp(b) - compareStamp(a)); if (prior.length) return prior[0]; // No strictly-prior report — return null rather than self-matching return null; } function regionalBreakdown(events){ return [...groupBy(events, e => e.country || e.continent || 'Unknown').entries()] .sort((a,b) => b[1].length - a[1].length) .slice(0,6); } function themeSummary(events){ const themeFor = (e) => { const s = `${e.title || ''} ${e.summary || ''} ${e.description || ''}`.toLowerCase(); if (/labou?r|union|wage|strike|worker/.test(s)) return 'Labour / Employment'; if (/gaza|palestin|israel|ceasefire|solidarity/.test(s)) return 'Palestine / Gaza'; if (/climate|environment|emission|oil|gas|pipeline|energy/.test(s)) return 'Environmental / local campaigning'; if (/student|university|school|campus|education/.test(s)) return 'Student / education'; if (/housing|rent|tenant|evict|cost of living|inflation|price/.test(s)) return 'Cost of living / housing'; if (/election|government|corruption|democracy|policy|minister|parliament/.test(s)) return 'Governance / political'; if (/immigration|migrant|border|asylum|refugee/.test(s)) return 'Migration / borders'; if (/racism|far[- ]?right|anti[- ]?racism|equality|rights/.test(s)) return 'Rights / anti-discrimination'; return 'Civic mobilisation'; }; const counts = new Map(); (Array.isArray(events) ? events : []).forEach(e => { const label = themeFor(e || {}); counts.set(label, (counts.get(label) || 0) + 1); }); return [...counts.entries()] .sort((a,b) => b[1] - a[1]) .slice(0,5) .map(([label, count]) => ({ label, count })); } function previousDatasetWindowMeta(state){ const alerts = normalizeAlerts(); const buildType = String(state.buildType || '').toLowerCase(); const regionFilter = String(state.regionFilter || ''); const scopeMode = (state.scopeMode || (state.compareMeta && state.compareMeta.scopeMode) || 'global'); const selectedRegions = normalizeRegionList(state.selectedRegions || ((state.compareMeta && state.compareMeta.selectedRegions) || [])); let events = []; let label = ''; let title = ''; let monthKey = ''; if (buildType === 'weekly' || buildType === 'weekly_review'){ // For Weekly Preview: compare against the previous completed Mon-Sun week // For Weekly Review: compare against the week before that (two weeks back) const today = new Date(); today.setHours(0,0,0,0); const day = today.getDay(); const monOffset = day === 0 ? -6 : 1 - day; const thisMonday = new Date(today); thisMonday.setDate(today.getDate() + monOffset); // Weekly Preview current window starts at thisMonday // So previous window = thisMonday-7 to thisMonday-1 // For Weekly Review (which already covers thisMonday-7 to thisMonday-1), previous = thisMonday-14 to thisMonday-8 const weeksBack = buildType === 'weekly_review' ? 14 : 7; const prevStart = new Date(thisMonday); prevStart.setDate(thisMonday.getDate() - weeksBack); const prevEnd = new Date(thisMonday); prevEnd.setDate(thisMonday.getDate() - (weeksBack - 7)); events = alerts.filter(a => a.when >= prevStart && a.when < prevEnd); const prevEndLabel = new Date(prevEnd.getTime() - 1); label = `${prevStart.toLocaleDateString('en-GB')} to ${prevEndLabel.toLocaleDateString('en-GB')}`; const prevWeekNo = isoWeekNumber(prevStart); title = buildType === 'weekly_review' ? `Weekly Review — Week ${prevWeekNo}` : `Weekly Preview — Week ${prevWeekNo}`; // Store periodLabel in compareMeta so compareStamp can use it monthKey = `${prevStart.getFullYear()}-${String(prevStart.getMonth()+1).padStart(2,'0')}`; } else { const key = String(state.monthKey || '').split('-').map(Number); if (!(key[0] && key[1])) return null; const prevDate = new Date(key[0], key[1]-2, 1); monthKey = `${prevDate.getFullYear()}-${String(prevDate.getMonth()+1).padStart(2,'0')}`; events = alerts.filter(a => a.monthKey === monthKey); label = monthNameFromKey(monthKey); title = `${buildType === 'preview' ? 'Protest Preview' : 'Protest Review'}: ${label}`; } if (regionFilter){ events = events.filter(a => regionNameForEvent(a) === regionFilter); title += ` — ${regionFilter}`; } else if (scopeMode !== 'global' && selectedRegions.length){ events = applyCustomScope(events, scopeMode, selectedRegions); } const regional = regionalBreakdown(events); const themes = themeSummary(events); const riskCounts = {High:0, Medium:0, Low:0}; events.forEach(e => riskCounts[e.risk] = (riskCounts[e.risk] || 0) + 1); const dominantRisk = riskCounts.High ? 'High' : riskCounts.Medium ? 'Medium' : 'Low'; const focusLabel = (buildType === 'weekly' || buildType === 'weekly_review') ? label : label; const compareMeta = buildCompareMeta(events, themes, regional, riskCounts, dominantRisk, focusLabel, buildType, monthKey || state.monthKey || '', regionFilter, scopeMode, selectedRegions); // Store periodLabel inside compareMeta so compareStamp can parse it compareMeta.periodLabel = label; return { id: '', title, type: buildType === 'preview' ? 'Monthly Preview' : buildType === 'review' ? 'Monthly Review' : buildType === 'weekly_review' ? 'Weekly Review' : 'Weekly Preview', periodLabel: label, compareMeta, risk: dominantRisk, metrics:{ events:events.length, high:riskCounts.High || 0, countries:new Set(events.map(e => e.country || e.continent || 'Unknown')).size }, dataSource:'dataset' }; } function deltaDirection(v){ return v > 0 ? 'up' : (v < 0 ? 'down' : 'flat'); } function deltaLabel(v, noun){ return !Number.isFinite(v) || v === 0 ? `No change vs prior ${noun}` : `${v > 0 ? '+' : ''}${v} vs prior ${noun}`; } function buildDeltaHtml(state, scope){ let prev = previousComparableReport(state, scope); let sourceLabel = scope === 'org' ? orgName() : 'CityIntel Master'; if (!prev){ prev = previousDatasetWindowMeta(state); sourceLabel = prev ? 'Dataset fallback' : sourceLabel; } if (!prev) return { html:`
No prior comparable ${sourceLabel} report found yet, and no usable prior dataset window was found in alerts-data.js for this scope.
`, previousId:'', previousTitle:'', compareScope:scope }; const cur = state.compareMeta || {}; const pm = prev.compareMeta || {}; const prevEvents = Number(pm.eventsCount ?? (prev.metrics && prev.metrics.events) ?? 0); const prevHigh = Number(pm.highRiskCount ?? (prev.metrics && prev.metrics.high) ?? 0); const prevCountries = Number(pm.countriesCount ?? (prev.metrics && prev.metrics.countries) ?? 0); const eventDelta = (cur.eventsCount || 0) - prevEvents; const highDelta = (cur.highRiskCount || 0) - prevHigh; const countryDelta = (cur.countriesCount || 0) - prevCountries; const curThemeKeys = new Set((cur.themeLabels || []).map(s => String(s).toLowerCase())); const prevThemeKeys = new Set((pm.themeLabels || []).map(s => String(s).toLowerCase())); const newThemes = (cur.themeLabels || []).filter(s => !prevThemeKeys.has(String(s).toLowerCase())); const droppedThemes = (pm.themeLabels || []).filter(s => !curThemeKeys.has(String(s).toLowerCase())); const prevRegions = new Set((pm.topRegions || []).map(x => x.region)); const newRegions = (cur.topRegions || []).map(x => x.region).filter(r => !prevRegions.has(r)); const prevSigs = new Set(pm.eventSignatures || []); const curSigs = new Set(cur.eventSignatures || []); const newEventCount = [...curSigs].filter(s => !prevSigs.has(s)).length; const droppedEventCount = [...prevSigs].filter(s => !curSigs.has(s)).length; const trajectory = classifyRiskTrajectory(highDelta, eventDelta); const tempoSignal = classifyTempo(eventDelta, prevEvents); const geographySignal = classifyGeographicSignal(countryDelta); const curPrimaryTheme = (cur.themeLabels || [])[0] || ''; const prevPrimaryTheme = (pm.themeLabels || [])[0] || ''; const curTopCity = ((cur.topCities || [])[0] || null); const prevTopCity = ((pm.topCities || [])[0] || null); const hotspotEmerging = curTopCity && (!prevTopCity || `${curTopCity.city}|${curTopCity.country||''}` !== `${prevTopCity.city}|${prevTopCity.country||''}`); const threatOutlook = highDelta > 0 ? `High-risk exposure has increased by ${highDelta}, indicating a more volatile operating picture for client movement, site access, and event-adjacent activity. This suggests analysts should assume a higher probability of disruptive flashpoints or more resource-intensive monitoring requirements during the current window.` : highDelta < 0 ? `High-risk exposure has reduced by ${Math.abs(highDelta)}, suggesting some cooling in the threat mix. While this indicates a less acute operating picture than the comparison period, the remaining high-risk events still warrant targeted monitoring where client footprints overlap with the current event set.` : 'High-risk exposure is unchanged against the comparison period, indicating that the overall risk posture remains broadly consistent. Operational planning should therefore assume a similar level of disruption sensitivity unless local conditions materially diverge.'; const mobilisationPattern = newEventCount > 0 ? `${newEventCount} newly surfaced event${newEventCount===1?'':'s'} compared with ${esc(titleFor(prev))}. ${tempoSignal}. This implies the current cycle is being shaped by fresh mobilisation drivers rather than simply recycling the previous event mix, which may affect staffing cadence, route planning, or local stakeholder awareness.` : `No newly surfaced events compared with ${esc(titleFor(prev))}. ${tempoSignal}. The mobilisation picture is therefore being driven more by persistence and carry-over activity than by a materially new wave of incidents, which is useful when assessing continuity of protest networks and repeat pressure points.`; const geographicSpread = `${geographySignal}${newRegions.length ? `, with new prominence in ${newRegions.slice(0,3).map(esc).join(', ')}` : ''}. ${droppedEventCount > 0 ? `${droppedEventCount} prior event${droppedEventCount===1?' has':'s have'} dropped out of the current draft window.` : 'No prior events have dropped out of the current draft window.'} This means activity is ${countryDelta > 0 ? 'reaching into a broader set of operating environments' : countryDelta < 0 ? 'becoming more concentrated in fewer operating environments' : 'holding a similar territorial pattern'}, which matters for clients deciding whether disruption risk is widening or clustering.`; let narrativeShift = ''; if (curPrimaryTheme && prevPrimaryTheme && curPrimaryTheme !== prevPrimaryTheme) narrativeShift = `Narrative emphasis has shifted from ${esc(prevPrimaryTheme)} to ${esc(curPrimaryTheme)}. This indicates that the issue set likely drawing attendance, activist attention, or media framing has changed, so client messaging and situational awareness should be aligned to the current narrative rather than the previous cycle.`; else if (newThemes.length) narrativeShift = `Emerging themes: ${newThemes.slice(0,3).map(esc).join(', ')}. These do not yet displace the wider theme mix entirely, but they are becoming visible enough to merit analyst attention because they may alter participant profile, symbolism, or likely points of friction.`; else if (droppedThemes.length) narrativeShift = `Themes cooling or dropping out: ${droppedThemes.slice(0,3).map(esc).join(', ')}. This suggests some issue strands are losing prominence, which may reduce the salience of previously expected mobilisation triggers in the current window.`; else narrativeShift = 'Theme mix is broadly consistent with the last comparable cycle, suggesting campaign framing and issue drivers remain stable. That continuity normally supports more confident forward planning because organisers are operating within familiar narrative lines.'; const hotspotText = hotspotEmerging && curTopCity ? `New focal city emerging: ${esc(curTopCity.city)}${curTopCity.country ? `, ${esc(curTopCity.country)}` : ''}. This location should be treated as newly important for monitoring because concentration is shifting towards it relative to the previous cycle, even if the wider national picture remains mixed.` : 'No new focal city has displaced the prior leading hotspot. The main city-level pressure point therefore remains persistent rather than newly emerging, which is useful for continuity planning and repeat-location preparedness.'; const insightSections = [ { label:'Threat outlook', text: threatOutlook }, { label:'Mobilisation pattern', text: mobilisationPattern }, { label:'Geographic spread', text: geographicSpread }, { label:'Narrative shift', text: narrativeShift }, { label:'Hotspot watch', text: hotspotText } ]; const html = `
${trajectory.text}
Compared with ${esc(titleFor(prev))} · ${esc(periodFor(prev))} · ${sourceLabel}
Events in scope
${cur.eventsCount || 0}
${deltaLabel(eventDelta, 'cycle')}
High risk
${cur.highRiskCount || 0}
${deltaLabel(highDelta, 'cycle')}
Countries / regions
${cur.countriesCount || 0}
${deltaLabel(countryDelta, 'cycle')}
${insightSections.map(sec => `
${sec.label}: ${sec.text}
`).join('')}
`; return { html, previousId: prev.id || '', previousTitle: titleFor(prev), compareScope: scope }; } function buildDraft(type, forcedMonthKey='', regionFilter='', scopeConfig=null){ const alerts = normalizeAlerts(), now = new Date(); let focusMonthKey = normalizeMonthKeyValue(forcedMonthKey); if (!focusMonthKey){ if (type === 'preview') focusMonthKey = getPreviewMonthKey(now); else if (type === 'review') focusMonthKey = getReviewMonthKey(now); else focusMonthKey = getWeeklyWindow(now).monthKey; } let baseEvents = [], events = [], title = '', subtitle = '', badges = [], focusLabel = monthNameFromKey(focusMonthKey); if (type === 'preview'){ baseEvents = alerts.filter(a => a.monthKey === focusMonthKey && a.when >= new Date()); title = `Draft Builder — Protest Preview: ${monthNameFromKey(focusMonthKey)}`; subtitle = 'Upcoming events auto-grouped from alerts-data.js. This date-driven preview always targets the next calendar month and rolls forward automatically on the 1st.'; badges = [{text:'Monthly Preview'},{text:monthNameFromKey(focusMonthKey)},{text:`${baseEvents.length} events`}]; } else if (type === 'review'){ baseEvents = alerts.filter(a => a.monthKey === focusMonthKey); title = `Draft Builder — Protest Review: ${monthNameFromKey(focusMonthKey)}`; subtitle = `Month-end review framework. This date-driven review flips to the current month from day ${REPORT_SCHEDULE.reviewCutoffDay}; before that it keeps the previous month visible.`; badges = [{text:'Monthly Review'},{text:monthNameFromKey(focusMonthKey)},{text:`${baseEvents.length} events`}]; } else { const isReview = type === 'weekly_review'; // Weekly Review = completed previous Mon-Sun window (same as getWeeklyWindow) // Weekly Preview = current in-progress week (this Monday to now) — shows what's happening this week const weekly = getWeeklyWindow(now); let start, end, previewPeriodLabel, previewMonthKey; if (isReview) { start = weekly.start; end = weekly.end; previewPeriodLabel = weekly.periodLabel; previewMonthKey = weekly.monthKey; } else { // Current week: from this Monday through to end of Sunday (or now if mid-week) const today = new Date(now); today.setHours(0,0,0,0); const day = today.getDay(); const mondayOffset = day === 0 ? -6 : 1 - day; start = new Date(today); start.setDate(today.getDate() + mondayOffset); end = new Date(start); end.setDate(start.getDate() + 7); // full week window const endLabel = new Date(end.getTime() - 1); previewPeriodLabel = `${start.toLocaleDateString('en-GB')} to ${endLabel.toLocaleDateString('en-GB')}`; previewMonthKey = `${start.getFullYear()}-${String(start.getMonth()+1).padStart(2,'0')}`; } baseEvents = alerts.filter(a => a.when >= start && a.when < end); const weekNo = isReview ? weekly.weekNo : isoWeekNumber(start); title = isReview ? `Draft Builder — Weekly Review` : `Draft Builder — Weekly Preview`; subtitle = isReview ? `End-of-week retrospective for ${weekly.periodLabel}. Covers the same Mon–Sun window as the Weekly Preview using past-tense language — what occurred, what was confirmed, what diverged.` : `Forward-looking weekly preview for ${previewPeriodLabel}. Shows protest-relevant events for the current week as they appear in the dataset.`; badges = [{text: isReview ? 'Weekly Review' : 'Weekly Preview'},{text: isReview ? weekly.periodLabel : previewPeriodLabel},{text:`${baseEvents.length} events`}]; focusLabel = isReview ? weekly.periodLabel : previewPeriodLabel; focusMonthKey = isReview ? weekly.monthKey : previewMonthKey; } const scopeState = scopeConfig ? { scopeMode: scopeConfig.scopeMode || 'global', selectedRegions: normalizeRegionList(scopeConfig.selectedRegions || []) } : ((currentBuilderParams.buildType === type && currentBuilderParams.monthKey === focusMonthKey && !regionFilter && !currentBuilderParams.regionFilter) ? { scopeMode: currentBuilderParams.scopeMode || 'global', selectedRegions: normalizeRegionList(currentBuilderParams.selectedRegions || []) } : { scopeMode:'global', selectedRegions:[] }); let scopeMode = scopeState.scopeMode; let selectedRegions = scopeState.selectedRegions; currentBuilderParams = { buildType:type, monthKey:focusMonthKey, regionFilter:regionFilter || '', scopeMode, selectedRegions:[...selectedRegions] }; events = [...baseEvents]; if (regionFilter){ events = events.filter(a => regionNameForEvent(a) === regionFilter); title += ` — ${regionFilter}`; subtitle = `Regional draft mode for ${regionFilter}. This view narrows the selected ${(type === 'weekly' || type === 'weekly_review') ? 'weekly window' : 'report window'} to a single country / region.`; badges.push({text:regionFilter}); currentBuilderParams.scopeMode = 'global'; currentBuilderParams.selectedRegions = []; scopeMode = 'global'; selectedRegions = []; } else if (scopeMode !== 'global' && selectedRegions.length){ events = applyCustomScope(events, scopeMode, selectedRegions); const shortLabel = selectedRegions.length <= 2 ? selectedRegions.join(' + ') : `${selectedRegions.length} regions`; title += scopeMode === 'include' ? ` — ${shortLabel}` : ` — Excluding ${shortLabel}`; subtitle = scopeMode === 'include' ? `Custom scoped draft for the selected regions: ${selectedRegions.join(', ')}.` : `Custom scoped draft excluding ${selectedRegions.join(', ')} from the selected report window.`; badges.push({ text: scopeMode === 'include' ? 'Selected regions' : 'Excluding regions' }); badges.push({ text: shortLabel }); } const themes = summarizeThemes(events), regional = [...groupBy(events, e => e.country || e.continent || 'Unknown').entries()].sort((a,b) => b[1].length - a[1].length).slice(0,6), riskCounts = {High:0, Medium:0, Low:0}; events.forEach(e => riskCounts[e.risk] = (riskCounts[e.risk] || 0) + 1); const highlight = topEvents(events, (type === 'weekly' || type === 'weekly_review') ? 5 : 6); const recommendationText = type === 'preview' ? 'Prioritise travel disruption hotspots, client location proximity, and high-attendance protest days. Maintain a shortlist of events likely to create secondary congestion or policing posture changes.' : type === 'review' ? 'Capture where actual turnout, route changes, policing posture, or spillover diverged from expectations, then refine future preview assumptions from those learnings.' : type === 'weekly_review' ? 'Identify where this week\'s activity diverged from the prior Weekly Preview outlook. Note any escalations, cancellations, or geographic shifts that should inform next week\'s operational planning.' : 'Keep distribution short, practical, and focused on what clients need to know this week rather than broad context.'; const countryCount = new Set(events.map(e => e.country || e.continent).filter(Boolean)).size, dominantRisk = riskCounts.High ? 'High' : riskCounts.Medium ? 'Medium' : 'Low'; const summaryHtml = summaryParagraphs(events, type, focusLabel, regional, themes, regionFilter); const compareMeta = buildCompareMeta(events, themes, regional, riskCounts, dominantRisk, focusLabel, type, focusMonthKey, regionFilter, scopeMode, selectedRegions); const delta = buildDeltaHtml({ buildType:type, monthKey:focusMonthKey, periodLabel:focusLabel, regionFilter:regionFilter || '', compareMeta }, activeScope); el.draftShell.classList.add('active'); el.draftTitle.textContent = title; el.draftSubtitle.textContent = subtitle; el.regionPanelIntro.textContent = regionFilter ? `You are viewing a focused draft for ${regionFilter}. Use Open Risk Detail for a separate dashboard view or return to the broader report by rebuilding from the card buttons below.` : (scopeMode !== 'global' && selectedRegions.length ? `Custom scope is active. ${scopeMode === 'include' ? 'Only the selected regions are included in this draft.' : 'Selected regions have been excluded from this draft.'}` : 'Use this panel to open a country / region-specific draft or jump to Risk Detail for a separate dashboard view.'); el.draftBadges.innerHTML = badges.map(b => `${esc(b.text)}`).join(''); renderScopeTools(baseEvents, type, focusMonthKey, regionFilter, scopeMode, selectedRegions); renderRegionPanel(regional, type, focusMonthKey, regionFilter); el.draftBody.innerHTML = `
Events in scope
${events.length}
High risk
${riskCounts.High || 0}
Countries / regions touched
${countryCount}

Executive Summary (draft)

${summaryHtml}

Change Detection

${delta.html}

High Focus Events

${highlight.length ? highlight.map(eventCard).join('') : '
No events available for this time window.
'}

Risk Themes

${themes.length ? `` : '
No recurring themes detected from the current event set.
'}

Operational Recommendations (draft)

`; const exportTitle = title.replace(/^Draft Builder —\s*/,''); const themesHtml = themes.length ? `` : '
No recurring themes detected from the current event set.
'; const recommendationsHtml = ``; const regionalHtml = regional.length ? regional.map(([region, items]) => `
${esc(region)}
${items.length} event${items.length===1?'':'s'} · ${esc(summarizeRegionalRisk(items))}
${items.some(i => i.risk === 'High') ? 'High focus' : items.some(i => i.risk === 'Medium') ? 'Medium focus' : 'Low focus'}
`).join('') : '
No regional intelligence available for this selection.
'; const namedRegionsLine = regional.length ? regional.map(([region, items]) => `${region} (${items.length})`).join(', ') : 'No named countries or regions available for this selection.'; const thematicWatch = themes.length ? themes.map(t => `${t.label} (${t.count})`).join(' · ') : 'No recurring themes detected from the current event set.'; currentDraftState = { buildType: type, monthKey: focusMonthKey, regionFilter: regionFilter || '', scopeMode, selectedRegions, scopeSelectionKey: scopeSelectionKey(scopeMode, selectedRegions), title, exportTitle, typeLabel: type === 'preview' ? 'Monthly Preview' : type === 'review' ? 'Monthly Review' : type === 'weekly_review' ? 'Weekly Review' : 'Weekly Preview', periodLabel: (type === 'weekly' || type === 'weekly_review') ? focusLabel : shortPeriodFromKey(focusMonthKey), subtitle, risk: dominantRisk, riskClass: riskClass(dominantRisk), notes: regionFilter ? `Regional working copy for ${regionFilter}. Saved from the CityIntel baseline draft.` : (scopeMode !== 'global' && selectedRegions.length ? `Custom scope draft (${scopeMode === 'include' ? 'selected regions' : 'excluded regions'}: ${selectedRegions.join(', ')}). Saved from the CityIntel baseline draft.` : `Saved from builder for ${type === 'preview' ? 'upcoming outlook' : type === 'review' ? 'monthly review' : type === 'weekly_review' ? 'weekly retrospective review' : 'weekly operational preview'}.`), keywords: [type, focusMonthKey, regionFilter, scopeMode, ...selectedRegions].filter(Boolean), metrics:{events:events.length, high:riskCounts.High || 0, countries:countryCount}, compareMeta, compareScope: delta.compareScope, comparePreviousId: delta.previousId, comparePreviousTitle: delta.previousTitle, summaryHtml, deltaHtml: delta.html, eventsHtml: highlight.length ? highlight.map(eventCard).join('') : '
No events available for this time window.
', themesHtml, regionalHtml, recommendationsHtml, namedRegionsLine, thematicWatch, exportFilename: `CityIntel_${slugify(exportTitle)}.html`, savedHtml: `${esc(title)}

${esc(title)}

${esc(subtitle)}

${el.draftBody.innerHTML}` }; currentDraftState.brandedHtml = buildBrandedReportHtml(currentDraftState); el.saveOrgDraftBtn.disabled = false; el.publishOrgBtn.disabled = false; el.saveMasterBtn.style.display = isAdmin() ? '' : 'none'; el.draftShell.scrollIntoView({ behavior:'smooth', block:'start' }); } function draftIdFor(scope, d){ const scopeKey = scope === 'master' ? 'master' : `org-${orgKey()}`; return [scopeKey, d.buildType, d.monthKey, d.regionFilter || 'all', d.scopeSelectionKey || scopeSelectionKey(d.scopeMode || 'global', d.selectedRegions || [])].join('__').replace(/[^\w.-]+/g,'_'); } function upsertReport(scope, status){ if (!currentDraftState){ alert('Open a draft first, then save it to the library.'); return; } if (scope === 'master' && !isAdmin()){ alert('Only CityIntel admin should save to the master library.'); return; } const draftId = draftIdFor(scope, currentDraftState); // When saving to master, find the matching seed slot so the merge can replace it // (seed IDs differ from draftIdFor output — match on buildType + monthKey) let seedKey = undefined; if (scope === 'master') { const matchedSeed = MASTER_REPORTS_SEED.find(r => r.buildType === currentDraftState.buildType && normalizeMonthKeyValue(r.monthKey || '') === normalizeMonthKeyValue(currentDraftState.monthKey || '') ); if (matchedSeed) seedKey = seedKeyFor(matchedSeed); } const row = { id: seedKey || draftId, seedKey: seedKey || draftId, type: currentDraftState.typeLabel, monthKey: currentDraftState.monthKey, periodLabel: currentDraftState.periodLabel, title: currentDraftState.title.replace(/^Draft Builder —\s*/,''), status, risk: currentDraftState.risk, file:'', notes: currentDraftState.notes + (scope === 'master' ? ' Master storage syncs through Worker/D1 in v6.' : ''), keywords: currentDraftState.keywords, buildType: currentDraftState.buildType, regionFilter: currentDraftState.regionFilter, scopeMode: currentDraftState.scopeMode, selectedRegions: currentDraftState.selectedRegions, compareMeta: currentDraftState.compareMeta, compareScope: currentDraftState.compareScope, comparePreviousId: currentDraftState.comparePreviousId, comparePreviousTitle: currentDraftState.comparePreviousTitle, savedHtml: currentDraftState.savedHtml, brandedHtml: currentDraftState.brandedHtml || buildBrandedReportHtml(currentDraftState), exportFilename: currentDraftState.exportFilename || `CityIntel_${slugify(currentDraftState.exportTitle || currentDraftState.title)}.html`, savedAt: new Date().toISOString(), scope }; if (scope === 'master'){ const rows = getMasterReports(); const idx = rows.findIndex(r => r.id === row.id || (seedKeyFor(r) && seedKeyFor(r) === seedKeyFor(row))); if (idx >= 0) rows[idx] = row; else rows.push(row); setMasterReports(rows); activeScope = 'master'; } else { const rows = getOrgReports(); const idx = rows.findIndex(r => r.id === row.id); if (idx >= 0) rows[idx] = row; else rows.push(row); setOrgReports(rows); activeScope = 'org'; } hydrateMonths(); renderStats(); renderScopeTabs(); renderTable(); alert(scope === 'master' ? `Saved to CityIntel Master (${status}). Note: Worker/D1 sync is active when the status badge is green.` : `Saved to ${orgName()} library (${status}). This does not overwrite the CityIntel baseline.`); } hydrateMonths(); renderStats(); renderScopeTabs(); renderTable(); bootReportsBackend(); el.applyBtn.addEventListener('click', renderTable); el.clearBtn.addEventListener('click', () => { el.typeFilter.value = 'all'; el.monthFilter.value = 'all'; el.keywordFilter.value = ''; renderTable(); }); el.keywordFilter.addEventListener('keydown', e => { if (e.key === 'Enter') renderTable(); }); document.querySelectorAll('[data-build]').forEach(btn => btn.addEventListener('click', () => buildDraft(btn.dataset.build, '', '', { scopeMode:'global', selectedRegions:[] }))); document.querySelectorAll('[data-template]').forEach(btn => btn.addEventListener('click', () => { const type = btn.dataset.template; const labels = { preview:'Preview templates: Standard / Client Lite', review:'Review templates: Standard / Executive', weekly:'Weekly templates: Ops / Client Brief' }; alert(labels[type] || 'Templates coming soon.'); })); function placeholderUpload(kind){ alert(`${kind} workflow not wired yet. For now, keep uploading finished files manually into the repo/CDN, then add or update the matching report metadata row here.`); } el.uploadPdfBtn.addEventListener('click', () => placeholderUpload('PDF upload')); el.uploadPdfBtn2.addEventListener('click', () => placeholderUpload('PDF upload')); el.uploadDocxBtn.addEventListener('click', () => placeholderUpload('DOCX upload')); el.templatesBtn.addEventListener('click', () => alert('Template manager can be added next. Suggested set: Monthly Preview, Monthly Review, Weekly Preview, Weekly Review, Incident Brief.')); el.newReportBtn.addEventListener('click', () => buildDraft('preview', '', '', { scopeMode:'global', selectedRegions:[] })); el.saveOrgDraftBtn.addEventListener('click', () => upsertReport('org', 'Draft')); el.publishOrgBtn.addEventListener('click', () => upsertReport('org', 'Published')); el.downloadBrandedBtn.addEventListener('click', () => { if (!currentDraftState || !currentDraftState.brandedHtml){ alert('Open a draft first, then export the PDF version.'); return; } printHtmlDoc(currentDraftState.brandedHtml, { autoPrint: true, pdfFilename: pdfFilenameFromState(currentDraftState) }); }); el.openPrintViewBtn.addEventListener('click', () => { if (!currentDraftState || !currentDraftState.brandedHtml){ alert('Open a draft first, then open the print preview.'); return; } printHtmlDoc(currentDraftState.brandedHtml, { autoPrint: false, pdfFilename: pdfFilenameFromState(currentDraftState) }); }); el.saveMasterBtn.addEventListener('click', () => upsertReport('master', 'Published')); if (!isAdmin()) el.saveMasterBtn.style.display = 'none'; })();