Files
LinkedIn-Analytics-Dashboard/build_static_export.py
T
beantownbabeandClaude Sonnet 5 f9f27401db Add Reports tab: monthly campaign progress reports
New "Reports" tab for the 3-month LinkedIn campaign, combining dashboard
data with the Follower Directory rather than a standalone HTML/PDF
workflow. For each campaign month it shows KPI tiles (including a new
Monthly Growth Rate, this month's net new followers as a percentage of
the followers already on the page at the start of that month, resets
every month against its own starting count), the follower growth chart,
every post published that month, an Audience Composition breakdown
sourced live from the Follower Directory (sector/seniority/country, both
this month's cohort and the all-time snapshot), and curated monthly
narrative notes (features shipped, data quality fixes, Directory
updates, outstanding items). Reuses the existing reportlab PDF pipeline,
extended with the same sections, and mirrored in the static exporter
with live-only controls disabled.

New files: monthly_report_notes.py (per-month curated narrative,
July populated), follower_directory_data.py (parses the Directory's
DATA array directly so numbers can't drift from the hand-maintained
source of truth).

Also fixes three pre-existing bugs found while building this: average
engagement rate was displaying 100x too small (a 0-1 fraction shown
as-is instead of scaled to a percentage), the Sector/Seniority/Country
breakdown tables were silently truncated below their own stated totals
(a hardcoded top-5 display cap plus a separate top-10 country cap
inside composition_stats itself), and the static exporter had no
support for dbc.Table, dropping the <table> tag entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 09:56:19 +02:00

762 lines
34 KiB
Python

"""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 = {}
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()
parts.append(f"{css_key}:{v}")
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-<td>,
not on the <tr>, 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'<div id="{cid}" class="js-plotly-chart" style="height:{height};width:100%;"></div>'
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 content area: empty in the live layout (filled by a 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.
if getattr(comp, "id", None) == "reports-content-area":
if A._REPORTS_DEFAULT_MONTH:
return render(A._render_reports_tab(A._REPORTS_DEFAULT_MONTH))
return render(dbc.Alert("No campaign months have been written up yet.", color="info"))
# --- Plotly graphs ---
if isinstance(comp, dcc.Graph):
return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None))
# --- Dropdowns: render as <select>, wiring handled by caller via id convention ---
if isinstance(comp, dcc.Dropdown):
opts = "".join(
f'<option value="{esc(o["value"])}"{" selected" if o["value"] == comp.value else ""}>{esc(o["label"])}</option>'
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 in ("report-timeframe-select", "reports-month-select"):
title = ("Switching months needs the live tool, not available in this snapshot."
if cid == "reports-month-select" else
"PDF generation needs the live tool, not available in this snapshot.")
return (f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" '
f'style="{style};opacity:0.5;cursor:not-allowed" disabled '
f'title="{title}">{opts}</select>')
return f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" style="{style}" onchange="window.__onSelect && window.__onSelect(this)">{opts}</select>'
# --- Stores/Download: no static equivalent needed (Download requires a live backend) ---
if isinstance(comp, (dcc.Store, dcc.Download)):
return ""
# --- Upload: disabled in the snapshot, saving files needs a live Python backend ---
if isinstance(comp, dcc.Upload):
style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
"opacity": "0.5", "cursor": "not-allowed"})
cid = getattr(comp, "id", "") or next_chart_id()
return (f'<div id="{esc(cid)}" style="{style}" '
f'title="Uploading new data needs the live tool (requires a Python backend), not available in this snapshot.">'
f'{render(getattr(comp, "children", None))}</div>')
# --- 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"<th>{esc(c['name'])}</th>" 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"<td{td_style}>{esc(r.get(c['id'], ''))}</td>" for c in cols)
trs.append(f"<tr>{tds}</tr>")
cid = getattr(comp, "id", "") or next_chart_id()
return (f'<div class="table-responsive"><table id="{esc(cid)}" class="table table-sm static-datatable">'
f"<thead><tr>{thead}</tr></thead><tbody>{''.join(trs)}</tbody></table></div>")
# --- 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'<div class="{base} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Row":
return f'<div class="row {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Col":
md = getattr(comp, "md", None)
colcls = f"col-md-{md}" if md else "col"
return f'<div class="{colcls} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
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'<table class="{table_classes} {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</table>'
if cls == "Card":
return f'<div class="card {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "CardHeader":
return f'<div class="card-header" style="{style}">{children_html}</div>'
if cls == "CardBody":
return f'<div class="card-body" style="{style}"{id_attr}>{children_html}</div>'
if cls == "Alert":
color = getattr(comp, "color", "info")
return f'<div class="alert alert-{color} {esc(extra_class)}" style="{style}">{children_html}</div>'
if cls == "Popover":
target = getattr(comp, "target", "")
body_html = render(getattr(comp, "children", None))
return f'<div class="static-popover" data-target="{esc(target)}" style="display:none;">{body_html}</div>'
if cls == "PopoverBody":
style = style_dict_to_css(getattr(comp, "style", None))
return f'<div class="popover-body" style="{style}">{children_html}</div>'
if cls == "Accordion":
return f'<div class="static-accordion {esc(extra_class)}" style="{style}"{id_attr}>{children_html}</div>'
if cls == "AccordionItem":
title = getattr(comp, "title", "") or ""
return (f'<details class="static-accordion-item">'
f'<summary class="static-accordion-title">{esc(title)}</summary>'
f'<div class="static-accordion-body">{children_html}</div>'
f'</details>')
# 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",
}
tag = tag_map.get(cls, "div")
if cls == "Img":
src = inline_asset(getattr(comp, "src", ""))
return f'<img src="{esc(src)}" style="{style}"{class_attr}{id_attr}/>'
if cls == "Hr":
return f'<hr style="{style}"{class_attr}/>'
if cls == "Button":
if cid in ("generate-report-btn", "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'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled '
f'title="PDF generation needs the live tool (requires a Python backend), not available in this snapshot.">'
f'{children_html}</button>')
onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f'<button type="button"{id_attr}{class_attr} style="{style}"{onclick}{title_attr}>{children_html}</button>'
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'<div style="padding:20px 4px;">'
f'<div style="margin-bottom:8px;font-weight:700;color:#1a2533;">'
f'Net {esc(sign)}{net} follower{"s" if abs(net) != 1 else ""} in '
f'{A._MONTH_NAMES[month - 1]} {year}: {esc(detail)}.</div>'
f'<div style="color:#6b7a8d;font-size:0.8rem;">'
f'Category breakdown (location, industry, etc.) isn\'t available for these &mdash; '
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.</div>'
f'</div>'
)
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"<td{td_style}>{esc(r.get(c, ''))}</td>" for c in A.post_table_cols)
row_htmls.append(f"<tr>{tds}</tr>")
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 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 = '<div style="margin-top:10px;display:flex;flex-wrap:wrap;align-items:center;">'
+ '<span style="margin-right:16px;white-space:nowrap;"><span style="width:12px;height:12px;background-color:#52C41A;display:inline-block;margin-right:5px;vertical-align:middle;border-radius:2px;"></span><span style="font-size:0.72rem;color:#6b7a8d;">Gained</span></span>'
+ '<span style="margin-right:16px;white-space:nowrap;"><span style="width:12px;height:12px;background-color:#d43f3a;display:inline-block;margin-right:5px;vertical-align:middle;border-radius:2px;"></span><span style="font-size:0.72rem;color:#6b7a8d;">Lost</span></span>'
+ '</div>';
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];
if (hero) {
document.getElementById('hero-title').textContent = hero[0];
var subEl = document.getElementById('hero-subtitle');
subEl.textContent = hero[1];
var infoHtml = TAB_HERO_INFO[key];
if (infoHtml) subEl.insertAdjacentHTML('beforeend', infoHtml);
}
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 = '<div class="js-plotly-chart" id="fb-breakdown-chart"></div>' + 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 = '<div class="js-plotly-chart" id="fb-breakdown-chart"></div>';
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 = '<div class="js-plotly-chart" id="vb-breakdown-chart"></div>';
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 = '<div class="js-plotly-chart" id="frr-chart"></div>';
}
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)
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()}
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'<div id="tab-{key}" class="app-tab" style="display:{"block" if key == "overview" else "none"};">'
f'{tabs_html[key]}</div>'
for key, _label, _c in A.NAV_ITEMS
)
js = JS_TEMPLATE % {
"charts": json.dumps(CHARTS),
"tab_hero": json.dumps(A.TAB_HERO),
"tab_hero_info": json.dumps(tab_hero_info_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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>TXproducts LinkedIn Analytics, Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<style>
{custom_css}
body {{ background:#f7f9fc; }}
.app-shell {{ display:flex; align-items:stretch; }}
.static-popover {{ position:fixed; z-index:2000; background:white; border:1px solid #ccc; border-radius:6px;
box-shadow:0 4px 16px rgba(0,0,0,0.15); max-width:360px; padding:4px; }}
.static-datatable th, .static-datatable td {{ font-size:11px; padding:3px 6px; }}
select.compact-dropdown {{ height:28px; font-size:0.85rem; }}
.snapshot-banner {{ background:#fff3cd; color:#664d03; padding:8px 16px; font-size:0.8rem; text-align:center; }}
.static-accordion {{ border:1px solid #e0e7ef; border-radius:6px; overflow:hidden; }}
.static-accordion-item {{ border-bottom:1px solid #e0e7ef; }}
.static-accordion-item:last-child {{ border-bottom:none; }}
.static-accordion-title {{ cursor:pointer; padding:10px 12px; font-size:0.85rem; font-weight:600;
color:#1a2533; list-style:none; user-select:none; }}
.static-accordion-title::-webkit-details-marker {{ display:none; }}
.static-accordion-title:before {{ content:'\\25B8 '; color:#6b7a8d; }}
details[open] > .static-accordion-title:before {{ content:'\\25BE '; }}
.static-accordion-body {{ padding:4px 12px 14px; }}
.static-accordion .static-accordion {{ border:none; border-radius:0; margin:0 0 0 8px; }}
</style>
</head>
<body>
<div class="snapshot-banner">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.</div>
<div class="app-shell">
{sidebar_html}
<div style="flex:1;min-width:0;padding:20px 24px;">
{hero_html}
<div id="tab-content-area">
{tab_divs}
</div>
</div>
</div>
<script>
document.querySelectorAll('[id^="nav-"]').forEach(function (btn) {{
btn.addEventListener('click', function () {{ switchTab(btn.id.replace('nav-', '')); }});
}});
{js}
</script>
</body>
</html>
"""
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()