Mckinley's PDFs kept cutting off content mid-page with no way to fix it short of regenerating the whole report — a fixed-layout PDF isn't the right format for something meant to be edited. Adds report.build_report_docx(), a python-docx equivalent of build_report_pdf() sharing the exact same compute_period_stats() data and section order (KPI table, section abstracts, weekly pace, audience composition, follower growth chart, all posts table with the same excluded-post strikethrough treatment, format performance, competitor snapshot, and the full Section Deep Dives for a monthly campaign report), so the two can never silently drift apart on numbers even though the rendering layer is completely different. Word reflows content automatically, so there's no more fixed page grid to fight when editing. The Reports tab's download button now produces .docx instead of PDF. Verified via python-docx re-read (KPI values, the 10/07 repost row's strikethrough, embedded chart images) and strict OOXML schema validation (including fixing a `w:zoom` attribute python-docx's own default template omits). build_report_pdf() is left in place, only report.py's ctx docstring rewritten since the doubly-stale "sidebar's generic PDF control" no longer exists as an actual call site. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
827 lines
39 KiB
Python
827 lines
39 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 = {}
|
|
_reports_render_cache = {}
|
|
|
|
|
|
def esc(s):
|
|
return html_escape_mod.escape(str(s), quote=True)
|
|
|
|
|
|
def inline_asset(src):
|
|
"""Rewrite /assets/foo.png into a base64 data: URI so the exported file is fully
|
|
self-contained (no separate image files needed alongside it)."""
|
|
if not src or not src.startswith("/assets/"):
|
|
return src
|
|
if src in _ASSET_CACHE:
|
|
return _ASSET_CACHE[src]
|
|
path = os.path.join(ASSETS_DIR, os.path.basename(src))
|
|
if not os.path.isfile(path):
|
|
return src
|
|
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
with open(path, "rb") as f:
|
|
b64 = base64.b64encode(f.read()).decode("ascii")
|
|
uri = f"data:{mime};base64,{b64}"
|
|
_ASSET_CACHE[src] = uri
|
|
return uri
|
|
|
|
|
|
def style_dict_to_css(style):
|
|
if not style:
|
|
return ""
|
|
parts = []
|
|
for k, v in style.items():
|
|
# camelCase -> kebab-case
|
|
css_key = re.sub(r"([A-Z])", r"-\1", k).lower()
|
|
# A literal " in a value (e.g. grid-template-areas: '"cm rm ap"') would
|
|
# terminate the HTML style="..." attribute this gets concatenated into early,
|
|
# silently corrupting everything after it — this exact bug broke the Reports
|
|
# tab's grid layout once already. Escape rather than trust every caller to
|
|
# remember single-quoting CSS strings.
|
|
parts.append(f"{css_key}:{str(v).replace(chr(34), '"')}")
|
|
return ";".join(parts)
|
|
|
|
|
|
def conditional_row_bg(conditional, row, idx):
|
|
"""Replays a DataTable's style_data_conditional row_index/filter_query rules for one
|
|
row, later rules override earlier ones on a match, same as Dash's own precedence, so
|
|
the best-engagement/best-CTR highlight still shows up here, not just in the live app.
|
|
Only supports the simple "{Col} = value" equality form filter_query uses in this
|
|
codebase, not the full Dash query mini-language. Caller applies the result per-<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 title/content area: both empty in the live layout (filled by a
|
|
# two-output callback), so bake in the one selectable month's rendered output
|
|
# directly. Switching months needs the live tool (see the disabled dropdown below),
|
|
# same rationale as every other live-only control in this exporter. Cached, since
|
|
# this component tree gets visited twice (once per div) and re-calling would
|
|
# double-register the follower-growth chart into CHARTS.
|
|
if getattr(comp, "id", None) in ("reports-title", "reports-content-area"):
|
|
if "result" not in _reports_render_cache:
|
|
if A._REPORTS_DEFAULT_MONTH:
|
|
# _render_reports_tab returns (title, content, store_value) — the third
|
|
# is only for syncing the live reports-selected-month Store, irrelevant
|
|
# to a static snapshot.
|
|
_reports_render_cache["result"] = A._render_reports_tab(A._REPORTS_DEFAULT_MONTH)[:2]
|
|
else:
|
|
_reports_render_cache["result"] = (
|
|
"", dbc.Alert("No campaign months have been written up yet.", color="info"))
|
|
title, content = _reports_render_cache["result"]
|
|
return render(title if comp.id == "reports-title" else content)
|
|
|
|
# --- Plotly graphs ---
|
|
if isinstance(comp, dcc.Graph):
|
|
return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None))
|
|
|
|
# --- Dropdowns: render as <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 == "reports-month-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="Switching months needs the live tool, not available in this snapshot.">'
|
|
f'{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/Location: no static equivalent needed (Download requires a live
|
|
# backend; Location is only a pathname-tracking hook for Dash callbacks, and the
|
|
# static export drives page-switching through switchTab() instead) ---
|
|
if isinstance(comp, (dcc.Store, dcc.Download, dcc.Location)):
|
|
return ""
|
|
|
|
# --- Link: internal routes (e.g. "/roadmap-insights") become switchTab() calls,
|
|
# since there's no server-side routing in a single static file; hrefs pointing at a
|
|
# real asset (e.g. "/assets/follower_study.html") stay real navigable links. ---
|
|
if isinstance(comp, dcc.Link):
|
|
href = getattr(comp, "href", "") or ""
|
|
style = style_dict_to_css(getattr(comp, "style", None))
|
|
label_html = render(getattr(comp, "children", None))
|
|
if href.startswith("/assets/"):
|
|
return f'<a href="{esc(href)}" target="_blank" rel="noopener noreferrer" style="{style}">{label_html}</a>'
|
|
key = href.lstrip("/")
|
|
return f'<a style="{style};cursor:pointer" onclick="switchTab(\'{esc(key)}\')">{label_html}</a>'
|
|
|
|
# --- 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", "Ul": "ul", "Li": "li", "A": "a", "I": "i",
|
|
}
|
|
tag = tag_map.get(cls, "div")
|
|
|
|
if cls == "Img":
|
|
src = inline_asset(getattr(comp, "src", ""))
|
|
return f'<img src="{esc(src)}" style="{style}"{class_attr}{id_attr}/>'
|
|
if cls == "Hr":
|
|
return f'<hr style="{style}"{class_attr}/>'
|
|
if cls == "A":
|
|
href = getattr(comp, "href", "") or ""
|
|
target = getattr(comp, "target", None)
|
|
target_attr = f' target="{esc(target)}"' if target else ""
|
|
rel_attr = ' rel="noopener noreferrer"' if target == "_blank" else ""
|
|
return (f'<a href="{esc(href)}"{target_attr}{rel_attr}{id_attr}{class_attr} '
|
|
f'style="{style}"{title_attr}>{children_html}</a>')
|
|
if cls == "Button":
|
|
if cid == "reports-download-btn":
|
|
disabled_style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
|
|
"opacity": "0.5", "cursor": "not-allowed"})
|
|
return (f'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled '
|
|
f'title="Report 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 TAB_HERO_ACTIONS = %(tab_hero_actions)s;
|
|
const CAL_STATES = %(cal_states)s;
|
|
const CAL_YEAR = %(cal_year)s;
|
|
const CAL_DEFAULT_MONTH = %(cal_default_month)s;
|
|
const FB_STATES = %(fb_states)s;
|
|
const FB_DELTA_STATES = %(fb_delta_states)s;
|
|
const FB_COUNT_ONLY_STATES = %(fb_count_only_states)s;
|
|
const VB_STATES = %(vb_states)s;
|
|
const CONTENT_STATES = %(content_states)s;
|
|
const FRR_STATES = %(frr_states)s;
|
|
|
|
const DELTA_LEGEND_HTML = '<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];
|
|
var titleEl = document.getElementById('hero-title');
|
|
var subEl = document.getElementById('hero-subtitle');
|
|
if (hero) {
|
|
// Reports folds its subtitle onto the title line instead of a separate line below
|
|
// (see _hero_title_children in app.py) — mirrored here rather than just dumping
|
|
// hero[1] onto its own line, which is what every other tab does.
|
|
if (key === 'reports') {
|
|
titleEl.innerHTML = hero[0] + '<span style="font-weight:500;font-size:0.85rem;"> — ' + hero[1] + '</span>';
|
|
subEl.textContent = '';
|
|
} else {
|
|
titleEl.textContent = hero[0];
|
|
subEl.textContent = hero[1];
|
|
var infoHtml = TAB_HERO_INFO[key];
|
|
if (infoHtml) subEl.insertAdjacentHTML('beforeend', infoHtml);
|
|
}
|
|
}
|
|
var actionsEl = document.getElementById('hero-actions');
|
|
if (actionsEl) actionsEl.innerHTML = TAB_HERO_ACTIONS[key] || '';
|
|
window.dispatchEvent(new Event('resize'));
|
|
}
|
|
|
|
function selectCalendarMonth(prefix, month) {
|
|
calMonth[prefix] = month;
|
|
var state = CAL_STATES[prefix][String(month)];
|
|
if (!state) return;
|
|
for (var m = 1; m <= 12; m++) {
|
|
var btn = document.getElementById(prefix + '-month-btn-' + m);
|
|
if (btn) btn.style.border = (m === month) ? '2px solid #1b3a5c' : '1px solid #e8e8e8';
|
|
}
|
|
var row = document.getElementById(prefix + '-posts-row');
|
|
if (row) row.innerHTML = state.postsRow;
|
|
renderChart(prefix + '-daily-followers-graph', state.dailyFig);
|
|
if (state.metrics) {
|
|
Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); });
|
|
}
|
|
if (prefix === 'followers') renderFollowersBreakdown();
|
|
}
|
|
|
|
function renderFollowersBreakdown() {
|
|
if (fbPeriod !== 'total') {
|
|
var cs = FB_COUNT_ONLY_STATES[fbPeriod];
|
|
if (cs) {
|
|
var area = document.getElementById('followers-breakdown-area');
|
|
if (area) area.innerHTML = cs.noteHtml;
|
|
var clabel = document.getElementById('fb-month-label');
|
|
if (clabel) clabel.textContent = cs.monthLabel;
|
|
var cbtn = document.getElementById('fb-expand-btn');
|
|
if (cbtn) cbtn.style.display = 'none';
|
|
return;
|
|
}
|
|
var ds = FB_DELTA_STATES[fbActive + '|' + fbPeriod];
|
|
if (!ds) return;
|
|
var area2 = document.getElementById('followers-breakdown-area');
|
|
if (area2) area2.innerHTML = '<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)
|
|
|
|
# The 7 Reports-tab insights pages (Core Message, Roadmap, All Posts, Follower
|
|
# Growth, Audience Composition, Follower Study, Monthly Notes) — reached only via
|
|
# the cross-page hero nav / "Click here for the insights" links, same as in the live
|
|
# app, so they're not in NAV_ITEMS/the sidebar and need rendering separately.
|
|
# Monthly Notes isn't one of the 7 Reports sections (reached only via its own
|
|
# sidebar link), so it's not in _REPORT_SECTIONS — added explicitly here so its
|
|
# tab div still gets rendered into the static file.
|
|
# Each insights page is now a builder FUNCTION taking a month value (see
|
|
# _INSIGHTS_PAGE_BUILDERS in app.py — the dynamic-month architecture), not
|
|
# pre-built content in TAB_CONTENT_MAP, so it's called here the same way the live
|
|
# app's routing callback does, baked to the one selectable month (see the Reports
|
|
# tab/content-area handling above — same rationale, switching months needs the
|
|
# live tool).
|
|
extra_tab_keys = sorted({key for _, key, _, _ in A._REPORT_SECTIONS} | {"monthly-notes-insights"})
|
|
for key in extra_tab_keys:
|
|
builder = A._INSIGHTS_PAGE_BUILDERS[key]
|
|
tabs_html[key] = render(builder(A._REPORTS_DEFAULT_MONTH))
|
|
|
|
sidebar_html = render(A.sidebar)
|
|
hero_html = render(A.hero)
|
|
# Per-tab extra markup appended after the hero subtitle text (e.g. the Growth tab's
|
|
# "what does growth measure" info popover) — switchTab() re-appends this on tab change
|
|
# since setting textContent wipes any previously-injected popover markup.
|
|
tab_hero_info_html = {key: render(A.info_popover(f"hero-{key}", info))
|
|
for key, info in A.TAB_HERO_INFO.items()}
|
|
# Per-tab hero-action buttons (Followers' Directory/Study links, and the Reports
|
|
# insights pages' cross-page nav) — previously baked in once for "overview" and
|
|
# never updated on tab switch at all, a pre-existing gap this fixes at the same time.
|
|
tab_hero_actions_html = {
|
|
key: render(A._hero_actions(key))
|
|
for key in [k for k, _, _ in A.NAV_ITEMS] + extra_tab_keys
|
|
}
|
|
|
|
cal_states, cal_year, cal_default_month = precompute_calendar_states()
|
|
fb_states = precompute_fb_states()
|
|
fb_delta_states = precompute_fb_delta_states()
|
|
fb_count_only_states = precompute_fb_count_only_states()
|
|
vb_states = precompute_vb_states()
|
|
frr_states = precompute_frr_states()
|
|
frr_default_period = A._frr_default_period(A.D["frr"])
|
|
content_states = precompute_content_states()
|
|
|
|
with open(os.path.join(ASSETS_DIR, "custom.css")) as f:
|
|
custom_css = f.read()
|
|
|
|
tab_divs = "\n".join(
|
|
f'<div id="tab-{key}" class="app-tab" style="display:{"block" if key == "overview" else "none"};">'
|
|
f'{tabs_html[key]}</div>'
|
|
for key in [k for k, _, _ in A.NAV_ITEMS] + extra_tab_keys
|
|
)
|
|
|
|
js = JS_TEMPLATE % {
|
|
"charts": json.dumps(CHARTS),
|
|
"tab_hero": json.dumps(A.TAB_HERO),
|
|
"tab_hero_info": json.dumps(tab_hero_info_html),
|
|
"tab_hero_actions": json.dumps(tab_hero_actions_html),
|
|
"cal_states": json.dumps(cal_states),
|
|
"cal_year": json.dumps(cal_year),
|
|
"cal_default_month": json.dumps(cal_default_month),
|
|
"fb_states": json.dumps(fb_states),
|
|
"fb_delta_states": json.dumps(fb_delta_states),
|
|
"fb_count_only_states": json.dumps(fb_count_only_states),
|
|
"vb_states": json.dumps(vb_states),
|
|
"frr_states": json.dumps(frr_states),
|
|
"frr_default_period": json.dumps(frr_default_period),
|
|
"content_states": json.dumps(content_states),
|
|
}
|
|
|
|
html_doc = f"""<!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">
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.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()
|