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>
762 lines
34 KiB
Python
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 — '
|
|
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()
|