- 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>
742 lines
33 KiB
Python
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 — '
|
|
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()
|