"""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 ' f'{opts}') 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'
{children_html}
' if cls == "Row": return f'
{children_html}
' if cls == "Col": md = getattr(comp, "md", None) colcls = f"col-md-{md}" if md else "col" return f'
{children_html}
' 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'{children_html}
' if cls == "Card": return f'
{children_html}
' if cls == "CardHeader": return f'
{children_html}
' if cls == "CardBody": return f'
{children_html}
' if cls == "Alert": color = getattr(comp, "color", "info") return f'
{children_html}
' if cls == "Popover": target = getattr(comp, "target", "") body_html = render(getattr(comp, "children", None)) return f'' if cls == "PopoverBody": style = style_dict_to_css(getattr(comp, "style", None)) return f'
{children_html}
' if cls == "Accordion": return f'
{children_html}
' 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'') onclick = f' onclick="window.__onClick && window.__onClick(this)"' return f'' 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}' if cid == "growth-indicator-btn": onclick = f' onclick="switchTab(\'growth\')"' return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}{onclick}>{children_html}' return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}>{children_html}' # 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.
{sidebar_html}
{hero_html}
{tab_divs}
""" 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()