"""Builds a single self-contained HTML file snapshot of the live Dash dashboard, for
sharing with people who don't have the app running (e.g. via NextCloud/email).
Reuses app.py's data loading and figure-builder functions directly. Converts the Dash
component trees (dbc.Card/Row/Col are just Bootstrap-class wrappers) into plain HTML,
so the same Bootstrap CSS renders them identically. Charts render via Plotly.js from
embedded JSON. A handful of interactive elements (calendar month click, breakdown
dropdowns, content month filter) have every possible state precomputed and swapped
client-side with vanilla JS, everything else is a static snapshot of today's data.
"""
import base64
import html as html_escape_mod
import json
import mimetypes
import os
import re
import app as A
from dash import html, dcc, dash_table
import dash_bootstrap_components as dbc
CHARTS = {} # chart div id -> plotly figure json string
CHART_COUNTER = [0]
ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
_ASSET_CACHE = {}
_reports_render_cache = {}
def esc(s):
return html_escape_mod.escape(str(s), quote=True)
def inline_asset(src):
"""Rewrite /assets/foo.png into a base64 data: URI so the exported file is fully
self-contained (no separate image files needed alongside it)."""
if not src or not src.startswith("/assets/"):
return src
if src in _ASSET_CACHE:
return _ASSET_CACHE[src]
path = os.path.join(ASSETS_DIR, os.path.basename(src))
if not os.path.isfile(path):
return src
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
uri = f"data:{mime};base64,{b64}"
_ASSET_CACHE[src] = uri
return uri
def style_dict_to_css(style):
if not style:
return ""
parts = []
for k, v in style.items():
# camelCase -> kebab-case
css_key = re.sub(r"([A-Z])", r"-\1", k).lower()
# A literal " in a value (e.g. grid-template-areas: '"cm rm ap"') would
# terminate the HTML style="..." attribute this gets concatenated into early,
# silently corrupting everything after it — this exact bug broke the Reports
# tab's grid layout once already. Escape rather than trust every caller to
# remember single-quoting CSS strings.
parts.append(f"{css_key}:{str(v).replace(chr(34), '"')}")
return ";".join(parts)
def conditional_row_bg(conditional, row, idx):
"""Replays a DataTable's style_data_conditional row_index/filter_query rules for one
row, later rules override earlier ones on a match, same as Dash's own precedence, so
the best-engagement/best-CTR highlight still shows up here, not just in the live app.
Only supports the simple "{Col} = value" equality form filter_query uses in this
codebase, not the full Dash query mini-language. Caller applies the result per-
,
not on the , Bootstrap 5 paints table backgrounds via a CSS variable applied
directly to each cell, which sits on top of and hides a tr-level background-color."""
bg = None
for rule in conditional:
cond = rule.get("if", {})
matched = False
if "row_index" in cond:
ri = cond["row_index"]
if ri == "odd" and idx % 2 == 1:
matched = True
elif ri == "even" and idx % 2 == 0:
matched = True
elif isinstance(ri, int) and idx == ri:
matched = True
elif "filter_query" in cond:
m = re.match(r"\{([^}]+)\}\s*=\s*(.+)", cond["filter_query"])
if m:
col, val = m.group(1), m.group(2)
cell = row.get(col)
try:
matched = float(cell) == float(val)
except (TypeError, ValueError):
matched = str(cell) == val
if matched and "backgroundColor" in rule:
bg = rule["backgroundColor"]
return bg
def next_chart_id():
CHART_COUNTER[0] += 1
return f"chart-{CHART_COUNTER[0]}"
def register_chart(fig, style=None, comp_id=None):
cid = comp_id or next_chart_id()
CHARTS[cid] = json.loads(fig.to_json())
height = "320px"
if style and "height" in style:
height = style["height"]
return f'
'
def render(comp):
"""Recursively convert a Dash component (or list/str) into an HTML string."""
if comp is None:
return ""
if isinstance(comp, (str, int, float)):
return esc(comp)
if isinstance(comp, (list, tuple)):
return "".join(render(c) for c in comp)
cls = type(comp).__name__
module = type(comp).__module__
# --- Reports tab title/content area: both empty in the live layout (filled by a
# two-output callback), so bake in the one selectable month's rendered output
# directly. Switching months needs the live tool (see the disabled dropdown below),
# same rationale as every other live-only control in this exporter. Cached, since
# this component tree gets visited twice (once per div) and re-calling would
# double-register the follower-growth chart into CHARTS.
if getattr(comp, "id", None) in ("reports-title", "reports-content-area"):
if "result" not in _reports_render_cache:
if A._REPORTS_DEFAULT_MONTH:
# _render_reports_tab returns (title, content, store_value) — the third
# is only for syncing the live reports-selected-month Store, irrelevant
# to a static snapshot.
_reports_render_cache["result"] = A._render_reports_tab(A._REPORTS_DEFAULT_MONTH)[:2]
else:
_reports_render_cache["result"] = (
"", dbc.Alert("No campaign months have been written up yet.", color="info"))
title, content = _reports_render_cache["result"]
return render(title if comp.id == "reports-title" else content)
# --- Plotly graphs ---
if isinstance(comp, dcc.Graph):
return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None))
# --- Dropdowns: render as , wiring handled by caller via id convention ---
if isinstance(comp, dcc.Dropdown):
opts = "".join(
f'{esc(o["label"])} '
for o in comp.options
)
style = style_dict_to_css(getattr(comp, "style", None))
extra_class = getattr(comp, "className", "") or ""
cid = getattr(comp, "id", "")
if cid == "reports-month-select":
return (f'')
return f''
# --- Stores/Download/Location: no static equivalent needed (Download requires a live
# backend; Location is only a pathname-tracking hook for Dash callbacks, and the
# static export drives page-switching through switchTab() instead) ---
if isinstance(comp, (dcc.Store, dcc.Download, dcc.Location)):
return ""
# --- Link: internal routes (e.g. "/roadmap-insights") become switchTab() calls,
# since there's no server-side routing in a single static file; hrefs pointing at a
# real asset (e.g. "/assets/follower_study.html") stay real navigable links. ---
if isinstance(comp, dcc.Link):
href = getattr(comp, "href", "") or ""
style = style_dict_to_css(getattr(comp, "style", None))
label_html = render(getattr(comp, "children", None))
if href.startswith("/assets/"):
return f'{label_html} '
key = href.lstrip("/")
return f'{label_html} '
# --- DataTable: render a plain HTML table from data/columns ---
if isinstance(comp, dash_table.DataTable):
cols = comp.columns
rows = comp.data
conditional = getattr(comp, "style_data_conditional", None) or []
thead = "".join(f"{esc(c['name'])} " for c in cols)
trs = []
for i, r in enumerate(rows):
bg = conditional_row_bg(conditional, r, i)
td_style = f' style="background-color:{bg}"' if bg else ""
tds = "".join(f"{esc(r.get(c['id'], ''))} " for c in cols)
trs.append(f"{tds} ")
cid = getattr(comp, "id", "") or next_chart_id()
return (f''
f"{thead} {''.join(trs)}
")
# --- dbc components: map to their underlying Bootstrap markup ---
if module.startswith("dash_bootstrap_components"):
children_html = render(getattr(comp, "children", None))
extra_class = getattr(comp, "className", "") or ""
style = style_dict_to_css(getattr(comp, "style", None))
cid = getattr(comp, "id", "")
id_attr = f' id="{esc(cid)}"' if cid else ""
if cls == "Container":
base = "container-fluid" if getattr(comp, "fluid", False) else "container"
return f''
if cls == "Row":
return f''
if cls == "Col":
md = getattr(comp, "md", None)
colcls = f"col-md-{md}" if md else "col"
return f''
if cls == "Table":
table_classes = "table"
if getattr(comp, "bordered", False):
table_classes += " table-bordered"
if getattr(comp, "striped", False):
table_classes += " table-striped"
if getattr(comp, "size", None):
table_classes += f" table-{comp.size}"
return f''
if cls == "Card":
return f''
if cls == "CardHeader":
return f''
if cls == "CardBody":
return f'{children_html}
'
if cls == "Alert":
color = getattr(comp, "color", "info")
return f''
if cls == "Popover":
target = getattr(comp, "target", "")
body_html = render(getattr(comp, "children", None))
return f'{body_html}
'
if cls == "PopoverBody":
style = style_dict_to_css(getattr(comp, "style", None))
return f'{children_html}
'
if cls == "Accordion":
return f''
if cls == "AccordionItem":
title = getattr(comp, "title", "") or ""
return (f''
f'{esc(title)} '
f'{children_html}
'
f' ')
# Fallback: just render children
return children_html
# --- Plain html.* components ---
if module.startswith("dash.html") or module.startswith("dash_html_components"):
children_html = render(getattr(comp, "children", None))
extra_class = getattr(comp, "className", "") or ""
style = style_dict_to_css(getattr(comp, "style", None))
cid = getattr(comp, "id", "")
id_attr = f' id="{esc(cid)}"' if cid else ""
title = getattr(comp, "title", None)
title_attr = f' title="{esc(title)}"' if title else ""
class_attr = f' class="{esc(extra_class)}"' if extra_class else ""
tag_map = {
"Div": "div", "Span": "span", "Strong": "strong", "B": "b", "P": "p",
"H3": "h3", "Hr": "hr", "Button": "button",
"Table": "table", "Thead": "thead", "Tbody": "tbody", "Tr": "tr",
"Th": "th", "Td": "td", "Ul": "ul", "Li": "li", "A": "a", "I": "i",
}
tag = tag_map.get(cls, "div")
if cls == "Img":
src = inline_asset(getattr(comp, "src", ""))
return f' '
if cls == "Hr":
return f' '
if cls == "A":
href = getattr(comp, "href", "") or ""
target = getattr(comp, "target", None)
target_attr = f' target="{esc(target)}"' if target else ""
rel_attr = ' rel="noopener noreferrer"' if target == "_blank" else ""
return (f'{children_html} ')
if cls == "Button":
if cid == "reports-download-btn":
disabled_style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
"opacity": "0.5", "cursor": "not-allowed"})
return (f''
f'{children_html} ')
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f'{children_html} '
if cid.startswith("info-"):
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}{onclick}>{children_html}{tag}>'
if cid == "growth-indicator-btn":
onclick = f' onclick="switchTab(\'growth\')"'
return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}{onclick}>{children_html}{tag}>'
return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}>{children_html}{tag}>'
# Unknown component type, best-effort render children
return render(getattr(comp, "children", None))
def fig_json(fig):
return json.loads(fig.to_json())
def precompute_calendar_states():
"""Every month's tile-row + daily graph (+ Overview's 4 metric charts), for both
calendar instances, this is what the month-box click swaps between."""
states = {}
year = int(A.D["fts"]["Date"].dt.year.max())
for prefix in ("overview", "followers"):
states[prefix] = {}
for m in range(1, 13):
entry = {
"postsRow": render(A._month_day_tiles(A.D["fts"], A.D["posts"], year, m)),
"dailyFig": fig_json(A.daily_followers_figure(A.D["fts"], A.D["posts"], year, m)),
}
if prefix == "overview":
cts_f = A._filtered_by_month(A.D["cts"], year, m)
vts_f = A._filtered_by_month(A.D["vts"], year, m)
entry["metrics"] = {
"overview-impressions-graph": fig_json(A.impressions_figure(cts_f)),
"overview-engagement-graph": fig_json(A.engagement_figure(cts_f)),
"overview-ctr-graph": fig_json(A.ctr_figure(cts_f)),
"overview-pageviews-graph": fig_json(A.page_views_figure(vts_f)),
}
states[prefix][str(m)] = entry
default_month = A._default_active_month(A.D["fts"], A.D["posts"], year)
return states, year, default_month
def precompute_fb_states():
"""Followers-By dropdown x Show-all-toggle x calendar-month states — mirrors the live
app's _render_followers_breakdown callback, which re-renders on fb-select, fb-expanded,
AND the selected calendar month (so June shows June's actual demographic snapshot, not
always the latest one)."""
states = {}
months = sorted(A.D["breakdown_history"]) + [None] # None = no month context (fallback/current)
for key, _label in A.FOLLOWER_BREAKDOWNS:
for expanded in (False, True):
for ym in months:
year, month = ym if ym is not None else (None, None)
_lbl, fig, total_n = A._breakdown_figure(key, expanded, year, month)
fig = A._std(fig)
needs_toggle = total_n > 10
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
month_label = f"as of {A._MONTH_NAMES[month - 1]} {year}" if year and month else ""
month_key = f"{year}-{month}" if year and month else "none"
states[f"{key}|{str(expanded).lower()}|{month_key}"] = {
"fig": fig_json(fig),
"btnLabel": btn_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
"monthLabel": month_label,
}
return states
def precompute_fb_delta_states():
"""Followers-By month-over-month delta states — one per breakdown key x delta-eligible
month, mirroring the live app's _breakdown_delta_figure branch of _render_followers_breakdown."""
states = {}
for key, _label in A.FOLLOWER_BREAKDOWNS:
for year, month in A._followers_delta_months():
_lbl, fig, changed_n, overflow = A._breakdown_delta_figure(key, year, month)
fig = A._std(fig)
month_label = f"{A._MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
if overflow > 0:
month_label += f" · showing top {A._DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
states[f"{key}|{year}-{month}"] = {
"fig": fig_json(fig),
"monthLabel": month_label,
}
return states
def precompute_fb_count_only_states():
"""Months with real follower-count activity but no category snapshot to attribute it to
(currently just June 2026) — mirrors the live app's count-only branch of
_render_followers_breakdown, which shows a plain explanation instead of a bar chart."""
states = {}
for year, month in A._followers_count_only_months():
net, sign, detail = A._monthly_count_summary(year, month)
month_label = f"{A._MONTH_NAMES[month - 1]} {year} — no category breakdown available"
note_html = (
f''
f'
'
f'Net {esc(sign)}{net} follower{"s" if abs(net) != 1 else ""} in '
f'{A._MONTH_NAMES[month - 1]} {year}: {esc(detail)}.
'
f'
'
f'Category breakdown (location, industry, etc.) isn\'t available for these — '
f'LinkedIn\'s demographic export wasn\'t captured yet at this point, so there\'s no '
f'earlier snapshot to know which category they belong to.
'
f'
'
)
states[f"{year}-{month}"] = {"noteHtml": note_html, "monthLabel": month_label}
return states
def precompute_vb_states():
"""Visitors-By dropdown x Show-all-toggle x period states — mirrors precompute_fb_states.
'total' means the running total (no month scope); other keys are 'YYYY-M' month cutoffs
against the persistent running total (see visitor_history.py)."""
states = {}
periods = [("total", None, None)] + [
(f"{y}-{m}", y, m) for y, m in A._visitor_category_months()
]
for key, _label in A.VISITOR_BREAKDOWNS:
for expanded in (False, True):
for period_key, year, month in periods:
_lbl, fig, total_n = A._visitor_breakdown_figure(key, expanded, year, month)
fig = A._std(fig)
needs_toggle = total_n > 10
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
month_label = f"as of {A._MONTH_NAMES[month - 1]} {year}" if year and month else ""
states[f"{key}|{str(expanded).lower()}|{period_key}"] = {
"fig": fig_json(fig),
"btnLabel": btn_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
"monthLabel": month_label,
}
return states
def precompute_frr_states():
"""Follow Rate Relative to Reach month-dropdown states."""
states = {}
for opt in A._frr_month_options(A.D["frr"]):
year, month = (int(p) for p in opt["value"].split("-"))
fig = A._std(A.follow_rate_figure(A.D["frr"], year, month))
states[opt["value"]] = {"fig": fig_json(fig)}
return states
def precompute_content_states():
"""Content tab's month-filter dropdown states, 6 charts + the posts table."""
states = {}
for opt in A._content_month_options():
value = opt["value"]
posts_f = A._content_posts_filtered(value)
cts_f = A._content_cts_filtered(value)
records = A._posts_table_records(value)
# Same fixed rule set for every month, matching the live app: its month-filter
# callback only swaps `data`, never `style_data_conditional`, so the highlight
# always marks the all-time best post/CTR, not a per-month recompute.
row_htmls = []
for i, r in enumerate(records):
bg = conditional_row_bg(A.post_highlight_styles, r, i)
td_style = f' style="background-color:{bg}"' if bg else ""
tds = "".join(f"{esc(r.get(c, ''))} " for c in A.post_table_cols)
row_htmls.append(f"{tds} ")
rows_html = "".join(row_htmls)
states[value] = {
"content-impressions-fig": fig_json(A._finalize_fig(
A.impressions_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)),
"content-engagement-fig": fig_json(A._finalize_fig(
A.engagement_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)),
"content-ctr-fig": fig_json(A._finalize_fig(
A.ctr_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)),
"content-scatter-fig": fig_json(A._finalize_fig(
A.impressions_vs_engagement_figure(posts_f), legend=A.CONTENT_SCATTER_LEGEND)),
"content-reactions-fig": fig_json(A._finalize_fig(A.reactions_figure(cts_f))),
"content-clicks-fig": fig_json(A._finalize_fig(A.clicks_figure(cts_f))),
"tableRows": rows_html,
}
return states
JS_TEMPLATE = r"""
const CHARTS = %(charts)s;
const TAB_HERO = %(tab_hero)s;
const TAB_HERO_INFO = %(tab_hero_info)s;
const TAB_HERO_ACTIONS = %(tab_hero_actions)s;
const CAL_STATES = %(cal_states)s;
const CAL_YEAR = %(cal_year)s;
const CAL_DEFAULT_MONTH = %(cal_default_month)s;
const FB_STATES = %(fb_states)s;
const FB_DELTA_STATES = %(fb_delta_states)s;
const FB_COUNT_ONLY_STATES = %(fb_count_only_states)s;
const VB_STATES = %(vb_states)s;
const CONTENT_STATES = %(content_states)s;
const FRR_STATES = %(frr_states)s;
const DELTA_LEGEND_HTML = ''
+ 'Gained '
+ 'Lost '
+ '
';
const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH};
let fbActive = 'company_size', fbExpanded = false, fbPeriod = 'total';
let vbActive = 'seniority', vbExpanded = false, vbPeriod = 'total';
let frrPeriod = %(frr_default_period)s;
function renderChart(divId, figSpec) {
if (!figSpec || !document.getElementById(divId)) return;
Plotly.react(divId, figSpec.data, figSpec.layout, {displayModeBar: false, responsive: true});
}
function renderChartWithin(containerId, figSpec) {
var container = document.getElementById(containerId);
if (!container || !figSpec) return;
var chartDiv = container.querySelector('.js-plotly-chart');
if (!chartDiv) return;
Plotly.react(chartDiv.id, figSpec.data, figSpec.layout, {displayModeBar: false, responsive: true});
}
document.addEventListener('DOMContentLoaded', function () {
Object.keys(CHARTS).forEach(function (id) {
var spec = CHARTS[id];
Plotly.newPlot(id, spec.data, spec.layout, {displayModeBar: false, responsive: true});
});
});
function switchTab(key) {
document.querySelectorAll('.app-tab').forEach(function (el) { el.style.display = 'none'; });
var el = document.getElementById('tab-' + key);
if (el) el.style.display = 'block';
document.querySelectorAll('[id^="nav-"]').forEach(function (btn) {
btn.classList.toggle('active', btn.id === 'nav-' + key);
});
var hero = TAB_HERO[key];
var titleEl = document.getElementById('hero-title');
var subEl = document.getElementById('hero-subtitle');
if (hero) {
// Reports folds its subtitle onto the title line instead of a separate line below
// (see _hero_title_children in app.py) — mirrored here rather than just dumping
// hero[1] onto its own line, which is what every other tab does.
if (key === 'reports') {
titleEl.innerHTML = hero[0] + ' — ' + hero[1] + ' ';
subEl.textContent = '';
} else {
titleEl.textContent = hero[0];
subEl.textContent = hero[1];
var infoHtml = TAB_HERO_INFO[key];
if (infoHtml) subEl.insertAdjacentHTML('beforeend', infoHtml);
}
}
var actionsEl = document.getElementById('hero-actions');
if (actionsEl) actionsEl.innerHTML = TAB_HERO_ACTIONS[key] || '';
window.dispatchEvent(new Event('resize'));
}
function selectCalendarMonth(prefix, month) {
calMonth[prefix] = month;
var state = CAL_STATES[prefix][String(month)];
if (!state) return;
for (var m = 1; m <= 12; m++) {
var btn = document.getElementById(prefix + '-month-btn-' + m);
if (btn) btn.style.border = (m === month) ? '2px solid #1b3a5c' : '1px solid #e8e8e8';
}
var row = document.getElementById(prefix + '-posts-row');
if (row) row.innerHTML = state.postsRow;
renderChart(prefix + '-daily-followers-graph', state.dailyFig);
if (state.metrics) {
Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); });
}
if (prefix === 'followers') renderFollowersBreakdown();
}
function renderFollowersBreakdown() {
if (fbPeriod !== 'total') {
var cs = FB_COUNT_ONLY_STATES[fbPeriod];
if (cs) {
var area = document.getElementById('followers-breakdown-area');
if (area) area.innerHTML = cs.noteHtml;
var clabel = document.getElementById('fb-month-label');
if (clabel) clabel.textContent = cs.monthLabel;
var cbtn = document.getElementById('fb-expand-btn');
if (cbtn) cbtn.style.display = 'none';
return;
}
var ds = FB_DELTA_STATES[fbActive + '|' + fbPeriod];
if (!ds) return;
var area2 = document.getElementById('followers-breakdown-area');
if (area2) area2.innerHTML = '
' + DELTA_LEGEND_HTML;
renderChartWithin('followers-breakdown-area', ds.fig);
var dlabel = document.getElementById('fb-month-label');
if (dlabel) dlabel.textContent = ds.monthLabel;
var dbtn = document.getElementById('fb-expand-btn');
if (dbtn) dbtn.style.display = 'none';
return;
}
var month = calMonth.followers;
var monthKey = month ? (CAL_YEAR + '-' + month) : 'none';
var s = FB_STATES[fbActive + '|' + fbExpanded + '|' + monthKey] || FB_STATES[fbActive + '|' + fbExpanded + '|none'];
if (!s) return;
var totalArea = document.getElementById('followers-breakdown-area');
if (totalArea) totalArea.innerHTML = '
';
renderChartWithin('followers-breakdown-area', s.fig);
var label = document.getElementById('fb-month-label');
if (label) label.textContent = s.monthLabel;
var btn = document.getElementById('fb-expand-btn');
if (btn) {
btn.textContent = s.btnLabel;
btn.style.display = s.btnDisplay;
}
}
function renderVisitorBreakdown() {
var s = VB_STATES[vbActive + '|' + vbExpanded + '|' + vbPeriod];
if (!s) return;
var area = document.getElementById('visitor-breakdown-area');
if (area) area.innerHTML = '
';
renderChartWithin('visitor-breakdown-area', s.fig);
var label = document.getElementById('vb-month-label');
if (label) label.textContent = s.monthLabel;
var btn = document.getElementById('vb-expand-btn');
if (btn) {
btn.textContent = s.btnLabel;
btn.style.display = s.btnDisplay;
}
}
function renderFollowRate() {
var s = FRR_STATES[frrPeriod];
if (!s) return;
var area = document.getElementById('frr-area');
if (area && !area.querySelector('.js-plotly-chart')) {
area.innerHTML = '
';
}
renderChartWithin('frr-area', s.fig);
}
function renderContentState(value) {
var s = CONTENT_STATES[value];
if (!s) return;
['content-impressions-fig', 'content-engagement-fig', 'content-ctr-fig',
'content-scatter-fig', 'content-reactions-fig', 'content-clicks-fig'].forEach(function (id) {
renderChart(id, s[id]);
});
var tbody = document.querySelector('#posts-table tbody');
if (tbody) tbody.innerHTML = s.tableRows;
}
function closeAllPopovers(except) {
document.querySelectorAll('.static-popover').forEach(function (p) {
if (p !== except) p.style.display = 'none';
});
}
window.__onClick = function (el) {
var id = el.id || '';
var m;
if (id.indexOf('info-') === 0) {
var pop = document.querySelector('.static-popover[data-target="' + id + '"]');
if (!pop) return;
var willOpen = pop.style.display === 'none' || !pop.style.display;
closeAllPopovers();
if (willOpen) {
var rect = el.getBoundingClientRect();
pop.style.position = 'fixed';
pop.style.top = (rect.bottom + 6) + 'px';
pop.style.left = Math.min(rect.left, window.innerWidth - 380) + 'px';
pop.style.display = 'block';
}
return;
}
if ((m = id.match(/^(overview|followers)-month-btn-(\d+)$/))) {
selectCalendarMonth(m[1], parseInt(m[2], 10));
return;
}
if (id === 'fb-expand-btn') {
fbExpanded = !fbExpanded;
renderFollowersBreakdown();
return;
}
if (id === 'vb-expand-btn') {
vbExpanded = !vbExpanded;
renderVisitorBreakdown();
return;
}
};
window.__onSelect = function (el) {
if (el.id === 'fb-select') { fbActive = el.value; fbExpanded = false; renderFollowersBreakdown(); return; }
if (el.id === 'fb-period-select') { fbPeriod = el.value; renderFollowersBreakdown(); return; }
if (el.id === 'vb-select') { vbActive = el.value; vbExpanded = false; renderVisitorBreakdown(); return; }
if (el.id === 'vb-period-select') { vbPeriod = el.value; renderVisitorBreakdown(); return; }
if (el.id === 'frr-month-select') { frrPeriod = el.value; renderFollowRate(); return; }
if (el.id === 'content-month-select') { renderContentState(el.value); return; }
};
document.addEventListener('click', function (ev) {
if (ev.target.id && ev.target.id.indexOf('info-') === 0) return;
if (ev.target.closest && ev.target.closest('.static-popover')) return;
closeAllPopovers();
});
"""
def build():
tabs_html = {}
for key, _label, comp in A.NAV_ITEMS:
tabs_html[key] = render(comp)
# The 7 Reports-tab insights pages (Core Message, Roadmap, All Posts, Follower
# Growth, Audience Composition, Follower Study, Monthly Notes) — reached only via
# the cross-page hero nav / "Click here for the insights" links, same as in the live
# app, so they're not in NAV_ITEMS/the sidebar and need rendering separately.
# Monthly Notes isn't one of the 7 Reports sections (reached only via its own
# sidebar link), so it's not in _REPORT_SECTIONS — added explicitly here so its
# tab div still gets rendered into the static file.
# Each insights page is now a builder FUNCTION taking a month value (see
# _INSIGHTS_PAGE_BUILDERS in app.py — the dynamic-month architecture), not
# pre-built content in TAB_CONTENT_MAP, so it's called here the same way the live
# app's routing callback does, baked to the one selectable month (see the Reports
# tab/content-area handling above — same rationale, switching months needs the
# live tool).
extra_tab_keys = sorted({key for _, key, _, _ in A._REPORT_SECTIONS} | {"monthly-notes-insights"})
for key in extra_tab_keys:
builder = A._INSIGHTS_PAGE_BUILDERS[key]
tabs_html[key] = render(builder(A._REPORTS_DEFAULT_MONTH))
sidebar_html = render(A.sidebar)
hero_html = render(A.hero)
# Per-tab extra markup appended after the hero subtitle text (e.g. the Growth tab's
# "what does growth measure" info popover) — switchTab() re-appends this on tab change
# since setting textContent wipes any previously-injected popover markup.
tab_hero_info_html = {key: render(A.info_popover(f"hero-{key}", info))
for key, info in A.TAB_HERO_INFO.items()}
# Per-tab hero-action buttons (Followers' Directory/Study links, and the Reports
# insights pages' cross-page nav) — previously baked in once for "overview" and
# never updated on tab switch at all, a pre-existing gap this fixes at the same time.
tab_hero_actions_html = {
key: render(A._hero_actions(key))
for key in [k for k, _, _ in A.NAV_ITEMS] + extra_tab_keys
}
cal_states, cal_year, cal_default_month = precompute_calendar_states()
fb_states = precompute_fb_states()
fb_delta_states = precompute_fb_delta_states()
fb_count_only_states = precompute_fb_count_only_states()
vb_states = precompute_vb_states()
frr_states = precompute_frr_states()
frr_default_period = A._frr_default_period(A.D["frr"])
content_states = precompute_content_states()
with open(os.path.join(ASSETS_DIR, "custom.css")) as f:
custom_css = f.read()
tab_divs = "\n".join(
f''
f'{tabs_html[key]}
'
for key in [k for k, _, _ in A.NAV_ITEMS] + extra_tab_keys
)
js = JS_TEMPLATE % {
"charts": json.dumps(CHARTS),
"tab_hero": json.dumps(A.TAB_HERO),
"tab_hero_info": json.dumps(tab_hero_info_html),
"tab_hero_actions": json.dumps(tab_hero_actions_html),
"cal_states": json.dumps(cal_states),
"cal_year": json.dumps(cal_year),
"cal_default_month": json.dumps(cal_default_month),
"fb_states": json.dumps(fb_states),
"fb_delta_states": json.dumps(fb_delta_states),
"fb_count_only_states": json.dumps(fb_count_only_states),
"vb_states": json.dumps(vb_states),
"frr_states": json.dumps(frr_states),
"frr_default_period": json.dumps(frr_default_period),
"content_states": json.dumps(content_states),
}
html_doc = f"""
TXproducts LinkedIn Analytics, Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})
Static snapshot as of {A.D['last_updated'].strftime('%B %d, %Y')}, dropdowns, month clicks,
and breakdown selectors work, but this file does not update with new data. Open the live tool for current numbers.
"""
out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "TXproducts_Dashboard_Snapshot.html")
with open(out_path, "w") as f:
f.write(html_doc)
print(f"Wrote {out_path} ({len(html_doc):,} bytes, {len(CHARTS)} charts)")
return out_path
if __name__ == "__main__":
build()