Files
LinkedIn-Analytics-Dashboard/build_static_export.py
T
beantownbabeandClaude Sonnet 5 8a72eeb402 Add Follower Directory/Study hero links, fix country-map centroid gaps, and sync accumulated dashboard work
- Hero banner now shows Follower Directory / Follower Study links on the
  Followers tab only (not the sidebar), opening the standalone reports served
  from assets/.
- Followers by Country: _COUNTRY_CENTROIDS expanded from 14 hardcoded entries
  to a near-complete world table, so a country missing from the old dict no
  longer vanishes from the map silently; added a console warning for any
  country still missing a centroid, and a "N countries represented" caption
  computed from the same rollup the map uses so the two can never disagree.
- Added visitor_history.py: append-only observation log for the Visitors
  export's category breakdowns (Location/Seniority/Company size), since
  LinkedIn only shows a category if it clears a per-export minimum-visits
  threshold and otherwise silently drops it.
- Synced other accumulated app.py/build_static_export.py work not yet
  committed: calendar month drill-down tiles, follower-breakdown expand
  toggle, CTR explainer, manual post title overrides, and matching static-
  export precompute/JS for the calendar and content tabs.
- .gitignore: exclude the newer generated snapshot outputs (follower study/
  directory HTML copies, PDF report, visitor_category_history.json) using
  the same rationale as the existing growth_history.json/snapshot rules —
  regenerable from code + data, not itself code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 08:49:19 +02:00

742 lines
33 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__
# --- 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 == "report-timeframe-select":
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="PDF generation needs the live tool, not available in this snapshot.">{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 == "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 == "generate-report-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_opts = A._frr_month_options(A.D["frr"])
frr_default_period = _frr_opts[0]["value"] if _frr_opts else None
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()