Files
LinkedIn-Analytics-Dashboard/app.py
T
beantownbabeandClaude Sonnet 5 292bd21abe Generate EOM reports as editable .docx instead of fixed-layout PDF
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>
2026-09-30 14:19:29 +02:00

4495 lines
245 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""TXproducts LinkedIn Analytics Dashboard"""
import warnings; warnings.filterwarnings("ignore")
import os
from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import report
import growth
import visitor_history
import monthly_report_notes
import follower_directory_data
import engagement_audit
# Overridable via env var so the same code runs unmodified in Docker/internal deployments —
# defaults to this machine's path for local dev.
DATA_DIR = os.environ.get("LINKEDIN_DATA_DIR", "/Users/mckinleyblack/Documents")
UPLOAD_DIR = os.path.join(DATA_DIR, "linkedin_analytics")
ASSETS_DIR = os.path.join(UPLOAD_DIR, "assets")
# Loaded once at startup, not re-parsed per callback — re-run the app to pick up new
# assets/follower_directory.html entries, same as every other data source here.
DIRECTORY_DATA = follower_directory_data.load_directory(ASSETS_DIR)
# LinkedIn's export only gives daily gains, never an absolute follower total. This is
# the one manual anchor point: the count going into the very first day this export
# ever covered (2026-06-16). Every other day's exact total is baseline + cumulative
# gains since, computed automatically — this constant should never need to change.
BASELINE_FOLLOWERS = 84
BASELINE_DATE = "2026-06-16"
# LinkedIn exports no caption text for some posts (image/video-only). Fill in titles by hand here.
MANUAL_POST_TITLES = {
"2026-07-09": "How it all started",
"2026-07-14": "Time resolution",
"2026-08-13": "TXservices measurement at a fraction of the cost",
"2026-09-03": "Come meet us at SNIB2026",
}
# TXproducts resharing someone else's post, not a post in TXproducts' own voice — excluded
# from every format/day/traction breakdown on the Reports tab's insights pages, the same way
# a competitor's post wouldn't count as one of "our" posts. LinkedIn gives no visibility into
# who reposts TXproducts' own content, so that direction isn't tracked at all.
REPOST_DATES = {"2026-07-10"}
# Posts that exist in the LinkedIn export but aren't part of the 24-post branding
# campaign (confirmed by Mckinley 2026-09-14/09-21) — excluded from every
# format/day/traction breakdown and the Audience/Team audit the same way
# REPOST_DATES is, so a one-off non-campaign post can't skew "what worked" findings
# that are specifically about the campaign's own content. The campaign itself only
# runs July-September (per LinkedIn Branding Campaign Roadmap.pdf), so anything
# before 01/07 is out of range by definition, not a case-by-case judgment call.
NON_CAMPAIGN_DATES = {"2026-06-19", "2026-06-24", "2026-09-02"}
# LinkedIn's export doesn't distinguish carousel/document posts from generic Image — override by hand.
MANUAL_CONTENT_TYPES = {
"2026-07-09": "Carousel",
"2026-07-16": "Carousel",
"2026-07-21": "Poll",
"2026-07-23": "Carousel",
"2026-07-30": "Carousel",
"2026-08-04": "Carousel",
"2026-08-06": "Poll",
}
# ── Palette — matched to txdata_project/portal.py's navy/blue design language ──
BLUE = "#1b3a5c" # navy — primary accent (was LinkedIn blue #0A66C2)
NAVY2 = "#2d6a9f" # secondary blue, used in gradients alongside BLUE
ORANGE = "#E8825A"
GREEN = "#52C41A"
AMBER = "#FFA726"
LIGHT_BLUE = "#4a90d9"
TEAL = "#2d6a9f" # aligned to the secondary blue instead of a separate teal
TMPL = "plotly_white"
BG = "rgba(0,0,0,0)"
FORMAT_COLORS = {
"Video": "#E8825A",
"Image": "#1b3a5c",
"Article": "#52C41A",
"Text": "#FFA726",
"Carousel": "#9C27B0",
"Poll": "#00838F",
"Unknown": "#888888",
}
# ── Chart helper ──────────────────────────────────────────────────────────────
def _std(fig):
fig.update_layout(
plot_bgcolor=BG, paper_bgcolor=BG,
margin=dict(t=30, b=30, l=10, r=10),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
font=dict(size=11),
)
return fig
def _finalize_fig(fig, margin=None, legend=None):
"""Apply the shared _std() styling plus any per-chart margin/legend overrides —
used both when building a chart inline and when rebuilding one inside a callback,
so the two paths always render identically."""
fig = _std(fig)
if margin:
fig.update_layout(margin=margin)
if legend:
fig.update_layout(legend=legend)
return fig
def G(fig, style=None, margin=None, legend=None, **kw):
s = {"height": "320px"}
if style:
s.update(style)
fig = _finalize_fig(fig, margin=margin, legend=legend)
return dcc.Graph(figure=fig, config={"displayModeBar": False}, style=s, **kw)
def mark_posts(fig, fts_df, posts_df):
"""Overlay a star marker on days a post was published, with the post title on hover."""
if posts_df.empty:
return fig
day_totals = fts_df.set_index("Date")["Total followers"]
matched = posts_df[posts_df["Created date"].isin(fts_df["Date"])]
if matched.empty:
return fig
fig.add_trace(go.Scatter(
x=matched["Created date"],
y=[day_totals.get(d, 0) for d in matched["Created date"]],
mode="markers",
marker=dict(symbol="star", size=13, color="#E53935", line=dict(width=1, color="white")),
name="Post published",
customdata=matched["Short title"],
hovertemplate="<b>Post published — %{x|%b %d, %Y}</b><br>%{customdata}<extra></extra>",
))
return fig
def _is_linkedin_export(fname, keyword, extensions):
"""True only for real LinkedIn export files (e.g. txproducts_followers_<id>.xls) —
excludes unrelated files that happen to contain the keyword (like a manually-built
roster) and Office lock files (~$...) left behind by a file currently open in Excel."""
lower = fname.lower()
if lower.startswith("~$") or lower.startswith("."):
return False
if not lower.startswith("txproducts_"):
return False
return keyword.lower() in lower and lower.endswith(extensions)
def _find_latest_file(keyword, extensions=(".xls", ".xlsx")):
"""Most recently modified real LinkedIn export across DATA_DIR and UPLOAD_DIR whose
name contains `keyword` — replaces matching one exact hardcoded filename, so any
correctly-named LinkedIn export (including freshly uploaded ones) is picked up without
needing the file's exact auto-generated export ID."""
candidates = []
for folder in (DATA_DIR, UPLOAD_DIR):
if not os.path.isdir(folder):
continue
for fname in os.listdir(folder):
if _is_linkedin_export(fname, keyword, extensions):
candidates.append(os.path.join(folder, fname))
if not candidates:
raise FileNotFoundError(
f"No file containing '{keyword}' found in {DATA_DIR} or {UPLOAD_DIR}. "
"Upload the matching LinkedIn export to get the dashboard running.")
return max(candidates, key=os.path.getmtime)
def _find_all_files(keyword, extensions=(".xls", ".xlsx")):
"""Like _find_latest_file, but returns every match sorted oldest-to-newest instead of
just the newest — Content merges all historical exports rather than replacing them."""
candidates = []
for folder in (DATA_DIR, UPLOAD_DIR):
if not os.path.isdir(folder):
continue
for fname in os.listdir(folder):
if _is_linkedin_export(fname, keyword, extensions):
candidates.append(os.path.join(folder, fname))
return sorted(set(candidates), key=os.path.getmtime)
# ── Load data ─────────────────────────────────────────────────────────────────
def load():
d = {}
file_mtimes = []
# Followers — "New followers" is a rolling ~30-day window in each export, so merge every
# historical file by date (newest file wins on an overlapping date) the same way Content
# already does. Otherwise a day that ages out of the newest file's window silently
# disappears from the cumulative total and any month it belonged to.
follower_files = _find_all_files("followers")
file_mtimes.extend(os.path.getmtime(fp) for fp in follower_files)
fts_frames = []
for fp in follower_files:
f = pd.read_excel(fp, sheet_name="New followers")
f["Date"] = pd.to_datetime(f["Date"])
f["_src"] = os.path.getmtime(fp)
fts_frames.append(f)
fts = (pd.concat(fts_frames)
.sort_values("_src")
.drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"])
.sort_values("Date")
.reset_index(drop=True))
# Cumulative math is anchored at BASELINE_DATE — drop any merged rows before it so the
# baseline stays the true day-zero rather than double-counting pre-baseline history.
fts = fts[fts["Date"] >= pd.Timestamp(BASELINE_DATE)].reset_index(drop=True)
# Exact follower count on each day = the one manual baseline anchor + every gain since.
fts["Cumulative Followers"] = BASELINE_FOLLOWERS + fts["Total followers"].cumsum()
d["fts"] = fts
# Breakdown sheets are point-in-time snapshots, not a time series — always the newest file
# for the "current" values, but also keep one snapshot per (year, month) below so the
# "Followers by" panel can show an accurate historical split instead of always today's.
fp = follower_files[-1]
d["floc"] = pd.read_excel(fp, sheet_name="Location").sort_values("Total followers", ascending=False)
d["fjob"] = pd.read_excel(fp, sheet_name="Job function").sort_values("Total followers")
d["fsen"] = pd.read_excel(fp, sheet_name="Seniority").sort_values("Total followers")
d["find"] = pd.read_excel(fp, sheet_name="Industry").sort_values("Total followers", ascending=False)
d["fcs"] = pd.read_excel(fp, sheet_name="Company size").sort_values("Total followers")
# One breakdown snapshot per (year, month) — the latest export within that month wins,
# so e.g. selecting June shows June's actual demographic split, not today's.
breakdown_history = {}
for hfp in follower_files:
mtime = pd.Timestamp(os.path.getmtime(hfp), unit="s")
breakdown_history[(mtime.year, mtime.month)] = {
"floc": pd.read_excel(hfp, sheet_name="Location").sort_values("Total followers", ascending=False),
"fjob": pd.read_excel(hfp, sheet_name="Job function").sort_values("Total followers"),
"fsen": pd.read_excel(hfp, sheet_name="Seniority").sort_values("Total followers"),
"find": pd.read_excel(hfp, sheet_name="Industry").sort_values("Total followers", ascending=False),
"fcs": pd.read_excel(hfp, sheet_name="Company size").sort_values("Total followers"),
}
d["breakdown_history"] = breakdown_history
# Visitors — same rolling-window issue as Followers, same fix.
visitor_files = _find_all_files("visitors")
file_mtimes.extend(os.path.getmtime(vp) for vp in visitor_files)
vts_frames = []
for vp in visitor_files:
v = pd.read_excel(vp, sheet_name="Visitor metrics")
v["Date"] = pd.to_datetime(v["Date"])
v["_src"] = os.path.getmtime(vp)
vts_frames.append(v)
vts = (pd.concat(vts_frames)
.sort_values("_src")
.drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"])
.sort_values("Date")
.reset_index(drop=True))
d["vts"] = vts
vp = visitor_files[-1]
d["vloc"] = pd.read_excel(vp, sheet_name="Location").sort_values("Total views", ascending=False)
d["vsen"] = pd.read_excel(vp, sheet_name="Seniority").sort_values("Total views")
d["vind"] = pd.read_excel(vp, sheet_name="Industry")
d["vcs"] = pd.read_excel(vp, sheet_name="Company size").sort_values("Total views")
# Persistent running total per category (Location/Seniority/Company size), accumulated
# across every weekly export ever uploaded — see visitor_history.py for why this exists:
# LinkedIn only shows a category if it clears a minimum-visits threshold that week, so a
# single-snapshot approach (like Followers' breakdown_history) would silently lose a
# location the moment one week's export didn't happen to include it.
d["visitor_category_history"] = visitor_history.backfill_history(UPLOAD_DIR, visitor_files)
# Visitor -> Follower conversion: correlates daily unique visitors against daily new followers.
# Not a true funnel — LinkedIn doesn't expose which specific visitor became a follower, only
# daily totals for each, so this is a same-day correlation, not individual-level attribution.
conv = pd.merge(
fts[["Date", "Total followers"]].rename(columns={"Total followers": "New Followers"}),
vts[["Date", "Total unique visitors (desktop)", "Total unique visitors (mobile)"]],
on="Date", how="inner",
)
conv["Unique Visitors"] = conv["Total unique visitors (desktop)"] + conv["Total unique visitors (mobile)"]
conv["Conversion Rate"] = conv.apply(
lambda r: (r["New Followers"] / r["Unique Visitors"] * 100) if r["Unique Visitors"] > 0 else None, axis=1
)
d["conv"] = conv
# Content — merge all content files from both locations, newest data wins on duplicate dates
content_files = _find_all_files("content")
file_mtimes.extend(os.path.getmtime(cp) for cp in content_files)
metrics_frames, posts_frames = [], []
for cp in content_files:
m = pd.read_excel(cp, sheet_name="Metrics", header=1)
m["Date"] = pd.to_datetime(m["Date"])
m["_src"] = os.path.getmtime(cp)
metrics_frames.append(m)
p = pd.read_excel(cp, sheet_name="All posts", header=1)
p["_src"] = os.path.getmtime(cp)
posts_frames.append(p)
# Merge timeseries: keep newest file's row when dates overlap
cts = (pd.concat(metrics_frames)
.sort_values("_src")
.drop_duplicates(subset=["Date"], keep="last")
.drop(columns=["_src"])
.sort_values("Date")
.reset_index(drop=True))
cts["CTR (organic) %"] = (cts["Clicks (organic)"] / cts["Impressions (organic)"] * 100).where(
cts["Impressions (organic)"] > 0, 0)
cts["CTR (sponsored) %"] = (cts["Clicks (sponsored)"] / cts["Impressions (sponsored)"] * 100).where(
cts["Impressions (sponsored)"] > 0, 0)
d["cts"] = cts
# Follow Rate Relative to Reach: New Followers / Impressions * 100, per day. Replaces
# Conversion Rate (New Followers / Unique Visitors) for the cases where that metric silently
# drops a day — LinkedIn lets people follow a Page straight from a post in their feed, with
# zero page visit, so a real follower gain can land on a day with 0 tracked visitors. Impressions
# are almost never zero on a day with any reach at all, so those days no longer disappear.
frr = pd.merge(
conv[["Date", "New Followers", "Unique Visitors"]],
cts[["Date", "Impressions (organic)", "Impressions (sponsored)"]],
on="Date", how="inner",
)
frr["Impressions"] = frr["Impressions (organic)"] + frr["Impressions (sponsored)"]
frr["Follow Rate"] = frr.apply(
lambda r: (r["New Followers"] / r["Impressions"] * 100) if r["Impressions"] > 0 else None, axis=1
)
frr["No Visitor Gain"] = (frr["Unique Visitors"] == 0) & (frr["New Followers"] > 0)
d["frr"] = frr
# Merge posts: deduplicate by Post link, keep newest file's version
posts = (pd.concat(posts_frames)
.sort_values("_src")
.drop_duplicates(subset=["Post link"], keep="last")
.drop(columns=["_src"])
.reset_index(drop=True))
posts["Created date"] = pd.to_datetime(posts["Created date"], errors="coerce")
posts["Eng %"] = (posts["Engagement rate"] * 100).round(2)
posts["CTR %"] = (posts["Click through rate (CTR)"] * 100).round(2)
# Real Eng % (Audience-only, Team-excluded, hand-audited — see engagement_audit.py),
# computed for every post ever exported, not just the current Reports month. None
# for any post without an audit entry — most of history, until backfilled.
posts["Real Eng %"] = posts.apply(
lambda r: engagement_audit.real_eng_pct(
r["Created date"].strftime("%d/%m"), r["Impressions"], r.get("Clicks", 0))
if pd.notna(r["Created date"]) else None, axis=1).round(2)
posts["Content Type"] = posts["Content Type"].fillna("Text/Image")
posts["Post title"] = posts["Post title"].fillna("(no text — image/video-only post)")
# Manual titles for posts LinkedIn exported with no caption text (image/video-only posts).
for post_date, manual_title in MANUAL_POST_TITLES.items():
posts.loc[posts["Created date"].dt.date == pd.Timestamp(post_date).date(), "Post title"] = manual_title
# Manual content-type corrections (e.g. carousels LinkedIn exports as plain Image).
for post_date, manual_type in MANUAL_CONTENT_TYPES.items():
posts.loc[posts["Created date"].dt.date == pd.Timestamp(post_date).date(), "Content Type"] = manual_type
posts["Short title"] = posts["Post title"].str[:75] + "…"
# Chronological (oldest first), regardless of which file/upload order contributed
# each row — same sort order as every other per-post table in the app, so a
# specific post's row is never in a different relative order table to table.
posts = posts.sort_values("Created date", ascending=True).reset_index(drop=True)
d["posts"] = posts
# Competitors
comp_path = _find_latest_file("competitor")
file_mtimes.append(os.path.getmtime(comp_path))
d["comp"] = pd.read_excel(comp_path, sheet_name="COMPETITORS", header=1)
# Most recent export drop across all sources — shown in the header
d["last_updated"] = pd.Timestamp(max(file_mtimes), unit="s")
return d
def date_range_label(df, col="Date"):
if df.empty:
return ""
lo, hi = df[col].min(), df[col].max()
return f"{lo.strftime('%b %d')} – {hi.strftime('%b %d')}"
_MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
_DELTA_MONTH_CAP = 20
def refresh_data():
global D, GROWTH_SNAPSHOT, current_total_followers, current_total_followers_as_of
global new_followers_30d, total_impressions, total_page_views, best_eng, num_posts
global total_clicks, avg_eng_rate, conv_total_followers, conv_total_visitors
global overall_conversion_rate, overview, posts, post_table_cols, content_tab
global followers_tab, visitors_tab, comp, comp_ordered, competitors_tab, growth_tab
global TAB_CONTENT_MAP, _breakdown_figure, _visitor_breakdown_figure
global _breakdown_delta_figure, _followers_delta_months
global _followers_count_only_months, _monthly_count_summary
global _visitor_category_months
global _posts_table_records, _content_posts_filtered, _content_cts_filtered
global impressions_by_format_figure, engagement_by_format_figure, ctr_by_format_figure
global impressions_vs_engagement_figure, reactions_figure, clicks_figure
global FOLLOWER_BREAKDOWNS, VISITOR_BREAKDOWNS, CONTENT_BAR_MARGIN, CONTENT_SCATTER_LEGEND
global _content_month_options, post_highlight_styles
D = load()
GROWTH_SNAPSHOT = growth.backfill_history(D["fts"], UPLOAD_DIR)[-1]
current_total_followers = int(D["fts"]["Cumulative Followers"].iloc[-1])
current_total_followers_as_of = D["fts"]["Date"].max().strftime("%b %d, %Y")
new_followers_30d = int(D["fts"]["Total followers"].sum())
total_impressions = int(D["cts"]["Impressions (total)"].sum())
total_page_views = int(D["vts"]["Total page views (total)"].sum())
_audited_eng = D["posts"]["Real Eng %"].dropna() if len(D["posts"]) else pd.Series(dtype=float)
best_eng = float(_audited_eng.max()) if not _audited_eng.empty else None
num_posts = len(D["posts"])
total_clicks = int(D["cts"]["Clicks (total)"].sum())
avg_eng_rate = float(D["cts"]["Engagement rate (total)"].replace(0, float("nan")).mean())
conv_total_followers = int(D["conv"]["New Followers"].sum())
conv_total_visitors = int(D["conv"]["Unique Visitors"].sum())
overall_conversion_rate = (conv_total_followers / conv_total_visitors * 100) if conv_total_visitors else None
_ov_year = int(D["fts"]["Date"].dt.year.max())
_ov_month = _default_active_month(D["fts"], D["posts"], _ov_year)
overview = dbc.Container([
html.Div([
html.Div(kpi("Starting Followers", f"{BASELINE_FOLLOWERS:,}", f"as of {pd.Timestamp(BASELINE_DATE).strftime('%b %d, %Y')}", "#999"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Followers", f"{current_total_followers:,}", f"as of {current_total_followers_as_of}", TEAL), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("New Followers", f"{new_followers_30d:,}", date_range_label(D["fts"])), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Impressions", f"{total_impressions:,}", date_range_label(D["cts"])), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Page Views", f"{total_page_views:,}", date_range_label(D["vts"])), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Clicks", f"{total_clicks:,}", date_range_label(D["cts"])), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Best Post Real Eng.", _fmt_best_eng(best_eng), f"across {num_posts} posts", GREEN), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Avg. Eng. Rate", f"{avg_eng_rate:.2f}%" if not pd.isna(avg_eng_rate) else "—", "daily avg (organic)", TEAL), style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"}),
dbc.Row([
dbc.Col(section("Follower Gains — Calendar (★ = post published)",
followers_calendar(D["fts"], D["posts"], id_prefix="overview", height="270px")
), md=8),
dbc.Col(section("New Followers per Day (★ = post published)",
G(daily_followers_figure(D["fts"], D["posts"], _ov_year, _ov_month),
id="overview-daily-followers-graph", style={"height": "270px"})
), md=4),
], className="gx-3 mb-3"),
dbc.Row([
dbc.Col(section("Impressions",
G(impressions_figure(_filtered_by_month(D["cts"], _ov_year, _ov_month)),
id="overview-impressions-graph", style={"height": "240px"}),
info=("impressions", IMPRESSIONS_EXPLAINER),
), md=3),
dbc.Col(section("Daily Engagement Rate",
G(engagement_figure(_filtered_by_month(D["cts"], _ov_year, _ov_month)),
id="overview-engagement-graph", style={"height": "240px"}),
info=("engagement", ENGAGEMENT_EXPLAINER),
), md=3),
dbc.Col(section("Daily CTR",
G(ctr_figure(_filtered_by_month(D["cts"], _ov_year, _ov_month)),
id="overview-ctr-graph", style={"height": "240px"}),
info=("ctr", CTR_EXPLAINER),
), md=3),
dbc.Col(section("Page Views — Desktop vs Mobile",
G(page_views_figure(_filtered_by_month(D["vts"], _ov_year, _ov_month)),
id="overview-pageviews-graph", style={"height": "240px"}),
info=("page-views", PAGE_VIEWS_EXPLAINER),
), md=3),
], className="gx-3"),
], fluid=True, className="pb-3")
# ───── CONTENT PERFORMANCE ────────────────────────────────────────────────────
posts = D["posts"]
post_table_cols = ["Short title", "Created date", "Post type", "Content Type",
"Impressions", "Clicks", "CTR %", "Real Eng %", "Likes", "Comments", "Reposts"]
# Highlight the top-engagement post in pale yellow, and the top-CTR post in
# teal if it's a different post from the top-engagement one. Real Eng % is
# None/NaN for any unaudited post, which .max()/filter_query both simply skip —
# no highlight at all is the correct fallback if nothing in scope is audited yet,
# rather than reviving the raw, Team-inflated number to find something to highlight.
post_highlight_styles = [{"if": {"row_index": "odd"}, "backgroundColor": "#f8fafc"}]
if not posts.empty and posts["Real Eng %"].notna().any():
best_eng_val = posts["Real Eng %"].max()
best_ctr_val = posts["CTR %"].max()
same_post_has_both = not posts[
(posts["Real Eng %"] == best_eng_val) & (posts["CTR %"] == best_ctr_val)
].empty
post_highlight_styles.append({
"if": {"filter_query": "{Real Eng %} = " + str(best_eng_val)},
"backgroundColor": "#FFF9C4",
})
if not same_post_has_both:
post_highlight_styles.append({
"if": {"filter_query": "{CTR %} = " + str(best_ctr_val)},
"backgroundColor": "#B2EBF2",
})
CONTENT_HIGHLIGHT_EXPLAINER = html.Div([
html.Div([color_dot("#FFF9C4"), html.B("Highest Real engagement rate")], style={"marginBottom": "3px"}),
html.Div("Audience-only, Team excluded, hand-audited where available. Note this can differ from "
"whichever post LinkedIn's own algorithm favored in the moment — the algorithm reacts to "
"the raw number, Team activity included, not this corrected one.",
style={"color": "#6b7a8d", "marginBottom": "10px"}),
html.Div([color_dot("#B2EBF2"), html.B("Highest CTR")], style={"marginBottom": "3px"}),
html.Div("Best at driving clicks off LinkedIn — matters most when the goal is traffic to a "
"website rather than reach.", style={"color": "#6b7a8d", "marginBottom": "10px"}),
html.Hr(style={"margin": "8px 0"}),
html.B("Engagement rate vs. impressions vs. CTR — which one matters?"),
html.Div("Impressions mostly reflect audience size and algorithmic luck, not content quality.",
style={"marginTop": "6px", "marginBottom": "8px"}),
html.Div("Engagement rate (LinkedIn's own raw number, not the Real Eng % shown here) is what "
"LinkedIn uses as its main signal in the first hour to decide how far to keep pushing a "
"post, so it's the closest thing to a leading indicator of future reach.",
style={"marginBottom": "8px"}),
html.Div("CTR only matters when the goal is specifically driving off-platform traffic. LinkedIn "
"doesn't reward outbound clicks the way it rewards on-platform engagement, so a "
"high-CTR post can still get less reach than a high-engagement one with zero clicks."),
])
# Distinct from ENGAGEMENT_EXPLAINER above (Overview's Daily Engagement Rate chart,
# LinkedIn's own raw daily aggregate from Content Tracking Stats) — this chart is
# per-post, Real Eng %, Audience-only/Team-excluded, hand-audited where available.
# Reusing ENGAGEMENT_EXPLAINER here would describe the wrong metric.
CONTENT_FORMAT_ENGAGEMENT_EXPLAINER = html.Div([
html.Div("Real Engagement Rate (Audience-only, Team excluded) by content format.",
style={"marginBottom": "8px"}),
html.Div("Hand-audited against each post's actual Reactions/Comments/Reposts lists on "
"LinkedIn where available. Unaudited posts are excluded from the average rather "
"than falling back to LinkedIn's raw, Team-inflated number.",
style={"color": "#6b7a8d"}),
])
IMPRESSIONS_VS_ENGAGEMENT_EXPLAINER = html.Div([
html.Div("Each dot is a post — x is reach, y is Real Engagement Rate (Audience-only, Team "
"excluded), color is content type, and bubble size is clicks. Unaudited posts are "
"excluded from this chart rather than shown with a misleading raw number.",
style={"marginBottom": "8px"}),
html.Div('It answers "does reach actually translate into resonance, or are some posts getting '
'distributed widely without anyone caring?"', style={"marginBottom": "8px"}),
html.Div("You can spot high-impression/low-engagement posts (reach without resonance) versus "
"low-impression/high-engagement posts (content LinkedIn under-distributed but people liked).",
style={"color": "#6b7a8d"}),
])
def _posts_table_records(selected_month):
"""Rows for the All Posts table, optionally filtered to one 'YYYY-MM' month value."""
df = posts[post_table_cols].copy()
source = posts
if selected_month and selected_month != "all":
year, month = (int(part) for part in selected_month.split("-"))
mask = (posts["Created date"].dt.year == year) & (posts["Created date"].dt.month == month)
df = df[mask]
source = posts[mask]
# A repost/non-campaign post's Real Eng % is None because it's deliberately
# never audited (see engagement_audit.py), not because the audit is behind —
# "Pending audit" on one of these would misread as an incomplete job rather
# than a deliberate exclusion, so those dates get "Excluded" instead, same
# wording as the Reports tab's All Posts/Core Message treatment of the same
# dates.
excluded_mask = source["Created date"].dt.strftime("%Y-%m-%d").isin(REPOST_DATES | NON_CAMPAIGN_DATES)
df["Created date"] = df["Created date"].dt.strftime("%d/%m/%Y")
df["Real Eng %"] = [
(v if not pd.isna(v) else ("Excluded" if excluded else "Pending audit"))
for v, excluded in zip(df["Real Eng %"], excluded_mask)
]
return df.to_dict("records")
def _content_month_options():
import calendar as calmod
months = sorted({(d.year, d.month) for d in posts["Created date"].dropna()})
return [{"label": "All Months", "value": "all"}] + [
{"label": f"{calmod.month_name[m]} {y}", "value": f"{y}-{m:02d}"} for y, m in months
]
def _content_posts_filtered(selected_month):
if not selected_month or selected_month == "all":
return posts
year, month = (int(part) for part in selected_month.split("-"))
return posts[(posts["Created date"].dt.year == year) & (posts["Created date"].dt.month == month)]
def _content_cts_filtered(selected_month):
cts = D["cts"]
if not selected_month or selected_month == "all":
return cts
year, month = (int(part) for part in selected_month.split("-"))
return cts[(cts["Date"].dt.year == year) & (cts["Date"].dt.month == month)]
def _format_breakdown(posts_df, col, agg):
if posts_df.empty:
return pd.DataFrame(columns=["Content Type", col])
grouped = posts_df.groupby("Content Type", as_index=False)[col]
return (grouped.sum() if agg == "sum" else grouped.mean()).sort_values(col)
def impressions_by_format_figure(posts_df):
df = _format_breakdown(posts_df, "Impressions", "sum")
fig = px.bar(df, x="Impressions", y="Content Type", orientation="h", template=TMPL,
color="Content Type", color_discrete_map=FORMAT_COLORS)
return fig.update_layout(showlegend=False, bargap=0.6)
def engagement_by_format_figure(posts_df):
# A bar can't show "pending" — unaudited posts are excluded from the average
# rather than falling back to the raw, Team-inflated number, with a caption
# noting how many were dropped so the gap is never silent.
audited = posts_df[posts_df["Real Eng %"].notna()]
excluded = len(posts_df) - len(audited)
df = _format_breakdown(audited, "Real Eng %", "mean")
fig = px.bar(df, x="Real Eng %", y="Content Type", orientation="h", template=TMPL,
color="Content Type", color_discrete_map=FORMAT_COLORS,
labels={"Real Eng %": "Real Engagement Rate (%)"})
fig.update_layout(showlegend=False, bargap=0.6)
if excluded:
# Inside the plot area (not below the x-axis title) so it doesn't need
# extra bottom margin on these already-compact 180px mini charts.
fig.add_annotation(
text=f"{excluded} unaudited excluded",
xref="paper", yref="paper", x=0.98, y=0.98, xanchor="right", yanchor="top",
showarrow=False, font=dict(size=8, color="#9a9a9a"))
return fig
def ctr_by_format_figure(posts_df):
df = _format_breakdown(posts_df, "CTR %", "mean")
fig = px.bar(df, x="CTR %", y="Content Type", orientation="h", template=TMPL,
color="Content Type", color_discrete_map=FORMAT_COLORS,
labels={"CTR %": "Click-Through Rate (%)"})
return fig.update_layout(showlegend=False, bargap=0.6)
def impressions_vs_engagement_figure(posts_df):
# Same exclude-rather-than-fake-a-point rule as engagement_by_format_figure.
audited = posts_df[posts_df["Real Eng %"].notna()]
excluded = len(posts_df) - len(audited)
fig = px.scatter(audited, x="Impressions", y="Real Eng %", color="Content Type", size="Clicks",
hover_name="Short title", template=TMPL, color_discrete_map=FORMAT_COLORS,
labels={"Real Eng %": "Real Engagement Rate (%)", "Content Type": ""})
fig.update_traces(marker=dict(sizemin=8))
if excluded:
# Inside the plot area (not below the x-axis title) so it doesn't need
# extra bottom margin on these already-compact 180px mini charts.
fig.add_annotation(
text=f"{excluded} unaudited excluded",
xref="paper", yref="paper", x=0.98, y=0.98, xanchor="right", yanchor="top",
showarrow=False, font=dict(size=8, color="#9a9a9a"))
return fig
def reactions_figure(cts_df):
df = cts_df[["Date", "Reactions (organic)", "Comments (organic)", "Reposts (organic)"]].rename(
columns={"Reactions (organic)": "Reactions", "Comments (organic)": "Comments", "Reposts (organic)": "Reposts"})
df["Sponsored"] = cts_df["Reactions (sponsored)"] + cts_df["Comments (sponsored)"] + cts_df["Reposts (sponsored)"]
return px.line(df, x="Date", y=["Reactions", "Comments", "Reposts", "Sponsored"],
template=TMPL, color_discrete_sequence=[BLUE, GREEN, "#9C27B0", AMBER],
labels={"value": "Count", "variable": ""})
def clicks_figure(cts_df):
df = cts_df[["Date", "Clicks (organic)", "Clicks (sponsored)"]].rename(
columns={"Clicks (organic)": "Organic", "Clicks (sponsored)": "Sponsored"})
return px.line(df, x="Date", y=["Organic", "Sponsored"], template=TMPL,
color_discrete_sequence=[BLUE, AMBER], labels={"value": "Clicks", "variable": ""})
CONTENT_BAR_MARGIN = dict(t=10, b=20, l=10, r=10)
CONTENT_SCATTER_LEGEND = dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,
font=dict(size=8), itemwidth=30, tracegroupgap=0)
_posts_table_df = posts[post_table_cols].copy()
# A repost/non-campaign post's Real Eng % is None because it's deliberately never
# audited (see engagement_audit.py), not because the audit is behind — "Pending
# audit" on one of these would misread as an incomplete job rather than a
# deliberate exclusion, so those dates get "Excluded" instead, same wording as
# the Reports tab's All Posts/Core Message treatment of the same dates.
_excluded_dates_mask = posts["Created date"].dt.strftime("%Y-%m-%d").isin(REPOST_DATES | NON_CAMPAIGN_DATES)
_posts_table_df["Created date"] = _posts_table_df["Created date"].dt.strftime("%d/%m/%Y")
_posts_table_df["Real Eng %"] = [
(v if not pd.isna(v) else ("Excluded" if excluded else "Pending audit"))
for v, excluded in zip(_posts_table_df["Real Eng %"], _excluded_dates_mask)
]
content_tab = dbc.Container([
dbc.Card([
dbc.CardHeader([
html.Strong("Posts"),
dcc.Dropdown(
id="content-month-select",
options=_content_month_options(), value="all", clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "150px", "fontWeight": "700", "color": "#1a2533"},
),
info_popover("content-highlight", CONTENT_HIGHLIGHT_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
dbc.CardBody(
dash_table.DataTable(
id="posts-table",
data=_posts_table_df.to_dict("records"),
columns=[{"name": c, "id": c} for c in post_table_cols],
page_action="none",
fixed_rows={"headers": True},
style_table={"overflowX": "auto", "overflowY": "auto", "maxHeight": "260px"},
style_header={"backgroundColor": "#f0f4f8", "color": "#333", "fontWeight": "bold", "border": "1px solid #ccc",
"fontSize": "11px", "padding": "3px 6px", "height": "22px", "lineHeight": "16px",
"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis"},
style_cell={"backgroundColor": "#ffffff", "color": "#333", "border": "1px solid #e0e0e0",
"fontSize": "11px", "padding": "3px 6px", "height": "22px", "lineHeight": "16px",
"textAlign": "left", "whiteSpace": "nowrap",
"maxWidth": "300px", "overflow": "hidden", "textOverflow": "ellipsis"},
style_cell_conditional=[
{"if": {"column_id": "Short title"}, "minWidth": "250px", "maxWidth": "none"},
{"if": {"column_id": "Created date"}, "width": "95px", "minWidth": "95px", "maxWidth": "95px"},
{"if": {"column_id": "Post type"}, "width": "75px", "minWidth": "75px", "maxWidth": "75px"},
{"if": {"column_id": "Content Type"}, "width": "100px", "minWidth": "100px", "maxWidth": "100px"},
{"if": {"column_id": "Impressions"}, "width": "85px", "minWidth": "85px", "maxWidth": "85px"},
{"if": {"column_id": "Clicks"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
{"if": {"column_id": "Real Eng %"}, "width": "70px", "minWidth": "70px", "maxWidth": "70px"},
{"if": {"column_id": "CTR %"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
{"if": {"column_id": "Likes"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
{"if": {"column_id": "Comments"}, "width": "70px", "minWidth": "70px", "maxWidth": "70px"},
{"if": {"column_id": "Reposts"}, "width": "70px", "minWidth": "70px", "maxWidth": "70px"},
],
style_data_conditional=post_highlight_styles,
tooltip_data=[{c: {"value": str(row[c]), "type": "markdown"} for c in post_table_cols}
for row in _posts_table_df.to_dict("records")],
tooltip_duration=None,
)),
], className="mb-3 shadow-sm"),
dbc.Row([
dbc.Col(section("Impressions by Content Format",
G(impressions_by_format_figure(posts), id="content-impressions-fig",
style={"height": "180px"}, margin=CONTENT_BAR_MARGIN),
info=("content-impressions", IMPRESSIONS_EXPLAINER),
title_style={"fontSize": "0.72rem"},
), md=3),
dbc.Col(section("Avg. Engagement Rate by Content Format",
G(engagement_by_format_figure(posts), id="content-engagement-fig",
style={"height": "180px"}, margin=CONTENT_BAR_MARGIN),
info=("content-engagement", CONTENT_FORMAT_ENGAGEMENT_EXPLAINER),
title_style={"fontSize": "0.72rem"},
), md=3),
dbc.Col(section("Avg. CTR by Content Format",
G(ctr_by_format_figure(posts), id="content-ctr-fig",
style={"height": "180px"}, margin=CONTENT_BAR_MARGIN),
info=("content-ctr", CTR_EXPLAINER),
title_style={"fontSize": "0.72rem"},
), md=3),
dbc.Col(section("Impressions vs Engagement Rate",
G(impressions_vs_engagement_figure(posts), id="content-scatter-fig",
style={"height": "180px"}, legend=CONTENT_SCATTER_LEGEND),
info=("impressions-vs-engagement", IMPRESSIONS_VS_ENGAGEMENT_EXPLAINER),
title_style={"fontSize": "0.72rem"},
), md=3),
], className="gx-3 mb-3"),
dbc.Row([
dbc.Col(section("Daily Reactions, Comments & Reposts",
G(reactions_figure(D["cts"]), id="content-reactions-fig", style={"height": "180px"})
), md=6),
dbc.Col(section("Daily Clicks",
G(clicks_figure(D["cts"]), id="content-clicks-fig", style={"height": "180px"})
), md=6),
], className="gx-3"),
], fluid=True, className="pb-3")
# ───── FOLLOWERS ──────────────────────────────────────────────────────────────
# key -> (label, dataframe sorted largest-first, category column, bar color)
# All breakdowns share one light-blue accent — the color was never meaningful data, just visual noise.
LIGHT_BLUE = "#4a90d9"
FOLLOWER_BREAKDOWN_MAP = {
"company_size": ("Company Size", D["fcs"].sort_values("Total followers", ascending=False), "Company size", LIGHT_BLUE),
"job_function": ("Job Function", D["fjob"].sort_values("Total followers", ascending=False), "Job function", LIGHT_BLUE),
"industry": ("Industry", D["find"], "Industry", LIGHT_BLUE),
"location": ("Location", D["floc"], "Location", LIGHT_BLUE),
"seniority": ("Seniority", D["fsen"].sort_values("Total followers", ascending=False), "Seniority", LIGHT_BLUE),
}
FOLLOWER_BREAKDOWNS = [(key, label) for key, (label, *_rest) in FOLLOWER_BREAKDOWN_MAP.items()]
_BREAKDOWN_SHEET_KEY = {"company_size": "fcs", "job_function": "fjob",
"industry": "find", "location": "floc", "seniority": "fsen"}
def _breakdown_figure(key, expanded, year=None, month=None):
label, df, col, color = FOLLOWER_BREAKDOWN_MAP[key]
if year is not None and month is not None:
hist = D["breakdown_history"]
available = sorted(k for k in hist if k <= (year, month))
if available:
df = hist[available[-1]][_BREAKDOWN_SHEET_KEY[key]]
total_n = len(df)
show_df = df if expanded else df.head(10)
fig = px.bar(show_df.sort_values("Total followers"), x="Total followers", y=col,
orientation="h", template=TMPL, color_discrete_sequence=[color])
fig.update_layout(showlegend=False)
return label, fig, total_n
# Months where a prior snapshot exists to diff against — the earliest snapshot in
# breakdown_history has nothing before it, so it can never be a delta option.
def _followers_delta_months():
return sorted(D["breakdown_history"].keys())[1:]
def _breakdown_delta_figure(key, year, month):
label, _df, col, _color = FOLLOWER_BREAKDOWN_MAP[key]
sheet_key = _BREAKDOWN_SHEET_KEY[key]
hist = D["breakdown_history"]
available = sorted(hist.keys())
idx = available.index((year, month))
prev_year, prev_month = available[idx - 1]
cur = hist[(year, month)][sheet_key][[col, "Total followers"]]
prev = hist[(prev_year, prev_month)][sheet_key][[col, "Total followers"]]
merged = cur.merge(prev, on=col, how="outer", suffixes=("_cur", "_prev")).fillna(0)
merged["Delta"] = merged["Total followers_cur"] - merged["Total followers_prev"]
merged = merged[merged["Delta"] != 0].copy()
merged["AbsDelta"] = merged["Delta"].abs()
changed_n = len(merged)
show_df = merged.sort_values("AbsDelta", ascending=False).head(_DELTA_MONTH_CAP)
overflow = changed_n - len(show_df)
show_df = show_df.sort_values("Delta")
fig = px.bar(show_df, x="Delta", y=col, orientation="h", template=TMPL,
labels={"Delta": "Change in Followers"})
fig.update_traces(marker_color=show_df["Delta"].apply(
lambda d: "#52C41A" if d > 0 else "#d43f3a"))
fig.update_layout(showlegend=False)
return label, fig, changed_n, overflow
# Months with real follower-count activity (from the daily fts time series) but no
# category breakdown to attribute it to — i.e. months before the first-ever demographic
# export existed to serve as a "prior" snapshot. Currently only June 2026: the campaign's
# baseline count starts Jun 16, but the first Followers export with a category breakdown
# wasn't taken until Jun 24, so the loss/gains between Jun 16-24 have no category history.
def _followers_count_only_months():
delta_months = set(_followers_delta_months())
fts_months = sorted(set(zip(D["fts"]["Date"].dt.year, D["fts"]["Date"].dt.month)))
earliest_breakdown = min(D["breakdown_history"].keys()) if D["breakdown_history"] else None
return [ym for ym in fts_months
if ym not in delta_months and (earliest_breakdown is None or ym <= earliest_breakdown)]
def _monthly_count_summary(year, month):
fts = D["fts"]
sub = fts[(fts["Date"].dt.year == year) & (fts["Date"].dt.month == month)]
net = int(sub["Total followers"].sum())
events = sub[sub["Total followers"] != 0]
gains = events[events["Total followers"] > 0]
losses = events[events["Total followers"] < 0]
def _fmt(rows):
return ", ".join(f"{d.strftime('%b %-d')}" for d in rows["Date"])
parts = []
if len(gains):
parts.append(f"gained {int(gains['Total followers'].sum())} ({_fmt(gains)})")
if len(losses):
parts.append(f"lost {int(-losses['Total followers'].sum())} ({_fmt(losses)})")
detail = "; ".join(parts) if parts else "no change"
sign = "+" if net >= 0 else ""
return net, sign, detail
# Approximate geographic centroid for every country — not just the ones that happen to
# appear in this week's export. This used to be a 14-entry dict covering only the countries
# present when the map was first built; any new country in a future export would silently
# vanish from the map (dropped by the dropna() below) with no visible sign anything was
# missing. Now covers essentially every UN member state plus commonly-seen LinkedIn
# location names, so a country is never invisible just because nobody thought to add it —
# see the fallback warning in followers_country_map_figure() for the rare remaining gap.
_COUNTRY_CENTROIDS = {
"Afghanistan": (33.9391, 67.7100), "Albania": (41.1533, 20.1683),
"Algeria": (28.0339, 1.6596), "Andorra": (42.5462, 1.6016),
"Angola": (-11.2027, 17.8739), "Argentina": (-38.4161, -63.6167),
"Armenia": (40.0691, 45.0382), "Australia": (-25.2744, 133.7751),
"Austria": (47.5162, 14.5501), "Azerbaijan": (40.1431, 47.5769),
"Bahamas": (25.0343, -77.3963), "Bahrain": (26.0667, 50.5577),
"Bangladesh": (23.6850, 90.3563), "Belarus": (53.7098, 27.9534),
"Belgium": (50.5039, 4.4699), "Belize": (17.1899, -88.4976),
"Benin": (9.3077, 2.3158), "Bhutan": (27.5142, 90.4336),
"Bolivia": (-16.2902, -63.5887), "Bosnia and Herzegovina": (43.9159, 17.6791),
"Botswana": (-22.3285, 24.6849), "Brazil": (-14.2350, -51.9253),
"Brunei": (4.5353, 114.7277), "Bulgaria": (42.7339, 25.4858),
"Burkina Faso": (12.2383, -1.5616), "Burundi": (-3.3731, 29.9189),
"Cambodia": (12.5657, 104.9910), "Cameroon": (7.3697, 12.3547),
"Canada": (56.1304, -106.3468), "Cape Verde": (16.5388, -23.0418),
"Central African Republic": (6.6111, 20.9394), "Chad": (15.4542, 18.7322),
"Chile": (-35.6751, -71.5430), "China": (35.8617, 104.1954),
"Colombia": (4.5709, -74.2973), "Comoros": (-11.6455, 43.3333),
"Costa Rica": (9.7489, -83.7534), "Croatia": (45.1000, 15.2000),
"Cuba": (21.5218, -77.7812), "Cyprus": (35.1264, 33.4299),
"Czechia": (49.8175, 15.4730), "Denmark": (56.2639, 9.5018),
"Djibouti": (11.8251, 42.5903), "Dominican Republic": (18.7357, -70.1627),
"DR Congo": (-4.0383, 21.7587), "Democratic Republic of the Congo": (-4.0383, 21.7587),
"Ecuador": (-1.8312, -78.1834), "Egypt": (26.8206, 30.8025),
"El Salvador": (13.7942, -88.8965), "Estonia": (58.5953, 25.0136),
"Eswatini": (-26.5225, 31.4659), "Ethiopia": (9.1450, 40.4897),
"Fiji": (-17.7134, 178.0650), "Finland": (61.9241, 25.7482),
"France": (46.6034, 1.8883), "Gabon": (-0.8037, 11.6094),
"Gambia": (13.4432, -15.3101), "Georgia": (42.3154, 43.3569),
"Germany": (51.1657, 10.4515), "Ghana": (7.9465, -1.0232),
"Greece": (39.0742, 21.8243), "Greenland": (71.7069, -42.6043),
"Guatemala": (15.7835, -90.2308), "Guinea": (9.9456, -9.6966),
"Guyana": (4.8604, -58.9302), "Haiti": (18.9712, -72.2852),
"Honduras": (15.2000, -86.2419), "Hong Kong": (22.3193, 114.1694),
"Hungary": (47.1625, 19.5033), "Iceland": (64.9631, -19.0208),
"India": (20.5937, 78.9629), "Indonesia": (-0.7893, 113.9213),
"Iran": (32.4279, 53.6880), "Iraq": (33.2232, 43.6793),
"Ireland": (53.4129, -8.2439), "Israel": (31.0461, 34.8516),
"Italy": (41.8719, 12.5674), "Ivory Coast": (7.5400, -5.5471),
"Jamaica": (18.1096, -77.2975), "Japan": (36.2048, 138.2529),
"Jordan": (30.5852, 36.2384), "Kazakhstan": (48.0196, 66.9237),
"Kenya": (-0.0236, 37.9062), "Kosovo": (42.6026, 20.9030),
"Kuwait": (29.3117, 47.4818), "Kyrgyzstan": (41.2044, 74.7661),
"Laos": (19.8563, 102.4955), "Latvia": (56.8796, 24.6032),
"Lebanon": (33.8547, 35.8623), "Lesotho": (-29.6100, 28.2336),
"Liberia": (6.4281, -9.4295), "Libya": (26.3351, 17.2283),
"Liechtenstein": (47.1660, 9.5554), "Lithuania": (55.1694, 23.8813),
"Luxembourg": (49.8153, 6.1296), "Madagascar": (-18.7669, 46.8691),
"Malawi": (-13.2543, 34.3015), "Malaysia": (4.2105, 101.9758),
"Maldives": (3.2028, 73.2207), "Mali": (17.5707, -3.9962),
"Malta": (35.9375, 14.3754), "Mauritania": (21.0079, -10.9408),
"Mauritius": (-20.3484, 57.5522), "Mexico": (23.6345, -102.5528),
"Moldova": (47.4116, 28.3699), "Monaco": (43.7384, 7.4246),
"Mongolia": (46.8625, 103.8467), "Montenegro": (42.7087, 19.3744),
"Morocco": (31.7917, -7.0926), "Mozambique": (-18.6657, 35.5296),
"Myanmar": (21.9162, 95.9560), "Namibia": (-22.9576, 18.4904),
"Nepal": (28.3949, 84.1240), "Netherlands": (52.1326, 5.2913),
"New Zealand": (-40.9006, 174.8860), "Nicaragua": (12.8654, -85.2072),
"Niger": (17.6078, 8.0817), "Nigeria": (9.0820, 8.6753),
"North Korea": (40.3399, 127.5101), "North Macedonia": (41.6086, 21.7453),
"Norway": (60.4720, 8.4689), "Oman": (21.4735, 55.9754),
"Pakistan": (30.3753, 69.3451), "Palestine": (31.9522, 35.2332),
"Panama": (8.5380, -80.7821), "Papua New Guinea": (-6.3150, 143.9555),
"Paraguay": (-23.4425, -58.4438), "Peru": (-9.1900, -75.0152),
"Philippines": (12.8797, 121.7740), "Poland": (51.9194, 19.1451),
"Portugal": (39.3999, -8.2245), "Puerto Rico": (18.2208, -66.5901),
"Qatar": (25.3548, 51.1839), "Romania": (45.9432, 24.9668),
"Russia": (61.5240, 105.3188), "Rwanda": (-1.9403, 29.8739),
"Saudi Arabia": (23.8859, 45.0792), "Senegal": (14.4974, -14.4524),
"Serbia": (44.0165, 21.0059), "Singapore": (1.3521, 103.8198),
"Slovakia": (48.6690, 19.6990), "Slovenia": (46.1512, 14.9955),
"Somalia": (5.1521, 46.1996), "South Africa": (-30.5595, 22.9375),
"South Korea": (35.9078, 127.7669), "South Sudan": (6.8770, 31.3070),
"Spain": (40.4637, -3.7492), "Sri Lanka": (7.8731, 80.7718),
"Sudan": (12.8628, 30.2176), "Suriname": (3.9193, -56.0278),
"Sweden": (60.1282, 18.6435), "Switzerland": (46.8182, 8.2275),
"Syria": (34.8021, 38.9968), "Taiwan": (23.6978, 120.9605),
"Tajikistan": (38.8610, 71.2761), "Tanzania": (-6.3690, 34.8888),
"Thailand": (15.8700, 100.9925), "Togo": (8.6195, 0.8248),
"Trinidad and Tobago": (10.6918, -61.2225), "Tunisia": (33.8869, 9.5375),
"Turkey": (38.9637, 35.2433), "Turkmenistan": (38.9697, 59.5563),
"Uganda": (1.3733, 32.2903), "Ukraine": (48.3794, 31.1656),
"United Arab Emirates": (23.4241, 53.8478), "United Kingdom": (55.3781, -3.4360),
"United States": (39.8283, -98.5795), "Uruguay": (-32.5228, -55.7658),
"Uzbekistan": (41.3775, 64.5853), "Venezuela": (6.4238, -66.5897),
"Vietnam": (14.0583, 108.2772), "Yemen": (15.5527, 48.5164),
"Zambia": (-13.1339, 27.8493), "Zimbabwe": (-19.0154, 29.1549),
}
# LinkedIn omits the country entirely for some well-known metro areas (e.g. "San
# Francisco Bay Area" instead of "San Francisco Bay Area, United States") — these have
# no comma to split on, so they need an explicit lookup instead of the split-on-comma rule.
_LOCATION_COUNTRY_OVERRIDES = {
"San Francisco Bay Area": "United States",
"Greater Madison Area": "United States",
"Los Angeles Metropolitan Area": "United States",
}
def _followers_by_country(floc_df):
"""Location -> Country rollup shared by the map and the country-count caption, so both
always agree. Deliberately computed before any centroid lookup, so the count reflects
every country actually present in the export — even one missing from _COUNTRY_CENTROIDS
(which would otherwise silently vanish from the map with no visible sign anything's off)."""
by_country = floc_df.copy()
by_country["Country"] = by_country["Location"].map(_LOCATION_COUNTRY_OVERRIDES).fillna(
by_country["Location"].str.split(",").str[-1].str.strip())
return by_country.groupby("Country", as_index=False)["Total followers"].sum()
def followers_country_count(floc_df):
return len(_followers_by_country(floc_df))
def followers_country_map_figure(floc_df):
"""One marker per country (not per city) showing follower count, on a plain
white-land / gray-water base map — no country borders, no choropleth shading."""
by_country = _followers_by_country(floc_df)
by_country["lat"] = by_country["Country"].map(lambda c: _COUNTRY_CENTROIDS.get(c, (None, None))[0])
by_country["lon"] = by_country["Country"].map(lambda c: _COUNTRY_CENTROIDS.get(c, (None, None))[1])
missing = by_country.loc[by_country["lat"].isna(), "Country"].tolist()
if missing:
# _COUNTRY_CENTROIDS now covers essentially every country, so this should be rare —
# if it ever fires, add the missing name(s) to that dict rather than ignoring this.
print(f"[followers_country_map_figure] WARNING: no centroid for {missing} — "
f"dropped from the map (but still counted in followers_country_count).")
by_country = by_country.dropna(subset=["lat", "lon"])
fig = go.Figure(go.Scattergeo(
lat=by_country["lat"], lon=by_country["lon"],
hovertext=by_country["Country"] + ": " + by_country["Total followers"].astype(str),
mode="markers",
marker=dict(size=14, color="#E53935", line=dict(width=1, color="white"), opacity=0.85),
hovertemplate="%{hovertext}<extra></extra>",
))
# Explicit lon/lat window + a linear (equirectangular) projection, sized to approximate
# this card's aspect ratio (md=4 column at 240px tall, narrower than the md=8 this was
# first tuned for) — fitbounds="locations" left large blank margins here, since our data's
# true shape (Canada-to-China, ~7.8:1) doesn't match a narrow box's shape, and Plotly pads
# rather than crops to preserve true geographic proportions. Widening the latitude window
# (vs. the data's actual ~27° spread) trades some empty sky above/below for less empty
# space left/right, which fills a narrower box better without cropping any country's
# marker. This is tuned to an estimated box width, not a measured one (no browser
# available to verify pixel dimensions here) — nudge further if it's still off.
# No `template=` here deliberately — applying plotly_white to a geo figure resets/breaks
# its geo styling (landcolor/oceancolor/projection all get overridden). We already control
# every visual aspect of this map explicitly via update_geos() above.
fig.update_geos(showland=True, landcolor="white", showocean=True, oceancolor="#d9d9d9",
showcountries=False, showcoastlines=False, showframe=False,
projection_type="equirectangular",
lonaxis_range=[-120, 118], lataxis_range=[0, 85])
return fig
_init_label, _init_fig, _init_total = _breakdown_figure("company_size", False)
FOLLOWERS_BY_EXPLAINER = html.Div([
html.Div([html.B("Total"), " shows the ", html.B("Top Ten of ALL"), " followers."],
style={"marginBottom": "6px"}),
html.Div([html.B("When choosing a month"), ", you see the followers gained or lost that "
"month, regardless of whether they show up in the Top Ten shown in Total."],
style={"marginBottom": "10px"}),
html.Hr(style={"margin": "10px 0"}),
html.Div("These are LinkedIn's standard demographic splits of who is following your page — the "
"point is to check whether your content is reaching the audience you actually care about, "
"not just growing follower count in the abstract.", style={"marginBottom": "10px"}),
html.Div([html.B("Company Size"), " and ", html.B("Industry"), " tell you what kind of "
"organizations are watching."], style={"marginBottom": "6px"}),
html.Div([html.B("Seniority"), " tells you whether you're reaching people who can actually "
"greenlight a deal — if most followers sit at Entry level or student tier, your content "
"is resonating with people who use the tools but can't approve budget. Growth in "
"Director/VP/Owner tiers is a stronger signal for business development than raw "
"follower count."], style={"color": "#6b7a8d"}),
])
FOLLOW_RATE_EXPLAINER = html.Div([
html.Div([html.B("Follow Rate = New Followers ÷ Impressions × 100"), ", per day."],
style={"marginBottom": "8px"}),
html.Div("This replaces Conversion Rate (New Followers ÷ Unique Visitors), which silently "
"drops any day with 0 tracked visitors — even if real followers were gained that day "
"(e.g. someone follows straight from a post in their feed, without ever visiting the "
"Page). Impressions are almost never zero on a day with any reach at all, so those "
"days no longer disappear.", style={"marginBottom": "10px"}),
html.Hr(style={"margin": "10px 0"}),
html.Div([html.B("Days flagged red"), " still had 0 tracked visitors ", html.B("and"),
" gained followers — the exact case the old chart used to hide. They're not "
"errors; they're the days this fix was built for."], style={"color": "#6b7a8d"}),
])
followers_tab = dbc.Container([
html.Div([
html.Div(kpi("Starting Followers", f"{BASELINE_FOLLOWERS:,}", f"as of {pd.Timestamp(BASELINE_DATE).strftime('%b %d, %Y')}", "#999"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Followers", f"{current_total_followers:,}", f"as of {current_total_followers_as_of}", TEAL), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("New Followers", f"{new_followers_30d:,}", date_range_label(D["fts"])), style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.5rem"}),
dbc.Row([
dbc.Col(section("Follower Gains — Calendar (★ = post published)",
followers_calendar(D["fts"], D["posts"], id_prefix="followers", height="300px")
), md=8),
dbc.Col(section("New Followers per Day (★ = post published)",
G(daily_followers_figure(D["fts"], D["posts"],
int(D["fts"]["Date"].dt.year.max()),
_default_active_month(D["fts"], D["posts"], int(D["fts"]["Date"].dt.year.max()))),
id="followers-daily-followers-graph", style={"height": "300px"})
), md=4),
], className="gx-3 mb-3"),
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader([
html.Strong("Followers by:"),
dcc.Dropdown(
id="fb-period-select",
options=[{"label": "Total", "value": "total"}] + [
{"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"}
for y, m in sorted(set(_followers_delta_months()) | set(_followers_count_only_months()))
],
value="total", clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "110px", "fontWeight": "700", "color": "#1a2533"},
),
dcc.Dropdown(
id="fb-select",
options=[{"label": label, "value": key} for key, label in FOLLOWER_BREAKDOWNS],
value="company_size", clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "150px", "fontWeight": "700", "color": "#1a2533"},
),
html.Button("Show all", id="fb-expand-btn", n_clicks=0,
style={"display": "inline-block" if _init_total > 10 else "none",
"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
"background": "none", "color": "white", "textDecoration": "underline",
"cursor": "pointer", "padding": "0"}),
info_popover("followers-by", FOLLOWERS_BY_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
html.Div(id="fb-month-label", style={"fontSize": "0.72rem", "color": "#6b7a8d",
"textAlign": "right", "marginBottom": "2px"}),
dbc.CardBody(G(_init_fig, style={"height": "240px"}), id="followers-breakdown-area"),
dcc.Store(id="fb-expanded", data=False),
dcc.Store(id="followers-selected-month", data={
"year": int(D["fts"]["Date"].dt.year.max()),
"month": _default_active_month(D["fts"], D["posts"], int(D["fts"]["Date"].dt.year.max())),
}),
], className="shadow-sm h-100"), md=4),
dbc.Col(dbc.Card([
dbc.CardHeader([
html.Strong("Follow Rate Relative to Reach"),
dcc.Dropdown(
id="frr-month-select",
options=_frr_month_options(D["frr"]),
value=_frr_default_period(D["frr"]),
clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "140px", "fontWeight": "700", "color": "#1a2533", "marginLeft": "auto"},
),
info_popover("follow-rate", FOLLOW_RATE_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
dbc.CardBody([
G(follow_rate_figure(D["frr"],
*([int(p) for p in _frr_default_period(D["frr"]).split("-")]
if _frr_default_period(D["frr"]) else [None, None])),
style={"height": "210px"}),
follow_rate_legend(),
], id="frr-area"),
], className="shadow-sm h-100"), md=4),
dbc.Col(section("Followers by Country",
html.Div(f"{followers_country_count(D['floc'])} countries represented",
style={"fontSize": "0.78rem", "color": "#6b7a8d", "marginBottom": "6px"}),
G(followers_country_map_figure(D["floc"]), style={"height": "240px"},
margin=dict(l=0, r=0, t=0, b=0)),
card_class="h-100",
), md=4),
], className="gx-3"),
], fluid=True, className="pb-3")
# ───── VISITORS ───────────────────────────────────────────────────────────────
CONVERSION_EXPLAINER = html.Div([
html.Div(f"{conv_total_followers} new followers / {conv_total_visitors} unique visitors, "
f"{date_range_label(D['conv'])}.", style={"marginBottom": "8px"}),
html.Div(
"What this measures: whether a day's visits correlate with a follow — each day's total unique "
"visitors against that day's new followers. It is not a true funnel. LinkedIn doesn't expose "
"which specific visitor became a follower, only daily totals for each, so treat this as a "
"directional signal rather than individual-level attribution.",
style={"marginBottom": "10px", "color": "#6b7a8d"},
),
html.Div([html.B("Amber dots"), " mark days with 0 tracked visitors that still had a real follower "
"change — e.g. someone follows straight from a post in their feed, without ever visiting "
"the Page. These days used to be silently dropped from this chart entirely (division by "
"zero); now they show as a flat 0% bar with a dot flagging that something real happened "
"even though no rate could be computed."],
style={"marginBottom": "10px", "color": "#6b7a8d"}),
html.Div([html.B("Why the bar is flat, not tall, on those days:"), " this chart divides by Unique "
"Visitors, and 0 visitors makes that a true division by zero — there is no valid "
"percentage to plot, not a small one. That's different from Follow Rate on the Followers "
"page, which divides by Impressions (almost never zero), so it always has a real number "
"to show. Here, the actual follower count for that day lives in the dot's hover text, "
"since there's no honest percentage that represents \"followers over 0 visitors.\""],
style={"color": "#6b7a8d"}),
])
_conv_rate_df = D["conv"].copy()
_conv_rate_df["Flagged"] = (_conv_rate_df["Unique Visitors"] == 0) & (_conv_rate_df["New Followers"] != 0)
_conv_rate_df["Conversion Rate"] = _conv_rate_df["Conversion Rate"].fillna(0)
def _conversion_rate_figure(df):
fig = px.bar(df, x="Date", y="Conversion Rate", template=TMPL,
color_discrete_sequence=[LIGHT_BLUE],
labels={"Conversion Rate": "Conversion Rate (%)"})
fig.data[0].name = "Conversion rate"
fig.update_layout(showlegend=False)
flagged = df[df["Flagged"]]
if len(flagged):
peak = df["Conversion Rate"].abs().max()
offset = peak * 0.12 if peak > 0 else 3
hover_text = [
f"{d.strftime('%b %-d, %Y')}: 0 tracked visitors, {'+' if nf > 0 else ''}{nf} followers that day"
for d, nf in zip(flagged["Date"], flagged["New Followers"])
]
fig.add_scatter(
x=flagged["Date"], y=[offset] * len(flagged), mode="markers",
marker=dict(color=AMBER, size=9, symbol="circle", line=dict(width=1, color="white")),
name="0 visitors, follower change", text=hover_text,
hovertemplate="%{text}<extra></extra>",
)
return fig
# key -> (label, category column) — the dataframe itself now comes from the running
# rollup (visitor_history.rollup_as_of), not a single-file snapshot.
VISITOR_BREAKDOWN_MAP = {
"seniority": ("Seniority", "Category"),
"company_size": ("Company Size", "Category"),
"location": ("Location", "Category"),
}
VISITOR_BREAKDOWNS = [(key, label) for key, (label, _col) in VISITOR_BREAKDOWN_MAP.items()]
def _visitor_breakdown_figure(key, expanded=False, year=None, month=None):
label, col = VISITOR_BREAKDOWN_MAP[key]
on_or_before = f"{year:04d}-{month:02d}-31" if year and month else None
df = visitor_history.rollup_as_of(D["visitor_category_history"], key, on_or_before)
total_n = len(df)
show_df = df if expanded else df.head(10)
fig = px.bar(show_df.sort_values("Total views"), x="Total views", y="Category",
orientation="h", template=TMPL, color_discrete_sequence=[LIGHT_BLUE],
labels={"Category": label})
fig.update_layout(showlegend=False)
return label, fig, total_n
def _visitor_category_months():
return visitor_history.available_months(D["visitor_category_history"])
_vb_init_label, _vb_init_fig, _vb_init_total = _visitor_breakdown_figure("seniority", False)
VISITOR_VS_FOLLOWER_EXPLAINER = html.Div([
html.Div([html.B("Visitor"), " = anyone who viewed the Page, for any reason — searched for "
"TXproducts, clicked a link from a post, a recruiter checking the company out. It's just "
"a page view, no relationship implied."], style={"marginBottom": "8px"}),
html.Div([html.B("Follower"), " = someone who explicitly clicked Follow, opting in to see future "
"posts in their feed — a persistent subscription, separate from visiting."],
style={"marginBottom": "8px"}),
html.Div("A visitor can browse without ever following, and a follower doesn't need to revisit the "
"Page to keep getting content in their feed — that's why these track fundamentally "
"different behavior.", style={"color": "#6b7a8d"}),
])
UNIQUE_VISITORS_EXPLAINER = html.Div([
html.Div("Counts each distinct person who visited the Page that day once — no matter how many times "
"they viewed it, or how many sections (Overview/Life/Jobs) they looked at.",
style={"marginBottom": "8px"}),
html.Div("This differs from Page Views, which counts every view/session — one person browsing "
"multiple sections in a single visit still counts as one unique visitor but several page "
"views.", style={"color": "#6b7a8d"}),
])
VISITOR_TERMINOLOGY_EXPLAINER = html.Div([
html.Div([html.B("Total"), " is a running total across every weekly export uploaded so "
"far — every category ever seen keeps its running count, even if it doesn't "
"appear in every single week's export."],
style={"marginBottom": "6px"}),
html.Div([html.B("Choosing a month"), " shows the running total as it stood at the end "
"of that month — July includes everything added through July, June only what "
"had accumulated by then."],
style={"marginBottom": "10px"}),
html.Div([html.B("Why some categories appear or vanish between weeks:"), " LinkedIn only "
"reports a category here if it clears a minimum-visits threshold that week — so "
"we generally only see the higher-traffic cities/categories, and a lower-traffic "
"one can appear one week and drop out the next simply by dipping below that "
"threshold, not because visits actually stopped. The running total exists so a "
"category doesn't lose its count just because a later week's export happened to "
"leave it out."],
style={"marginBottom": "10px", "color": "#6b7a8d"}),
html.Div([html.B("Also important:"), " unlike Followers, a Visitor isn't a standing "
"relationship someone can end — a visit already happened and can't be "
"\"un-visited.\" And since LinkedIn's per-export numbers are a rolling window "
"(not a fresh daily count), consecutive weekly exports likely overlap in which "
"visits they're counting — so treat this running total as a cumulative activity "
"signal, not a precise unique-visitor count."],
style={"marginBottom": "10px", "color": "#6b7a8d"}),
html.Hr(style={"margin": "10px 0"}),
html.Div([html.B("Unique Visitor"), " = a distinct person, counted once per day no matter how many "
"tabs they looked at or how many times they viewed the page."], style={"marginBottom": "8px"}),
html.Div([html.B("Page View"), " = every individual view — one person browsing 3 tabs generates 3 "
"page views but is still 1 unique visitor."], style={"marginBottom": "8px"}),
html.Div(["Plain ", html.B("\"Visitor\""), " (as in the section below) isn't a third, shallower "
"category — it's just shorthand. But it's worth knowing this specific breakdown "
"(Seniority/Company Size/Location) is built from LinkedIn's ",
html.B("page-view totals"), " by category, not deduplicated unique-visitor counts — "
"LinkedIn doesn't provide a demographic breakdown of unique people, only of views."],
style={"color": "#6b7a8d"}),
])
visitors_tab = dbc.Container([
dbc.Row([
dbc.Col(section("Daily Unique Visitors vs New Followers",
G(px.bar(D["conv"], x="Date", y=["Unique Visitors", "New Followers"],
barmode="group", template=TMPL, color_discrete_sequence=[BLUE, GREEN],
labels={"value": "Count", "variable": ""},
), style={"height": "300px"}),
info=("visitor-vs-follower", VISITOR_VS_FOLLOWER_EXPLAINER),
), md=6),
dbc.Col(dbc.Card([
dbc.CardHeader([
html.Strong("Daily Conversion Rate"),
html.Span(f"{overall_conversion_rate:.1f}%" if overall_conversion_rate is not None else "—",
style={"color": "#FFD600", "fontWeight": "800", "marginLeft": "8px"}),
info_popover("conversion-rate", CONVERSION_EXPLAINER),
]),
dbc.CardBody([
G(_conversion_rate_figure(_conv_rate_df), style={"height": "270px"}),
conversion_rate_legend(),
]),
], className="mb-3 shadow-sm"), md=6),
], className="gx-3 mb-3"),
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader([
html.Strong("Visitors by:"),
dcc.Dropdown(
id="vb-period-select",
options=[{"label": "Total", "value": "total"}] + [
{"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"}
for y, m in _visitor_category_months()
],
value="total", clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "110px", "fontWeight": "700", "color": "#1a2533"},
),
dcc.Dropdown(
id="vb-select",
options=[{"label": label, "value": key} for key, label in VISITOR_BREAKDOWNS],
value="seniority", clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "150px", "fontWeight": "700", "color": "#1a2533"},
),
html.Button("Show all", id="vb-expand-btn", n_clicks=0,
style={"display": "inline-block" if _vb_init_total > 10 else "none",
"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
"background": "none", "color": "white", "textDecoration": "underline",
"cursor": "pointer", "padding": "0"}),
info_popover("visitor-terminology", VISITOR_TERMINOLOGY_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
html.Div(id="vb-month-label", style={"fontSize": "0.72rem", "color": "#6b7a8d",
"textAlign": "right", "marginBottom": "2px"}),
dbc.CardBody(G(_vb_init_fig, style={"height": "240px"}), id="visitor-breakdown-area"),
dcc.Store(id="vb-expanded", data=False),
], className="shadow-sm h-100"), md=4),
dbc.Col(section("Page Views over Time",
G(px.line(D["vts"][["Date", "Overview page views (total)", "Jobs page views (total)"]]
.rename(columns={"Overview page views (total)": "Overview", "Jobs page views (total)": "Jobs"}),
x="Date", y=["Overview", "Jobs"],
template=TMPL, color_discrete_sequence=[BLUE, AMBER],
labels={"value": "Page Views", "variable": "Section"},
), style={"height": "240px"}),
card_class="h-100",
info=("page-views", PAGE_VIEWS_EXPLAINER),
), md=4),
dbc.Col(section("Unique Visitors — Desktop vs Mobile",
G(px.area(D["vts"][["Date", "Total unique visitors (desktop)", "Total unique visitors (mobile)"]]
.rename(columns={"Total unique visitors (desktop)": "Desktop",
"Total unique visitors (mobile)": "Mobile"}),
x="Date", y=["Desktop", "Mobile"],
template=TMPL, color_discrete_sequence=[BLUE, ORANGE],
labels={"value": "Unique Visitors", "variable": ""},
), style={"height": "240px"}),
info=("unique-visitors", UNIQUE_VISITORS_EXPLAINER),
card_class="h-100",
), md=4),
], className="gx-3"),
], fluid=True, className="pb-3")
# ───── COMPETITORS ────────────────────────────────────────────────────────────
# Styled after LinkedIn's own "Compare growth" table (Analytics > Competitors), with
# TXproducts pinned to the top row and competitors listed underneath. LinkedIn's native
# % badges compare each page to ITS OWN previous period — we don't have prior-period
# snapshots to diff against, so ours instead compares each page to the OTHER tracked
# pages' average for that same metric, this period. Same visual language, different math.
comp = D["comp"]
tracked_competitors = [p for p in comp["Page"] if p != "TXproducts"]
COMP_METRIC_COLS = ["New Followers", "Posts", "Comments", "Comments per day", "Reactions"]
comp_ordered = pd.concat([comp[comp["Page"] == "TXproducts"], comp[comp["Page"] != "TXproducts"]],
ignore_index=True)
COMPARE_GROWTH_EXPLAINER = html.Div([
html.Div([color_dot(GREEN), html.B("Green"), " = ahead of the other tracked pages' average for "
"that metric, this period."], style={"marginBottom": "3px"}),
html.Div([color_dot("#d43f3a"), html.B("Red"), " = behind that average."], style={"marginBottom": "10px"}),
html.Hr(style={"margin": "8px 0"}),
html.B("Not the same as LinkedIn's own \"Compare growth\" %"),
html.Div("LinkedIn's native comparison (shown in the LinkedIn Page Admin UI) is each page's change "
"from ITS OWN previous period. We can't replicate that here because our export only has "
"one time snapshot — there's no prior-period data to diff against unless we start saving "
"snapshots over time.", style={"marginTop": "6px", "color": "#6b7a8d"}),
])
def _comp_metric_cell(value, others_avg):
parts = [html.Div(f"{int(value):,}", style={"fontWeight": "700", "fontSize": "0.95rem", "color": "#1a2533"})]
if others_avg is not None:
if others_avg == 0:
if value > 0:
parts.append(html.Div("▲ ahead", style={"fontSize": "0.68rem", "color": GREEN, "fontWeight": "700"}))
else:
pct = (value - others_avg) / others_avg * 100
arrow, color = ("▲", GREEN) if pct >= 0 else ("▼", "#d43f3a")
parts.append(html.Div(f"{arrow} {abs(pct):.0f}%", style={"fontSize": "0.68rem", "color": color, "fontWeight": "700"}))
return html.Td(parts, style={"textAlign": "center", "padding": "12px 10px", "borderBottom": "1px solid #eef1f5"})
def _comp_company_cell(rank, name, is_self):
if is_self:
avatar = html.Img(src="/assets/txp_logo_vertical.png",
style={"width": "32px", "height": "32px", "objectFit": "contain"})
else:
avatar = html.Div(name[0].upper(), style={
"width": "32px", "height": "32px", "borderRadius": "50%", "backgroundColor": LIGHT_BLUE,
"color": "white", "display": "flex", "alignItems": "center", "justifyContent": "center",
"fontWeight": "800", "fontSize": "0.85rem", "flexShrink": "0",
})
name_row = [html.Span(name, style={"fontWeight": "700"})]
if is_self:
name_row.append(html.Span("You", style={
"marginLeft": "6px", "fontSize": "0.65rem", "fontWeight": "700", "color": "#6b7a8d",
"backgroundColor": "#eef1f5", "padding": "1px 7px", "borderRadius": "10px",
}))
info_children = [html.Div(name_row)]
if is_self:
info_children.append(html.Div(f"{current_total_followers:,} followers",
style={"fontSize": "0.72rem", "color": "#6b7a8d", "marginTop": "2px"}))
return html.Td(html.Div([
html.Span(str(rank), style={"fontWeight": "700", "color": "#6b7a8d", "marginRight": "12px", "fontSize": "0.85rem"}),
avatar,
html.Div(info_children, style={"marginLeft": "10px"}),
], style={"display": "flex", "alignItems": "center"}), style={"padding": "12px 10px", "borderBottom": "1px solid #eef1f5"})
def compare_growth_table():
header = html.Tr(
[html.Th("Competitors", style={"textAlign": "left", "padding": "8px 10px", "color": "#6b7a8d",
"fontSize": "0.78rem", "fontWeight": "700"})]
+ [html.Th(m, style={"textAlign": "center", "padding": "8px 10px", "color": "#6b7a8d",
"fontSize": "0.78rem", "fontWeight": "700"}) for m in COMP_METRIC_COLS]
)
rows = []
for i, row in comp_ordered.reset_index(drop=True).iterrows():
is_self = row["Page"] == "TXproducts"
others = comp_ordered[comp_ordered["Page"] != row["Page"]]
cells = [_comp_company_cell(i + 1, row["Page"], is_self)]
for m in COMP_METRIC_COLS:
others_avg = float(others[m].mean()) if not others.empty else None
cells.append(_comp_metric_cell(row[m], others_avg))
rows.append(html.Tr(cells))
return html.Table([html.Thead(header), html.Tbody(rows)], style={"width": "100%", "borderCollapse": "collapse"})
competitors_tab = dbc.Container([
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader([
html.Strong("Compare Growth"),
info_popover("compare-growth", COMPARE_GROWTH_EXPLAINER),
]),
dbc.CardBody(
[html.Div("Last 30 days", style={"fontSize": "0.78rem", "color": "#6b7a8d", "marginBottom": "10px"}),
compare_growth_table()]
if tracked_competitors else
[dbc.Alert("Only TXproducts data is currently tracked. Add competitor pages in LinkedIn "
"Page Admin under Analytics > Competitors to enable full benchmarking.", color="info")]
),
], className="mb-3 shadow-sm"), md=12),
]),
], fluid=True, className="pb-3")
growth_tab = build_growth_tab()
TAB_CONTENT_MAP = {
"overview": overview, "content": content_tab, "followers": followers_tab,
"visitors": visitors_tab, "competitors": competitors_tab, "growth": growth_tab,
"reports": reports_tab,
}
# ── Component builders ────────────────────────────────────────────────────────
def kpi(label, value, note="", color=BLUE, label_style=None, label_extra=None, value_style=None,
card_style=None, body_style=None):
"""label_style/value_style/card_style let a specific row (e.g. the Reports tab's
9-tile row, tighter and bolder than any other kpi() row in the app) override just
its own labels/values/card without affecting every other tab's tiles, which all use
the defaults. label_extra appends an extra component (e.g. an info_popover badge)
right after the label text. body_style overrides the CardBody's own padding."""
style = {"fontSize": "0.62rem", "textTransform": "uppercase", "letterSpacing": "0.05em"}
if label_style:
style.update(label_style)
val_style = {"color": color, "fontWeight": "800", "fontSize": "1.2rem"}
if value_style:
val_style.update(value_style)
outer_style = {"borderLeft": f"4px solid {color}"}
if card_style:
outer_style.update(card_style)
body = {"padding": "0.6rem 0.8rem"}
if body_style:
body.update(body_style)
label_children = [label, label_extra] if label_extra is not None else label
return dbc.Card(dbc.CardBody([
html.P(label_children, className="text-muted mb-1", style=style),
html.H3(value, className="mb-0", style=val_style),
html.P(note, className="text-muted mb-0", style={"fontSize": "0.62rem"}) if note else None,
], style=body), className="h-100 shadow-sm", style=outer_style)
def _fmt_best_eng(value):
""""Best Post Real Eng." is the max Real Eng % among audited posts only (see
compute_period_stats) — None whenever a period has zero audited posts, shown as
"Pending audit" rather than silently falling back to the raw, Team-inflated max."""
return f"{value:.1f}%" if value is not None else "Pending audit"
def color_dot(color):
"""Small inline color swatch used inside popover content instead of describing the color in words."""
return html.Span(style={"width": "10px", "height": "10px", "backgroundColor": color, "display": "inline-block",
"borderRadius": "2px", "marginRight": "5px", "verticalAlign": "middle"})
def info_popover(id_suffix, content):
"""A clickable (?) badge that reveals an explanation popover — used to annotate
metrics throughout the dashboard without cluttering the page with permanent text."""
return html.Span([
html.Span("?", id=f"info-{id_suffix}", style={
"display": "inline-flex", "alignItems": "center", "justifyContent": "center",
"width": "22px", "height": "22px", "borderRadius": "50%",
"backgroundColor": "#FFD600", "color": "#1a2533", "fontSize": "0.8rem",
"fontWeight": "800", "cursor": "pointer", "marginLeft": "8px", "verticalAlign": "middle",
"boxShadow": "0 1px 4px rgba(0,0,0,0.3)", "border": "2px solid white",
}),
dbc.Popover(
dbc.PopoverBody(content, style={"fontSize": "0.82rem", "maxWidth": "360px", "color": "#1a2533", "lineHeight": "1.55"}),
target=f"info-{id_suffix}", trigger="legacy", placement="right",
),
])
def section(title, *children, info=None, title_style=None, card_class=""):
title_span_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis",
"display": "inline-block", "maxWidth": "calc(100% - 32px)", "verticalAlign": "middle"}
if title_style:
title_span_style.update(title_style)
header_children = [html.Strong(title, style=title_span_style)]
if info:
info_id, info_text = info
header_children.append(info_popover(info_id, info_text))
return dbc.Card([
dbc.CardHeader(header_children),
dbc.CardBody(list(children)),
], className=f"mb-3 shadow-sm {card_class}".strip())
def _available_years(fts_df, posts_df):
"""All years with export data, most recent first — powers the calendar's year picker."""
years = set(fts_df["Date"].dt.year)
if not posts_df.empty:
years |= set(posts_df["Created date"].dropna().dt.year)
return sorted(years, reverse=True)
def _default_active_month(fts_df, posts_df, year):
"""The most recent month (within `year`) that has export data — used to decide which
month's posts to show underneath the calendar before any month box has been clicked."""
months = set(fts_df.loc[fts_df["Date"].dt.year == year, "Date"].dt.month)
if not posts_df.empty:
months |= set(posts_df.loc[posts_df["Created date"].dt.year == year, "Created date"].dt.month)
return max(months) if months else None
def _monthly_summary(fts_df, posts_df, year):
"""Net follower change and post count per month, within `year`."""
monthly_net = {}
for d, v in zip(fts_df["Date"].dt.date, fts_df["Total followers"]):
if d.year == year:
monthly_net[d.month] = monthly_net.get(d.month, 0) + int(v)
monthly_posts = {}
if not posts_df.empty:
for d in posts_df["Created date"].dropna().dt.date:
if d.year == year:
monthly_posts[d.month] = monthly_posts.get(d.month, 0) + 1
months_with_data = set(monthly_net) | set(monthly_posts)
max_gain = max((v for v in monthly_net.values() if v > 0), default=0)
return monthly_net, monthly_posts, months_with_data, max_gain
def _month_box_style(monthly_net, months_with_data, max_gain, month, selected):
if month not in months_with_data:
bg = "#f8f9fa"
else:
net = monthly_net.get(month, 0)
if net < 0:
bg = "#FFCDD2"
elif net == 0:
bg = "#f0f4f8"
else:
intensity = 0.15 + 0.55 * min(net / max(max_gain, 1), 1.0)
bg = f"rgba(82,196,26,{intensity:.2f})"
return {
"flex": "1", "minWidth": "0", "textAlign": "center",
"border": f"2px solid {BLUE}" if selected else "1px solid #e8e8e8",
"borderRadius": "6px", "padding": "8px 4px", "backgroundColor": bg,
"cursor": "pointer", "outline": "none", "fontFamily": "inherit", "color": "inherit",
}
def _month_box_children(monthly_net, monthly_posts, months_with_data, month):
import calendar as calmod
has_data = month in months_with_data
net = monthly_net.get(month)
post_count = monthly_posts.get(month, 0)
net_label = "—" if net is None else (f"+{net}" if net > 0 else str(net))
return [
html.Div(calmod.month_abbr[month], style={"fontWeight": "700", "fontSize": "13px"}),
html.Div(net_label, style={"fontWeight": "700", "fontSize": "16px", "marginTop": "4px",
"color": "#333" if has_data else "#bbb"}),
html.Div("followers", style={"fontSize": "9px", "color": "#888"}),
html.Div(f"{post_count} post{'s' if post_count != 1 else ''}",
style={"fontSize": "10px", "color": "#666", "marginTop": "3px"}),
]
def _month_day_tiles(fts_df, posts_df, year, month, include_zero_gain_posts=True):
"""One tile per active day in `month` — weekday/day, running total, delta, and a
star + content type for days a post went out. No post titles, just date + action.
include_zero_gain_posts=False drops days that only qualify because a post went out
with zero net follower change that day — used by the Follower Growth insights page,
which is scoped to growth days specifically, not the combined post+growth calendar
shown elsewhere (Overview, Followers)."""
import calendar as calmod
if month is None:
return html.Div("No activity yet.", className="text-muted", style={"padding": "8px 0", "fontSize": "0.8rem"})
month_fts = fts_df[(fts_df["Date"].dt.year == year) & (fts_df["Date"].dt.month == month)]
gains = {d: int(v) for d, v in zip(month_fts["Date"].dt.day, month_fts["Total followers"])}
totals = {d: int(v) for d, v in zip(month_fts["Date"].dt.day, month_fts["Cumulative Followers"])}
post_types_by_day = {}
if not posts_df.empty:
pmask = (posts_df["Created date"].dt.year == year) & (posts_df["Created date"].dt.month == month)
for d, ct in zip(posts_df.loc[pmask, "Created date"].dt.day, posts_df.loc[pmask, "Content Type"]):
post_types_by_day.setdefault(d, []).append(ct)
post_days = set(post_types_by_day)
active_days = {d for d, v in gains.items() if v != 0}
if include_zero_gain_posts:
active_days |= post_days
if not active_days:
return html.Div(f"No activity in {calmod.month_name[month]}.", className="text-muted",
style={"padding": "8px 0", "fontSize": "0.8rem"})
max_gain = max((v for v in gains.values() if v > 0), default=0)
def cell_color(v):
if v is None:
return "#f0f4f8"
if v < 0:
return "#FFCDD2"
intensity = 0.15 + 0.55 * min(v / max(max_gain, 1), 1.0)
return f"rgba(82,196,26,{intensity:.2f})"
tiles = []
for day in sorted(active_days):
v = gains.get(day)
total = totals.get(day)
starred = day in post_days
types = post_types_by_day.get(day, [])
type_label = ", ".join(sorted(set(types))) if types else None
weekday_abbr = calmod.day_abbr[calmod.weekday(year, month, day)]
if v is None:
delta_label = ""
elif v > 0:
delta_label = f"+{v}"
elif v < 0:
delta_label = str(v)
else:
delta_label = ""
tiles.append(html.Div([
html.Div(f"{weekday_abbr} {day}", style={"fontSize": "10px", "color": "#999"}),
html.Div(str(total) if total is not None else "—", style={"fontWeight": "700", "fontSize": "15px"}),
html.Div(delta_label, style={"fontSize": "10px", "color": "#666"}),
html.Div([
html.Span("★ ", style={"color": "#E53935"}),
html.Span(type_label, style={"color": FORMAT_COLORS.get(type_label, "#666")}),
], style={"fontSize": "9px", "fontWeight": "600", "marginTop": "2px"}) if starred else None,
], style={
"minWidth": "72px", "minHeight": "66px", "border": "1px solid #e8e8e8", "borderRadius": "4px",
"padding": "4px", "backgroundColor": cell_color(v), "position": "relative", "flex": "0 0 auto",
}))
return html.Div(tiles, style={"display": "flex", "gap": "4px", "flexWrap": "nowrap", "overflowX": "auto"})
def followers_calendar(fts_df, posts_df, id_prefix, height="320px"):
"""Whole-year overview: one clickable box per month (Jan-Dec), each showing net
follower change and post count. Clicking a month reveals its day tiles underneath.
A year dropdown sits above — only one year exists today, but this keeps the
calendar ready for whenever a second year of export data shows up."""
if fts_df.empty:
return html.Div("No follower data.", className="text-muted")
available_years = _available_years(fts_df, posts_df)
year = available_years[0]
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(fts_df, posts_df, year)
default_month = _default_active_month(fts_df, posts_df, year)
boxes = []
for m in range(1, 13):
boxes.append(html.Button(
_month_box_children(monthly_net, monthly_posts, months_with_data, m),
id=f"{id_prefix}-month-btn-{m}", n_clicks=0,
style=_month_box_style(monthly_net, months_with_data, max_gain, m, selected=(m == default_month))))
return html.Div([
dcc.Dropdown(
id=f"{id_prefix}-year-select",
options=[{"label": str(y), "value": y} for y in available_years],
value=year, clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "90px", "marginBottom": "4px", "fontWeight": "800", "fontSize": "0.85rem"},
),
html.Div(boxes, style={"display": "flex", "gap": "6px"}),
html.Div(_month_day_tiles(fts_df, posts_df, year, default_month), id=f"{id_prefix}-posts-row",
style={"flex": "1", "overflowY": "auto", "marginTop": "8px"}),
calendar_legend(),
], style={"height": height, "display": "flex", "flexDirection": "column"})
def daily_followers_figure(fts_df, posts_df, year=None, month=None):
"""The 'New Followers per Day' bar chart, optionally scoped to one calendar month
so it can track whichever month box is selected in the calendar next to it."""
df, pdf = fts_df, posts_df
if year is not None:
mask = df["Date"].dt.year == year
if month is not None:
mask &= df["Date"].dt.month == month
df = df[mask]
if not pdf.empty:
pmask = pdf["Created date"].dt.year == year
if month is not None:
pmask &= pdf["Created date"].dt.month == month
pdf = pdf[pmask]
fig = px.bar(df, x="Date", y=["Organic followers", "Auto-invited followers"],
template=TMPL, barmode="stack",
color_discrete_sequence=[BLUE, AMBER],
labels={"value": "Followers", "variable": "", "Date": ""})
return _std(mark_posts(fig, df, pdf))
def _frr_month_options(frr_df):
"""Every (year, month) with real Follow Rate data, newest first — drives the month
dropdown on the 'Follow Rate Relative to Reach' chart."""
months = sorted(set(zip(frr_df["Date"].dt.year, frr_df["Date"].dt.month)), reverse=True)
return [{"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"} for y, m in months]
def _frr_default_period(frr_df):
"""Whichever available month has the most days of data, not just the newest — a brand-new
month can have as little as a single day of data right after it starts, which would
otherwise default the chart open to a near-empty, broken-looking single bar."""
counts = frr_df.groupby([frr_df["Date"].dt.year, frr_df["Date"].dt.month]).size()
if counts.empty:
return None
y, m = counts.idxmax()
return f"{y}-{m}"
def follow_rate_figure(frr_df, year=None, month=None):
"""Daily Follow Rate (New Followers / Impressions * 100) — every bar is the real value in
blue; a red dot floats above any day with 0 tracked visitors that still gained followers,
the case Conversion Rate used to silently drop."""
df = frr_df.dropna(subset=["Follow Rate"]).copy()
if year is not None and month is not None:
df = df[(df["Date"].dt.year == year) & (df["Date"].dt.month == month)]
fig = px.bar(df, x="Date", y="Follow Rate", template=TMPL,
color_discrete_sequence=[LIGHT_BLUE],
labels={"Follow Rate": "Follow Rate (%)"})
fig.data[0].name = "Follow rate"
flagged = df[df["No Visitor Gain"]]
if len(flagged):
peak = df["Follow Rate"].max()
offset = peak * 0.1 if peak > 0 else 0.1
fig.add_scatter(
x=flagged["Date"], y=flagged["Follow Rate"] + offset, mode="markers",
marker=dict(color="#d43f3a", size=9, symbol="circle", line=dict(width=1, color="white")),
name="0 visitors, gained followers", hoverinfo="skip",
)
fig.update_layout(showlegend=False)
return fig
def _legend_swatch(color, label, shape="square"):
swatch_style = {"width": "12px", "height": "12px", "backgroundColor": color, "display": "inline-block",
"marginRight": "5px", "verticalAlign": "middle"}
if shape == "square":
swatch_style["borderRadius"] = "2px"
elif shape == "circle":
swatch_style["borderRadius"] = "50%"
return html.Span([
html.Span(style=swatch_style) if shape != "text" else html.Span(color, style={"marginRight": "5px", "color": color, "fontWeight": "700"}),
html.Span(label, style={"fontSize": "0.72rem", "color": "#6b7a8d"}),
], style={"marginRight": "16px", "whiteSpace": "nowrap"})
def calendar_legend():
return html.Div([
_legend_swatch("rgba(82,196,26,0.7)", "Follower gain"),
_legend_swatch("#FFCDD2", "Follower loss"),
], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
def follow_rate_legend():
return html.Div([
_legend_swatch(LIGHT_BLUE, "Follow rate (%)"),
_legend_swatch("#d43f3a", "0 visitors, gained followers", shape="circle"),
], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
def conversion_rate_legend():
return html.Div([
_legend_swatch(LIGHT_BLUE, "Conversion rate (%)"),
_legend_swatch(AMBER, "0 visitors, follower change", shape="circle"),
], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
def delta_legend():
return html.Div([
_legend_swatch("#52C41A", "Gained"),
_legend_swatch("#d43f3a", "Lost"),
], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
def visitor_delta_legend():
# "Increased/Decreased" rather than "Gained/Lost" — a visit already happened and can't be
# taken back, so this can't be framed as people joining/leaving the way Followers can.
return html.Div([
_legend_swatch("#52C41A", "Increased"),
_legend_swatch("#d43f3a", "Decreased"),
], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
# ── Overview metric explainers ─────────────────────────────────────────────────
SPONSORED_NOTE = html.Div("Sponsored covers paid/boosted promotion — currently flat at 0 since no ads are "
"running, but will populate automatically once they start.",
style={"marginTop": "8px", "fontStyle": "italic", "color": "#8a97a8"})
IMPRESSIONS_EXPLAINER = html.Div([
html.Div("How many times your posts were displayed in someone's feed — whether or not "
"they engaged with it.", style={"marginBottom": "8px"}),
html.Div("Reflects reach and algorithmic distribution more than content quality. A high count "
"doesn't guarantee engagement.", style={"color": "#6b7a8d"}),
SPONSORED_NOTE,
])
ENGAGEMENT_EXPLAINER = html.Div([
html.Div("The percentage of impressions that resulted in a like, comment, repost, or click.",
style={"marginBottom": "8px"}),
html.Div("This is the metric LinkedIn's algorithm weighs most heavily in the first hour after a "
"post goes out, when deciding how far to keep distributing it.", style={"color": "#6b7a8d"}),
SPONSORED_NOTE,
])
CTR_EXPLAINER = html.Div([
html.Div("The percentage of impressions that led to an off-platform click.", style={"marginBottom": "8px"}),
html.Div("Matters most when the goal is driving traffic to a website rather than pure reach. LinkedIn "
"doesn't reward outbound clicks the way it rewards on-platform engagement, so a high-CTR day "
"can still mean lower reach.", style={"color": "#6b7a8d"}),
SPONSORED_NOTE,
])
PAGE_VIEWS_EXPLAINER = html.Div([
html.Div("This counts visits to our TXproducts Company Page specifically, not general LinkedIn "
"browsing and not posts showing up in someone's feed. Split between desktop and mobile.",
style={"marginBottom": "8px"}),
html.Div("Counts page views regardless of whether the visitor is logged in or already follows you.",
style={"color": "#6b7a8d"}),
])
def _filtered_by_month(df, year, month, date_col="Date"):
mask = df[date_col].dt.year == year
if month is not None:
mask &= df[date_col].dt.month == month
return df[mask]
def impressions_figure(cts_df):
renamed = cts_df[["Date", "Impressions (organic)", "Impressions (sponsored)"]].rename(
columns={"Impressions (organic)": "Organic", "Impressions (sponsored)": "Sponsored"})
return _std(px.line(renamed, x="Date", y=["Organic", "Sponsored"],
template=TMPL, color_discrete_sequence=[BLUE, AMBER],
labels={"value": "Impressions", "variable": ""},
))
def engagement_figure(cts_df):
renamed = cts_df[["Date", "Engagement rate (organic)", "Engagement rate (sponsored)"]].rename(
columns={"Engagement rate (organic)": "Organic", "Engagement rate (sponsored)": "Sponsored"})
return _std(px.line(renamed, x="Date", y=["Organic", "Sponsored"],
template=TMPL, color_discrete_sequence=[GREEN, AMBER],
labels={"value": "Eng. Rate", "variable": ""},
))
def ctr_figure(cts_df):
renamed = cts_df[["Date", "CTR (organic) %", "CTR (sponsored) %"]].rename(
columns={"CTR (organic) %": "Organic", "CTR (sponsored) %": "Sponsored"})
return _std(px.line(renamed, x="Date", y=["Organic", "Sponsored"],
template=TMPL, color_discrete_sequence=[ORANGE, AMBER],
labels={"value": "CTR (%)", "variable": ""},
))
def page_views_figure(vts_df):
renamed = vts_df[["Date", "Total page views (desktop)", "Total page views (mobile)"]].rename(
columns={"Total page views (desktop)": "Desktop", "Total page views (mobile)": "Mobile"})
return _std(px.area(renamed, x="Date", y=["Desktop", "Mobile"],
template=TMPL, color_discrete_sequence=[BLUE, ORANGE],
labels={"value": "Views", "variable": ""},
))
# ── Build tabs ────────────────────────────────────────────────────────────────
# ───── OVERVIEW ───────────────────────────────────────────────────────────────
def _growth_status_label(snapshot):
if snapshot["on_track"] is None:
return "Building History"
return "On Track" if snapshot["on_track"] else "Behind"
def _growth_status_color(snapshot):
if snapshot["on_track"] is None:
return "#6b7a8d"
return GREEN if snapshot["on_track"] else "#d43f3a"
def _last_week_comparison(history):
"""(last_week_entry, prior_week_entry, delta_or_None) for the sidebar's week-over-week
comparison. delta is last week's rate minus the prior week's; None until there are two
full completed weeks to compare."""
last_week, prior_week = growth.last_two_completed_weeks(history)
delta = None
if last_week is not None and prior_week is not None:
delta = round(last_week["week_to_date_pct"] - prior_week["week_to_date_pct"], 2)
return last_week, prior_week, delta
def _sidebar_last_week_text(history):
last_week, _, _ = _last_week_comparison(history)
return f"{last_week['week_to_date_pct']:.2f}%" if last_week is not None else "—"
def _sidebar_delta_text(history):
_, _, delta = _last_week_comparison(history)
if delta is None:
return ""
arrow = "▲" if delta >= 0 else "▼"
return f"{arrow} {abs(delta):.2f}pts"
def _sidebar_delta_style(history):
_, _, delta = _last_week_comparison(history)
color = "#5a6b7d" if delta is None else (GREEN if delta >= 0 else "#d43f3a")
return {"fontSize": "0.75rem", "fontWeight": "700", "color": color}
def _sidebar_note_text(history):
last_week, prior_week, _ = _last_week_comparison(history)
if last_week is None:
return "Building history"
if prior_week is None:
return "Building history, need one more completed week"
return f"(goal: {prior_week['week_to_date_pct']:.2f}%)"
GROWTH_OBJECTIVE_EXPLAINER = html.Div([
html.Div([
"YOUR TARGET: ", html.B(f"beat your best week from the last {growth.ROLLING_BEST_WEEKS} weeks."),
], style={"marginBottom": "8px"}),
html.Div(
f"Whatever your strongest week was in that last {growth.ROLLING_BEST_WEEKS}-week stretch is "
"the number to beat, so topping it becomes the new target, but a lucky one-off week doesn't "
f"haunt you forever, it stops counting once it drops out of the last {growth.ROLLING_BEST_WEEKS} "
"weeks. Either way, you're always racing your recent self, not an old record.",
style={"color": "#6b7a8d"}),
])
def _week_segments(history):
"""(start, end) date pairs for each real Monday-Sunday calendar week spanning the
history's date range — not a 7-day block anchored to whatever day-of-week the data
happens to start on (which produced Tuesday-Monday "weeks" here, since the tracked
history starts on a Tuesday). The first segment starts at the earliest available date
(which may be mid-week, so it's a short first week ending that Sunday); the final
segment is capped at the true last date even when shorter than 7 days, so the most
recent entry always gets its own segment rather than being absorbed into the previous
one."""
dates = pd.to_datetime([h["date"] for h in history])
start, end = dates.min(), dates.max()
segments = []
seg_start = start
while seg_start <= end:
week_start = seg_start - pd.Timedelta(days=seg_start.weekday())
week_end = week_start + pd.Timedelta(days=6)
seg_end = min(week_end, end)
segments.append((seg_start, seg_end))
seg_start = week_end + pd.Timedelta(days=1)
return segments
def _add_week_dividers(fig, history):
"""Solid gray vertical line between weeks, so the weekly growth-rate charts show where
one week ends and the next begins. Positioned at the midpoint between the last day of
one week and the first day of the next, not exactly on either day's own position, so
it reads as a boundary between days rather than passing through one."""
if not history:
return fig
for seg_start, _ in _week_segments(history)[1:]:
divider_x = seg_start - pd.Timedelta(hours=12)
fig.add_shape(type="line", xref="x", yref="paper", x0=divider_x, x1=divider_x, y0=0, y1=1,
line=dict(color="#888888", width=1))
return fig
def _week_range_ticks(history):
"""Tick label for each 7-day block, centered under that block and formatted as a
compact day range (e.g. 'Jun 16-22') instead of Plotly's default single-date ticks,
which don't line up with the week divider lines."""
tickvals, ticktext = [], []
for seg_start, seg_end in _week_segments(history):
midpoint = seg_start + (seg_end - seg_start) / 2
if seg_start.date() == seg_end.date():
label = seg_start.strftime("%b %d")
elif seg_start.month == seg_end.month:
label = f"{seg_start.strftime('%b')} {seg_start.day:02d}-{seg_end.day:02d}"
else:
label = f"{seg_start.strftime('%b %d')}-{seg_end.strftime('%b %d')}"
tickvals.append(midpoint)
ticktext.append(label)
return tickvals, ticktext
def growth_trend_figure(history):
df = pd.DataFrame(history)
df["date"] = pd.to_datetime(df["date"])
fig = px.line(df, x="date", y="total_followers", template=TMPL, markers=True)
fig.update_traces(line=dict(color=BLUE, width=2.5), marker=dict(color=BLUE, size=7),
hovertemplate="%{x|%b %d, %Y}<br>%{y:,} followers<extra></extra>")
tickvals, ticktext = _week_range_ticks(history)
fig.update_layout(xaxis_title=None, yaxis_title="Total followers",
xaxis=dict(tickmode="array", tickvals=tickvals, ticktext=ticktext))
return _add_week_dividers(fig, history)
def growth_pace_figure(history):
df = pd.DataFrame(history)
df["date"] = pd.to_datetime(df["date"])
bar_colors = ["#6b7a8d" if t is None else (GREEN if t else "#d43f3a") for t in df["on_track"]]
fig = go.Figure()
fig.add_trace(go.Bar(x=df["date"], y=df["week_to_date_pct"], name="Week-to-date growth",
marker_color=bar_colors,
hovertemplate="%{x|%b %d, %Y}<br>%{y:.2f}%<extra></extra>"))
fig.add_trace(go.Scatter(x=df["date"], y=df["rolling_best_weekly_pct"], name="Target (rolling best)",
mode="lines+markers", line=dict(color="#6b7a8d", dash="dash"),
hovertemplate="%{x|%b %d, %Y}<br>%{y:.2f}%<extra></extra>"))
tickvals, ticktext = _week_range_ticks(history)
fig.update_layout(template=TMPL, xaxis_title=None, yaxis_title="Weekly growth %",
xaxis=dict(tickmode="array", tickvals=tickvals, ticktext=ticktext))
return _add_week_dividers(fig, history)
def _daily_breakdown_table(history, seg_start, seg_end):
"""Day-by-day rows for one week's date range — the detail revealed when that week's
accordion item is expanded."""
header = html.Tr([html.Th(h, style={"textAlign": "left" if h == "Date" else "center",
"padding": "6px 8px", "color": "#6b7a8d",
"fontSize": "0.72rem", "fontWeight": "700"})
for h in ["Date", "Total Followers", "Trailing 7 Days", "Since Last Update", "Pace"]])
rows = []
for entry in reversed(history):
d = pd.Timestamp(entry["date"])
if not (seg_start <= d <= seg_end):
continue
weekly = f"{entry['weekly_pct']:+.2f}%" if entry["weekly_pct"] is not None else "—"
since_last = f"{entry['since_last_pct']:+.2f}%" if entry["since_last_pct"] is not None else "—"
badge = html.Span(_growth_status_label(entry), style={
"backgroundColor": _growth_status_color(entry), "color": "white", "fontSize": "0.64rem",
"fontWeight": "700", "padding": "1px 7px", "borderRadius": "10px",
})
rows.append(html.Tr([
html.Td(d.strftime("%b %d, %Y"), style={"padding": "6px 8px", "borderBottom": "1px solid #eef1f5"}),
html.Td(f"{entry['total_followers']:,}",
style={"padding": "6px 8px", "textAlign": "center", "borderBottom": "1px solid #eef1f5"}),
html.Td(weekly, style={"padding": "6px 8px", "textAlign": "center", "borderBottom": "1px solid #eef1f5"}),
html.Td(since_last, style={"padding": "6px 8px", "textAlign": "center", "borderBottom": "1px solid #eef1f5"}),
html.Td(badge, style={"padding": "6px 8px", "textAlign": "center", "borderBottom": "1px solid #eef1f5"}),
]))
return html.Table([html.Thead(header), html.Tbody(rows)],
style={"width": "100%", "borderCollapse": "collapse", "fontSize": "0.8rem"})
def growth_history_table(history):
"""Grouped by month, then by week — each week is a collapsed accordion item showing
its trailing-7-day summary; clicking it reveals that week's daily breakdown."""
entries_by_date = {e["date"]: e for e in history}
weeks = []
for seg_start, seg_end in _week_segments(history):
entry = entries_by_date.get(seg_end.strftime("%Y-%m-%d"))
if entry is None:
continue
label = (f"{seg_start.strftime('%b')} {seg_start.day:02d}-{seg_end.day:02d}"
if seg_start.month == seg_end.month
else f"{seg_start.strftime('%b %d')}-{seg_end.strftime('%b %d')}")
weeks.append((seg_end.strftime("%B %Y"), label, seg_start, seg_end, entry))
months = []
current_month, current_items = None, []
for month_label, week_label, seg_start, seg_end, entry in reversed(weeks):
if month_label != current_month:
if current_items:
months.append((current_month, current_items))
current_month, current_items = month_label, []
weekly = f"{entry['week_to_date_pct']:+.2f}%" if entry["week_to_date_pct"] is not None else "—"
title = f"{week_label} | {entry['total_followers']:,} followers | {weekly} | {_growth_status_label(entry)}"
current_items.append(dbc.AccordionItem(
_daily_breakdown_table(history, seg_start, seg_end),
title=title, item_id=f"week-{seg_end.strftime('%Y-%m-%d')}",
))
if current_items:
months.append((current_month, current_items))
month_items = [
dbc.AccordionItem(
dbc.Accordion(items, start_collapsed=True, flush=True),
title=month_label, item_id=f"month-{month_label}",
)
for month_label, items in months
]
return dbc.Accordion(month_items, start_collapsed=True, flush=True)
def build_growth_tab():
history = growth.load_history(UPLOAD_DIR)
latest = history[-1]
return dbc.Container([
html.Div([
html.Div(kpi("Starting Followers", f"{BASELINE_FOLLOWERS:,}",
f"as of {pd.Timestamp(BASELINE_DATE).strftime('%b %d, %Y')}", "#999"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Followers", f"{latest['total_followers']:,}",
f"as of {pd.Timestamp(latest['date']).strftime('%b %d, %Y')}"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("This Week", f"{latest['week_to_date_pct']:+.2f}%" if latest["week_to_date_pct"] is not None else "—",
"growth so far this week", color=GREEN if (latest["week_to_date_pct"] or 0) >= 0 else "#d43f3a"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Since Last Update", f"{latest['since_last_pct']:+.2f}%" if latest["since_last_pct"] is not None else "—",
"vs previous recorded snapshot",
color=GREEN if (latest["since_last_pct"] or 0) >= 0 else "#d43f3a"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Rolling Best", f"{latest['rolling_best_weekly_pct']:+.2f}%" if latest["rolling_best_weekly_pct"] is not None else "—",
_growth_status_label(latest), color=_growth_status_color(latest)),
style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"}),
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader("Total Followers Over Time"),
dbc.CardBody(G(growth_trend_figure(history), style={"height": "260px"})),
], className="mb-3 shadow-sm"), md=6),
dbc.Col(dbc.Card([
dbc.CardHeader([html.Strong("Rolling Best"),
info_popover("growth-pace", GROWTH_OBJECTIVE_EXPLAINER)]),
dbc.CardBody(G(growth_pace_figure(history), style={"height": "260px"})),
], className="mb-3 shadow-sm"), md=6),
]),
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader("History Log"),
dbc.CardBody(growth_history_table(history), id="growth-history-table-body"),
], className="mb-3 shadow-sm"), md=12),
]),
], fluid=True, className="pb-3")
# Fixed slots for the whole 3-month campaign, not derived from monthly_report_notes.NOTES
# — August/September are selectable now even though empty (no notes/data yet), so the
# dropdown structure is ready ahead of time rather than appearing only once written up.
# "campaign-end" is a distinct, non-month value (handled separately in _render_reports_tab
# and the PDF builder, not run through the normal per-month stats path) since it gets its
# own design once the campaign wraps, not just another empty month.
_REPORTS_PERIODS = [
("2026-07", "July 2026"),
("2026-08", "August 2026"),
("2026-09", "September 2026"),
("campaign-end", "End of Campaign"),
]
_REPORTS_MONTH_OPTIONS = [{"label": label, "value": key} for key, label in _REPORTS_PERIODS]
_REPORTS_DEFAULT_MONTH = "2026-07"
reports_tab = dbc.Container([
html.Div([
html.Div([
html.Label("Reporting Month", style={"fontSize": "0.75rem", "fontWeight": "600",
"marginBottom": "4px", "display": "block"}),
dcc.Dropdown(
id="reports-month-select",
options=_REPORTS_MONTH_OPTIONS, value=_REPORTS_DEFAULT_MONTH,
clearable=False, searchable=False, style={"width": "260px"},
),
]),
html.Div(id="reports-title", style={"flex": "1", "textAlign": "center", "padding": "0 1rem"}),
html.Div([
html.Button("Download Report (.docx)", id="reports-download-btn", n_clicks=0,
style={"padding": "8px 16px",
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
"color": "white", "border": "none", "borderRadius": "8px",
"fontSize": "0.8rem", "fontWeight": "700", "cursor": "pointer"}),
dcc.Download(id="reports-download"),
]),
], style={"display": "flex", "justifyContent": "space-between", "alignItems": "center",
"marginBottom": "1.25rem"}),
html.Div(id="reports-content-area"),
], fluid=True, className="pb-3")
refresh_data()
# ── App layout ────────────────────────────────────────────────────────────────
app = Dash(
__name__,
external_stylesheets=[dbc.themes.FLATLY,
"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css"],
title="TXproducts LinkedIn Analytics",
)
NAV_ITEMS = [
("overview", "Overview", overview),
("content", "Content", content_tab),
("followers", "Followers", followers_tab),
("visitors", "Visitors", visitors_tab),
("competitors", "Competitors", competitors_tab),
("growth", "Growth", growth_tab),
("reports", "Reports", reports_tab),
]
TAB_CONTENT_MAP = {key: content for key, _, content in NAV_ITEMS}
TAB_HERO = {
"overview": ("Overview", "Follower growth, content performance, and visitor traffic for the TXproducts LinkedIn Page."),
"content": ("Content", "Every post's impressions, clicks, engagement rate, and CTR — sortable and searchable."),
"followers": ("Followers", "Daily follower gains and losses, with the exact running total for every active day."),
"visitors": ("Visitors", "Page views and unique visitors to the TXproducts LinkedIn Page, broken down by device and section."),
"competitors": ("Competitors", "Benchmarking TXproducts against tracked competitor pages on LinkedIn."),
"growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."),
"reports": ("Reports", "Monthly progress reports for LinkedIn, combining the dashboard data with the Follower Directory."),
"roadmap-insights": ("Campaign Roadmap — Insights", ""),
"all-posts-insights": ("All Posts — Insights", "The Content tab's own post list and format/activity charts, mirrored here for the selected month."),
"follower-growth-insights": ("Follower Growth — Insights", "The Overview and Growth tabs' own follower charts, mirrored here for the selected month."),
"core-message-insights": ("Core Message — Insights", ""),
"audience-composition-insights": ("Audience Composition — Insights", "This month's new followers by seniority and country."),
"monthly-notes-insights": ("Monthly Notes — Insights", "Features/fixes shipped, data quality issues found and fixed, Follower Study/Directory updates, and outstanding items for the selected month."),
"follower-study-insights": ("Follower Study — Insights", "Notable C-Level/Founder and Director-level contacts across the whole Follower Directory, with a link through to the full narrative Study."),
}
GROWTH_METRIC_EXPLAINER = html.Div([
html.Div(html.B("Growth here is exclusively the cumulative followers."), style={"marginBottom": "8px"}),
html.Div("It's a single, narrow metric: are we gaining followers faster or slower than our own best "
"pace so far.", style={"color": "#6b7a8d"}),
])
TAB_HERO_INFO = {"growth": GROWTH_METRIC_EXPLAINER}
def _hero_title_children(active):
"""Reports folds its subtitle onto the same line as the title ("Reports — Monthly
progress reports...") instead of a separate line below, to give the tab's own
content (already the most vertically dense tab) a bit more viewing space."""
title, subtitle = TAB_HERO[active]
if active != "reports":
return title
return [title, html.Span(f" — {subtitle}", style={"fontWeight": "500", "fontSize": "0.85rem"})]
def _hero_subtitle_children(active):
if active == "reports":
return ""
text = TAB_HERO[active][1]
info = TAB_HERO_INFO.get(active)
if info is None:
return text
return [text, info_popover(f"hero-{active}", info)]
_REPORT_HERO_LINK_STYLE = {
"color": "white", "fontSize": "0.72rem", "fontWeight": "600",
"textDecoration": "none", "padding": "6px 10px",
"backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px",
"border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap",
}
# The selected Reporting Month, shown ahead of "Report Overview" in every insights
# page's hero nav — without this, only Core Message narrated which month's data was
# on screen, and every fallback ("Pending audit" etc.) looks identical regardless of
# month, so there was no way to tell at a glance. Sized to stay within the hero row's
# existing height (set by the title+subtitle stack on the left), not enlarge it — the
# 6-link nav row plus this badge is wide enough at 1440px that _REPORT_HERO_LINK_STYLE
# was tightened (padding/font) alongside this addition just to make room.
_MONTH_BADGE_STYLE = {
"color": "#1b3a5c", "fontSize": "0.82rem", "fontWeight": "800",
"textTransform": "uppercase", "letterSpacing": "0.02em",
"padding": "6px 10px", "backgroundColor": "#fdf6d8", "borderRadius": "6px",
"whiteSpace": "nowrap",
}
# The 7 named sections of the Reports tab, each with its own distinct page, in the
# order they should appear in every insights page's hero nav.
_REPORT_SECTIONS = [
("Core Message", "core-message-insights", "/core-message-insights", False),
("Roadmap", "roadmap-insights", "/roadmap-insights", False),
("All Posts", "all-posts-insights", "/all-posts-insights", False),
("Follower Growth", "follower-growth-insights", "/follower-growth-insights", False),
("Audience Composition", "audience-composition-insights", "/audience-composition-insights", False),
("Follower Study", "follower-study-insights", "/follower-study-insights", False),
]
_REPORT_NAV_ACTIVE_KEYS = {key for _, key, _, _ in _REPORT_SECTIONS}
def _report_section_nav(active, month_value=None):
# The month badge comes first, ahead of even "Report Overview" — see
# _MONTH_BADGE_STYLE. "Report Overview" itself always comes next and is never
# excluded — it's the way back to the main Reports page, not one of the 7
# sections, so it doesn't participate in the shared-destination exclusion below.
links = []
if month_value:
links.append(html.Div(report._period_label(month_value), style=_MONTH_BADGE_STYLE))
links.append(dcc.Link("Report Overview", href="/reports", style=_REPORT_HERO_LINK_STYLE))
for label, page_key, href, external in _REPORT_SECTIONS:
if page_key == active:
continue
if external:
link = html.A(label, href=href, target="_blank", rel="noopener noreferrer",
style=_REPORT_HERO_LINK_STYLE)
else:
link = dcc.Link(label, href=href, style=_REPORT_HERO_LINK_STYLE)
links.append(link)
for link in links[1:]:
link.style = {**link.style, "marginLeft": "6px"}
return links
def _hero_actions(active, month_value=None):
"""Follower Directory / Follower Study links shown in the hero banner — only on the
Followers tab, not in the sidebar. Both are standalone HTML files regenerated from
manual_followers_list_2026-07-22.xlsx and copied into assets/, so Dash serves them
as-is at /assets/... ; re-copy them here after every follower data update."""
if active == "followers":
return [
html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank",
rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE),
html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
rel="noopener noreferrer", style={**_REPORT_HERO_LINK_STYLE, "marginLeft": "8px"}),
]
if active in _REPORT_NAV_ACTIVE_KEYS:
return _report_section_nav(active, month_value)
return []
_SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR)
# Sidebar — vertical logo, LinkedIn+Analytics brand mark, description, vertical nav, footer
sidebar = html.Div([
html.Img(src="/assets/txp_logo_vertical.png", style={"maxWidth": "200px", "width": "100%", "display": "block", "margin": "0 auto 12px"}),
html.Div([
html.Img(src="/assets/linkedin_logo.svg", style={"height": "30px", "marginBottom": "4px"}),
html.Span("Analytics", style={"fontSize": "1.5rem", "fontWeight": "800", "color": BLUE}),
], style={"display": "flex", "flexDirection": "column", "alignItems": "center", "margin": "4px 0 10px"}),
html.Hr(style={"borderColor": "#e0e7ef", "margin": "6px 0"}),
html.Div([
html.Div("Data Last Updated", style={
"fontSize": "0.68rem", "textTransform": "uppercase", "letterSpacing": "0.05em",
"textAlign": "center", "color": "rgba(255,255,255,0.85)",
}),
html.Div(D["last_updated"].strftime("%b %d, %Y"), id="sidebar-last-updated", style={
"fontSize": "1.05rem", "fontWeight": "800", "textAlign": "center", "color": "white",
}),
], style={
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
"borderRadius": "8px", "padding": "8px 10px", "marginBottom": "12px",
}),
html.Div([
html.Div("Growth", style={
"fontSize": "0.72rem", "fontWeight": "800", "textTransform": "uppercase",
"letterSpacing": "0.05em", "textAlign": "center", "color": "#1b3a5c", "marginBottom": "2px",
}),
html.Div("Last Week vs. Prior Week", style={
"fontSize": "0.68rem", "textTransform": "uppercase", "letterSpacing": "0.05em",
"textAlign": "center", "color": "#5a6b7d",
}),
html.Div([
html.Span(_sidebar_last_week_text(_SIDEBAR_GROWTH_HISTORY), id="sidebar-growth-lastweek",
style={"fontSize": "1.3rem", "fontWeight": "800", "color": "#1b3a5c"}),
html.Span(_sidebar_delta_text(_SIDEBAR_GROWTH_HISTORY), id="sidebar-growth-delta",
style=_sidebar_delta_style(_SIDEBAR_GROWTH_HISTORY)),
], style={"display": "flex", "alignItems": "baseline", "justifyContent": "center", "gap": "8px"}),
html.Div(_sidebar_note_text(_SIDEBAR_GROWTH_HISTORY), id="sidebar-growth-note", style={
"fontSize": "0.68rem", "color": "#5a6b7d", "textAlign": "center",
}),
], id="growth-indicator-btn", n_clicks=0, title="View full growth history", style={
"backgroundColor": "#e8f0fe",
"borderRadius": "8px", "padding": "8px 10px", "marginBottom": "12px", "cursor": "pointer",
}),
html.Div(
[html.Div([
html.Button(label, id=f"nav-{key}", n_clicks=0,
className="sidebar-nav-btn active" if key == "overview" else "sidebar-nav-btn"),
] + ([dcc.Link("Monthly Notes", href="/monthly-notes-insights", className="sidebar-nav-btn",
style={"display": "block", "textDecoration": "none",
"backgroundColor": "#e8f0fe", "borderRadius": "8px",
"marginTop": "2px"})]
if key == "reports" else []))
for key, label, _ in NAV_ITEMS],
style={"display": "flex", "flexDirection": "column", "gap": "2px"},
),
html.Hr(style={"borderColor": "#e0e7ef", "margin": "6px 0"}),
html.Div("TXproducts · Internal Tool · 2026", style={
"fontSize": "0.75rem", "color": "#aab", "marginTop": "auto",
}),
], style={
"width": "240px", "minWidth": "240px", "padding": "20px 16px",
"borderRight": "1px solid #e0e7ef", "backgroundColor": "#ffffff",
"display": "flex", "flexDirection": "column", "minHeight": "100vh",
})
# Gradient hero banner (matches ONTLEX/txdata's .page-hero) — title/subtitle change with the active tab.
# hero-actions is a right-aligned slot for per-tab link buttons (currently just the Followers
# tab's Directory/Study links) — empty on every other tab.
hero = html.Div([
html.Div([
html.Div(TAB_HERO["overview"][0], id="hero-title",
style={"color": "white", "fontSize": "1.45rem", "fontWeight": "800", "lineHeight": "1.1"}),
html.Div(TAB_HERO["overview"][1], id="hero-subtitle",
style={"color": "rgba(255,255,255,0.84)", "fontSize": "0.9rem", "fontWeight": "500", "marginTop": "4px"}),
], style={"flex": "1", "minWidth": "0"}),
html.Div(_hero_actions("overview"), id="hero-actions",
style={"display": "flex", "alignItems": "center", "flexShrink": "0", "marginLeft": "16px"}),
], style={
"background": "linear-gradient(90deg,#1b3a5c 0%,#2d6a9f 100%)",
"borderRadius": "8px", "padding": "14px 18px 13px", "marginTop": "4px", "marginBottom": "16px",
"display": "flex", "alignItems": "center", "justifyContent": "space-between",
})
main_content = html.Div([
hero,
html.Div(overview, id="tab-content-area"),
], style={"flex": "1", "minWidth": "0", "padding": "20px 24px"})
# A single, persistent dcc.Location (never remounted) drives the two insights-page
# links via pathname, instead of n_clicks on the link elements themselves — those get
# recreated every time the Reports tab re-renders, and Dash fires a dynamically-mounted
# component's callback once on its first appearance regardless of prevent_initial_call,
# which was hijacking the view back to an insights page immediately on every Reports
# render. pathname changes only on an actual click, so this doesn't have that problem.
# Shared across every Reports-family page (Report Overview + all 7 insights pages) so
# picking a month once, anywhere, stays consistent while navigating between them —
# without this, each insights page always showed July regardless of what was selected.
app.layout = html.Div([dcc.Location(id="url-router", refresh=False),
dcc.Store(id="reports-selected-month", data=_REPORTS_DEFAULT_MONTH),
sidebar, main_content],
style={"display": "flex", "alignItems": "stretch"})
@app.callback(
[Output(f"nav-{key}", "className") for key, _, _ in NAV_ITEMS]
+ [Output("tab-content-area", "children"), Output("hero-title", "children"), Output("hero-subtitle", "children"),
Output("hero-actions", "children"), Output("url-router", "pathname")],
[Input(f"nav-{key}", "n_clicks") for key, _, _ in NAV_ITEMS]
+ [Input("growth-indicator-btn", "n_clicks")],
)
def _switch_tab(*_):
triggered = ctx.triggered_id
if triggered == "growth-indicator-btn":
active = "growth"
else:
active = triggered.replace("nav-", "") if triggered else "overview"
classes = ["sidebar-nav-btn active" if key == active else "sidebar-nav-btn" for key, _, _ in NAV_ITEMS]
hero_title = _hero_title_children(active)
# Also resets url-router's pathname to "/" — without this, revisiting the SAME
# insights link twice in a row (e.g. Follower Growth then All Posts, both pointing
# at /roadmap-insights) does nothing the second time, since pathname never actually
# changed value in between (going back to Reports via the sidebar doesn't touch it).
#
# Note: this does NOT re-sync reports-month-select's own value to the shared store
# (unlike the insights pages' own content, which always reads the store fresh) —
# an Output targeting that dropdown fails Dash's layout validation here, since the
# dropdown doesn't exist yet the first time Reports is opened from another tab (the
# same callback response that would set its value is also the one creating it).
# Net effect: the dropdown can visually show July again after picking another month
# elsewhere and returning here, even though every insights page still correctly
# uses whatever month was actually last selected. Picking the month again here
# resyncs the display; it's a cosmetic gap, not a data-correctness one.
return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active),
_hero_actions(active), "/"]
_INSIGHTS_ROUTES = {href: key for _, key, href, _ in _REPORT_SECTIONS}
_INSIGHTS_ROUTES["/reports"] = "reports"
# Monthly Notes is personal accounting, not one of the 7 Reports-tab sections — reached
# only from its own sidebar link below, not from the Reports Overview grid or any
# insights page's cross-page nav (both driven by _REPORT_SECTIONS, which excludes it).
_INSIGHTS_ROUTES["/monthly-notes-insights"] = "monthly-notes-insights"
@app.callback(
[Output(f"nav-{key}", "className", allow_duplicate=True) for key, _, _ in NAV_ITEMS]
+ [Output("tab-content-area", "children", allow_duplicate=True),
Output("hero-title", "children", allow_duplicate=True),
Output("hero-subtitle", "children", allow_duplicate=True),
Output("hero-actions", "children", allow_duplicate=True)],
Input("url-router", "pathname"),
State("reports-selected-month", "data"),
prevent_initial_call=True,
)
def _switch_to_insights_page(pathname, selected_month):
"""Driven by dcc.Location's pathname rather than n_clicks on the link elements
themselves: those links live inside reports-content-area, which gets torn down and
recreated every time the Reports tab re-renders, and Dash fires a dynamically
re-mounted component's callback once on each fresh appearance regardless of
prevent_initial_call — that was hijacking the view back to an insights page on
every single Reports render. url-router is a single persistent component defined
once in app.layout, so its pathname only changes on an actual click.
Each of the 7 insights pages is rebuilt fresh here from _INSIGHTS_PAGE_BUILDERS,
passing the shared reports-selected-month store, instead of looking up static
content computed once at startup — that's what used to make every insights page
show July regardless of which month was picked on Report Overview. (The dropdown
itself isn't re-synced on return to "/reports" — an Output targeting it fails
Dash's layout validation the first time Reports is opened, since the dropdown
doesn't exist in the layout yet at that moment. Cosmetic gap only: every
insights page still correctly uses whichever month was actually last picked.)"""
active = _INSIGHTS_ROUTES.get(pathname)
if active is None:
raise PreventUpdate
month_value = selected_month or _REPORTS_DEFAULT_MONTH
builder = _INSIGHTS_PAGE_BUILDERS.get(active)
content = builder(month_value) if builder else TAB_CONTENT_MAP[active]
classes = ["sidebar-nav-btn" for _ in NAV_ITEMS]
hero_title = _hero_title_children(active)
return classes + [content, hero_title, _hero_subtitle_children(active), _hero_actions(active, month_value)]
@app.callback(
Output("fb-expanded", "data", allow_duplicate=True),
Input("fb-expand-btn", "n_clicks"),
State("fb-expanded", "data"),
prevent_initial_call=True,
)
def _toggle_followers_expand(_, expanded):
return not expanded
@app.callback(
Output("followers-breakdown-area", "children"),
Output("fb-expand-btn", "children"),
Output("fb-expand-btn", "style"),
Output("fb-month-label", "children"),
Input("fb-select", "value"),
Input("fb-period-select", "value"),
Input("fb-expanded", "data"),
Input("followers-selected-month", "data"),
)
def _render_followers_breakdown(active, period, expanded, selected_month):
btn_style_base = {"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
"background": "none", "color": "white", "textDecoration": "underline",
"cursor": "pointer", "padding": "0"}
if period != "total":
year, month = (int(p) for p in period.split("-"))
btn_style = {**btn_style_base, "display": "none"}
if (year, month) in set(_followers_count_only_months()):
net, sign, detail = _monthly_count_summary(year, month)
month_label = f"{_MONTH_NAMES[month - 1]} {year} — no category breakdown available"
note = html.Div([
html.Div(f"Net {sign}{net} follower{'s' if abs(net) != 1 else ''} in "
f"{_MONTH_NAMES[month - 1]} {year}: {detail}.",
style={"marginBottom": "8px", "fontWeight": "700", "color": "#1a2533"}),
html.Div("Category breakdown (location, industry, etc.) isn't available for these — "
"LinkedIn's demographic export wasn't captured yet at this point, so there's "
"no earlier snapshot to know which category they belong to.",
style={"color": "#6b7a8d", "fontSize": "0.8rem"}),
], style={"padding": "20px 4px"})
return note, "", btn_style, month_label
label, fig, changed_n, overflow = _breakdown_delta_figure(active, year, month)
month_label = f"{_MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
if overflow > 0:
month_label += f" · showing top {_DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
return [G(fig, style={"height": "240px"}), delta_legend()], "", btn_style, month_label
year = selected_month.get("year") if selected_month else None
month = selected_month.get("month") if selected_month else None
label, fig, total_n = _breakdown_figure(active, expanded, year, month)
needs_toggle = total_n > 10
btn_style = {**btn_style_base, "display": "inline-block" if needs_toggle else "none"}
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
month_label = f"as of {_MONTH_NAMES[month - 1]} {year}" if year and month else ""
return G(fig, style={"height": "240px"}), btn_label, btn_style, month_label
@app.callback(
Output("vb-expanded", "data", allow_duplicate=True),
Input("vb-expand-btn", "n_clicks"),
State("vb-expanded", "data"),
prevent_initial_call=True,
)
def _toggle_visitors_expand(_, expanded):
return not expanded
@app.callback(
Output("visitor-breakdown-area", "children"),
Output("vb-expand-btn", "children"),
Output("vb-expand-btn", "style"),
Output("vb-month-label", "children"),
Input("vb-select", "value"),
Input("vb-period-select", "value"),
Input("vb-expanded", "data"),
)
def _render_visitor_breakdown(active, period, expanded):
btn_style_base = {"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
"background": "none", "color": "white", "textDecoration": "underline",
"cursor": "pointer", "padding": "0"}
if period != "total":
year, month = (int(p) for p in period.split("-"))
label, fig, total_n = _visitor_breakdown_figure(active, expanded, year, month)
month_label = f"as of {_MONTH_NAMES[month - 1]} {year}"
else:
label, fig, total_n = _visitor_breakdown_figure(active, expanded)
month_label = ""
needs_toggle = total_n > 10
btn_style = {**btn_style_base, "display": "inline-block" if needs_toggle else "none"}
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
return G(fig, style={"height": "240px"}), btn_label, btn_style, month_label
@app.callback(
Output("frr-area", "children"),
Input("frr-month-select", "value"),
)
def _render_follow_rate(period):
year, month = (int(p) for p in period.split("-"))
fig = follow_rate_figure(D["frr"], year, month)
return [G(fig, style={"height": "210px"}), follow_rate_legend()]
def _register_month_click_callback(id_prefix):
extra_outputs = [Output("followers-selected-month", "data")] if id_prefix == "followers" else []
@app.callback(
[Output(f"{id_prefix}-month-btn-{m}", "children") for m in range(1, 13)]
+ [Output(f"{id_prefix}-month-btn-{m}", "style") for m in range(1, 13)]
+ [Output(f"{id_prefix}-posts-row", "children"), Output(f"{id_prefix}-daily-followers-graph", "figure")]
+ extra_outputs,
[Input(f"{id_prefix}-year-select", "value")]
+ [Input(f"{id_prefix}-month-btn-{m}", "n_clicks") for m in range(1, 13)],
)
def _update_month_selection(year, *_):
triggered = ctx.triggered_id
if triggered and triggered.startswith(f"{id_prefix}-month-btn-"):
month = int(triggered.rsplit("-", 1)[-1])
else:
month = _default_active_month(D["fts"], D["posts"], year)
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(D["fts"], D["posts"], year)
children = [_month_box_children(monthly_net, monthly_posts, months_with_data, m) for m in range(1, 13)]
styles = [_month_box_style(monthly_net, months_with_data, max_gain, m, selected=(m == month))
for m in range(1, 13)]
result = children + styles + [_month_day_tiles(D["fts"], D["posts"], year, month),
daily_followers_figure(D["fts"], D["posts"], year, month)]
if id_prefix == "followers":
result.append({"year": year, "month": month})
return result
for _prefix in ("overview", "followers"):
_register_month_click_callback(_prefix)
@app.callback(
Output("overview-impressions-graph", "figure"),
Output("overview-engagement-graph", "figure"),
Output("overview-ctr-graph", "figure"),
Output("overview-pageviews-graph", "figure"),
Input("overview-year-select", "value"),
[Input(f"overview-month-btn-{m}", "n_clicks") for m in range(1, 13)],
)
def _sync_overview_metric_graphs(year, *_):
triggered = ctx.triggered_id
if triggered and triggered.startswith("overview-month-btn-"):
month = int(triggered.rsplit("-", 1)[-1])
else:
month = _default_active_month(D["fts"], D["posts"], year)
cts = _filtered_by_month(D["cts"], year, month)
vts = _filtered_by_month(D["vts"], year, month)
return impressions_figure(cts), engagement_figure(cts), ctr_figure(cts), page_views_figure(vts)
@app.callback(
Output("posts-table", "data"),
Output("posts-table", "tooltip_data"),
Input("content-month-select", "value"),
)
def _filter_posts_table(selected_month):
records = _posts_table_records(selected_month)
tooltip_data = [{c: {"value": str(row[c]), "type": "markdown"} for c in post_table_cols} for row in records]
return records, tooltip_data
@app.callback(
Output("content-impressions-fig", "figure"),
Output("content-engagement-fig", "figure"),
Output("content-ctr-fig", "figure"),
Output("content-scatter-fig", "figure"),
Output("content-reactions-fig", "figure"),
Output("content-clicks-fig", "figure"),
Input("content-month-select", "value"),
)
def _sync_content_charts(selected_month):
posts_f = _content_posts_filtered(selected_month)
cts_f = _content_cts_filtered(selected_month)
return (
_finalize_fig(impressions_by_format_figure(posts_f), margin=CONTENT_BAR_MARGIN),
_finalize_fig(engagement_by_format_figure(posts_f), margin=CONTENT_BAR_MARGIN),
_finalize_fig(ctr_by_format_figure(posts_f), margin=CONTENT_BAR_MARGIN),
_finalize_fig(impressions_vs_engagement_figure(posts_f), legend=CONTENT_SCATTER_LEGEND),
_finalize_fig(reactions_figure(cts_f)),
_finalize_fig(clicks_figure(cts_f)),
)
def _build_report_ctx():
"""Builds the ctx_data dict shared by report.build_report_docx/build_report_pdf/
compute_period_stats, so every consumer reads the same underlying data."""
return {
"D": D, "posts": posts, "comp_ordered": comp_ordered,
"current_total_followers": current_total_followers,
"daily_followers_figure": daily_followers_figure,
"std_fn": _std, "TMPL": TMPL, "FORMAT_COLORS": FORMAT_COLORS,
"growth_snapshot": GROWTH_SNAPSHOT, "directory_data": DIRECTORY_DATA,
"july_register_ratings": _JULY_REGISTER_RATINGS, "repost_dates": REPOST_DATES,
"non_campaign_dates": NON_CAMPAIGN_DATES,
}
def _rich_text(parts, style=None):
"""Renders a list of (text, is_bold) tuples as one html.P, e.g. for a fixed tagline
that needs bolding inside otherwise-plain narrative copy. bold="br" inserts a line
break instead of wrapping text (text is ignored for that entry)."""
children = []
for text, bold in parts:
if bold == "br":
children.append(html.Br())
elif bold:
children.append(html.Strong(text))
else:
children.append(text)
return html.P(children, style=style)
def _insights_link(href):
return html.Div(
dcc.Link("Click here for the insights", href=href,
style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f",
"textDecoration": "none", "cursor": "pointer"}),
style={"textAlign": "right"})
def _insights_link_external(label, href):
"""Same look as _insights_link, but a plain html.A opening in a new tab — for
standalone hand-maintained documents (like the Follower Study) that live outside
the Dash app entirely, as opposed to an internal SPA route."""
return html.Div(
html.A(label, href=href, target="_blank", rel="noopener noreferrer",
style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f",
"textDecoration": "none", "cursor": "pointer"}),
style={"textAlign": "right"})
def _dimension_card(title, paragraphs):
"""One of the three Analytical Dimensions (p.8), styled like the Core Message
PDF's own header-banner-plus-gray-box layout — white text on the app's standard
header gradient (not the PDF's yellow, per explicit direction), light gray body."""
return html.Div([
html.Div(title, style={
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
"color": "white", "fontWeight": "700", "fontSize": "0.85rem",
"padding": "0.6rem 0.9rem", "borderRadius": "6px 6px 0 0",
}),
html.Div([html.P(p, style={"fontSize": "0.8rem", "margin": "0 0 8px"}) for p in paragraphs],
style={"backgroundColor": "#e8e8e8", "padding": "0.9rem", "borderRadius": "0 0 6px 6px"}),
])
# Eng % column here is Real Eng % (Audience-only + Clicks, Team excluded, hand-audited
# — see engagement_audit.py), not LinkedIn's raw reported number. Recomputed directly
# from posts_f + engagement_audit.real_eng_pct rather than hardcoded, so a future
# audit correction here can't silently drift out of sync with the same numbers shown
# on Roadmap/All Posts. The underlying Rational/Emotional finding was re-checked
# against these real numbers (not just re-labeled): Emotional posts still average
# 21.8% Real Eng % vs. Rational's 8.1%, a wider gap than under raw numbers, so the
# "Emotional posts outperformed Rational ones" finding holds and needed no rewrite,
# just corrected figures — 09/07 in particular stays the clear #1 post (45.0% Real,
# barely down from 47.9% raw), not a bottom performer, since its huge Click volume
# (195) dominates the calculation and Clicks are counted in full per that decision.
def _july_register_ratings():
# Deliberately bypasses _reports_posts_f()/_build_report_ctx() here: those build
# the PDF's full ctx dict, which itself needs _JULY_REGISTER_RATINGS (see
# "july_register_ratings" in _build_report_ctx()) — going through them would be
# circular. compute_period_stats only reads ctx["D"]/ctx["posts"], so those two
# are enough on their own.
posts_f = report.compute_period_stats({"D": D, "posts": posts}, "2026-07")["posts_f"]
by_date = {r["Created date"].strftime("%d/%m"): r for _, r in posts_f.iterrows()}
def _eng(date_ddmm):
r = by_date.get(date_ddmm)
if r is None:
return "Pending audit"
pct = _real_eng_pct(date_ddmm, r["Impressions"], r["Clicks"])
return f"{pct:.1f}%" if pct is not None else "Pending audit"
return [
("02/07", "Text/Image", _eng("02/07"),
"A big thank you to Helena Krutošíková of ITCareerHub for connecting us with four "
"#talented interns this past spring.",
"Emotional", "Gratitude"),
("09/07", "Carousel", _eng("09/07"), "How it all started",
"Emotional", "Origin story"),
("10/07", "Text/Image", "N/A", "congratulations 👏",
"Repost", "Repost, excluded from comparison"),
("14/07", "Video", _eng("14/07"), "Time resolution",
"Rational", "Operation mode/frustration undertone"),
("16/07", "Carousel", _eng("16/07"),
"Imagine reclaiming 60% of the beamline time you're currently losing.",
"Rational", "Efficiency"),
("21/07", "Poll", _eng("21/07"),
"At TXproducts, we're studying the reliability challenges that matter most to you.",
"Emotional", "Direct probe"),
("23/07", "Carousel", _eng("23/07"), "How do we act without having the full information?",
"Rational", "Structured process"),
("28/07", "Video", _eng("28/07"),
"Every major synchrotron changes its operation mode several times a year to serve "
"different user communities.",
"Rational", "Operation mode/frustration undertone"),
("30/07", "Carousel", _eng("30/07"),
"Great breakdown, Daniel! We've been developing the product for the last two years "
"and are still working on minor improvements.",
"Emotional", "Founder story"),
]
def _july_register_table():
"""Post-by-post Rational/Emotional read behind the Formulate finding above —
the same table already reviewed and corrected in chat (10/07 reclassified as a
repost, not TXproducts' own voice; 14/07 and 28/07 confirmed as the same
scheduling-window topic posted twice, both with a frustration undertone).
Short columns (Date/Format/Eng %/Rating) pinned to nowrap so their fixed-width
values don't wrap and eat space that Post/Why actually need. Post truncates with
an ellipsis and shows the full text on hover, same pattern as the All Posts table."""
_nowrap = {"whiteSpace": "nowrap"}
_post_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis",
"maxWidth": "180px"}
def _row(date, fmt, eng, post, rating, why):
# Bootstrap's table CSS sets an explicit background-color on <td> itself, which
# paints over a <tr>-level background — the highlight has to go on each cell.
is_repost = rating == "Repost"
highlight = {"backgroundColor": "#fbdcdc", "textDecoration": "line-through"} if is_repost else {}
return html.Tr([
html.Td(date, style={**_nowrap, **highlight}),
html.Td(fmt, style={**_nowrap, **highlight}),
html.Td(eng, style={**_nowrap, **highlight}),
html.Td(post, title=post, style={**_post_style, **highlight}),
html.Td(html.Strong(rating), style={**_nowrap, **highlight}),
html.Td(why, style={"fontSize": "0.78rem", **highlight}),
])
_th_yellow = {"backgroundColor": "#fdf6d8"}
return dbc.Table([
html.Thead(html.Tr([html.Th(c, style={**(_nowrap if c not in ("Post", "Why") else {}), **_th_yellow})
for c in ["Date", "Format", "Real Eng %", "Post", "Rating", "Why"]])),
html.Tbody([_row(*row) for row in _JULY_REGISTER_RATINGS]),
], bordered=True, size="sm", style={"fontSize": "0.8rem", "marginTop": "14px", "tableLayout": "auto"})
def _rational_emotional_gauge(position_pct, caption):
"""Horizontal Rational<->Emotional indicator for the Formulate/Decision Context
read. position_pct (0=fully Rational, 100=fully Emotional) is a qualitative
placement per the "educated guess, not a computed stat" framing already
established for these dimensions — not a precise score. Light Blue/Orange: the
psychologically conventional cool-logical/warm-emotional pair, using the lighter
blue rather than the app's dominant navy so it reads as its own thing here
instead of blending into every header/border already using that navy."""
rational_color, emotional_color = LIGHT_BLUE, ORANGE
return html.Div([
html.Div([
html.Span("RATIONAL", style={"color": rational_color, "fontWeight": "700",
"fontSize": "0.7rem", "letterSpacing": "0.05em"}),
html.Span("EMOTIONAL", style={"color": emotional_color, "fontWeight": "700",
"fontSize": "0.7rem", "letterSpacing": "0.05em"}),
], style={"display": "flex", "justifyContent": "space-between", "marginBottom": "6px"}),
html.Div([
html.Div(caption, style={
"position": "absolute", "left": f"{position_pct}%", "top": "-20px",
"transform": "translateX(-50%)", "whiteSpace": "nowrap",
"fontSize": "0.72rem", "fontWeight": "700", "color": "#1a2533",
}),
html.Div(style={
"position": "absolute", "left": f"{position_pct}%", "top": "0px",
"transform": "translateX(-50%)", "width": "0", "height": "0",
"borderLeft": "7px solid transparent", "borderRight": "7px solid transparent",
"borderTop": "9px solid #1a2533",
}),
], style={"position": "relative", "height": "20px"}),
html.Div(style={
"height": "10px", "borderRadius": "5px",
"background": f"linear-gradient(90deg, {rational_color} 0%, {emotional_color} 100%)",
}),
], style={"width": "100%", "margin": "6px 0 16px"})
def _reports_card_body_style():
"""Flex column with space-between so an _insights_link sits flush against the
CardBody's own bottom padding (real breathing room before the card's border)
instead of wherever the content above happens to end."""
return {"display": "flex", "flexDirection": "column", "justifyContent": "space-between", "height": "100%"}
def _narrative_section(title, items):
if not items:
return html.Div()
return html.Div([
html.H5(title, style={"marginTop": "18px", "marginBottom": "8px", "color": "#1b3a5c"}),
html.Ul([html.Li(item, style={"marginBottom": "4px"}) for item in items],
style={"fontSize": "0.85rem", "paddingLeft": "1.1rem"}),
])
def _composition_table(counter, header_left, top_n=None):
"""No truncation by default, sector/seniority/function are a small, fixed taxonomy
(see project memory's classification categories), and country is uncapped by
composition_stats() for the same reason, so showing everything keeps the table's
own sum matching the cohort total instead of silently dropping tail categories."""
items = counter.most_common(top_n) if hasattr(counter, "most_common") else list(counter)[:top_n]
return dbc.Table([
html.Thead(html.Tr([html.Th(header_left), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in items]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
def _reports_posts_table(posts_f):
"""Every post in the period, listed chronologically, not just a top-N by engagement.
A monthly campaign report should show the full picture of what was published, since
the point is a complete record, not a highlights reel. A repost/non-campaign date is
still shown here (not silently dropped), but struck through and marked "Excluded" —
same pale-red treatment as the dedicated All Posts insights page and Core Message's
register table, so a reader doesn't see a plain, unmarked row and wonder why its
Real Eng % looks unaudited instead of deliberately out of scope."""
if not len(posts_f):
return html.Div("No posts published in this period.", className="text-muted")
all_posts = posts_f.sort_values("Created date")
_post_title_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis",
"maxWidth": "260px"}
_excl_style = {"backgroundColor": "#fbdcdc", "textDecoration": "line-through"}
def _row(r):
excluded = r["Created date"].strftime("%Y-%m-%d") in (REPOST_DATES | NON_CAMPAIGN_DATES)
cell_style = {**_post_title_style, **_excl_style} if excluded else _post_title_style
plain_style = _excl_style if excluded else {}
eng_cell = (html.Span(["Excluded"], style={"fontWeight": "700"}) if excluded else
(f"{r['Real Eng %']:.1f}%" if pd.notna(r["Real Eng %"]) else
html.Span("Pending audit", style={"color": "#8a96a3", "fontStyle": "italic"})))
return html.Tr([
html.Td(str(r["Short title"]), title=str(r["Short title"]), style=cell_style),
html.Td(r["Created date"].strftime("%d/%m"), style=plain_style),
html.Td(r["Content Type"], style=plain_style),
html.Td(f"{int(r['Impressions']):,}", style=plain_style),
html.Td(f"{r['CTR %']:.1f}%", style=plain_style),
html.Td(eng_cell, style=plain_style),
])
return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in
["Post", "Date", "Type", "Impressions", "CTR %", "Real Eng %"]])),
html.Tbody([_row(r) for _, r in all_posts.iterrows()]),
], bordered=True, size="sm", style={"fontSize": "0.75rem"})
# Audience-vs-Team audit + Real Eng % now live in engagement_audit.py, shared with
# report.py (the PDF generator) — see that module's docstring for why it's separate.
_AUDIENCE_TEAM_AUDIT = engagement_audit.AUDIENCE_TEAM_AUDIT
_real_eng_pct = engagement_audit.real_eng_pct
def _reports_posts_f(month_value="2026-07"):
"""Shared by every Roadmap-family insights page — the selected month's posts with
Shares/Weekday columns added, so each page computes from the identical base
instead of drifting. Excludes REPOST_DATES (a reshare of someone else's post, not
TXproducts' own content) and NON_CAMPAIGN_DATES (a real TXproducts post, but not
part of the 24-post branding campaign) — neither belongs in a format/day/traction
breakdown of what the campaign itself published."""
stats = report.compute_period_stats(_build_report_ctx(), month_value)
posts_f = stats["posts_f"].copy()
excluded_dates = REPOST_DATES | NON_CAMPAIGN_DATES
posts_f = posts_f[~posts_f["Created date"].dt.strftime("%Y-%m-%d").isin(excluded_dates)]
posts_f["Shares"] = posts_f["Comments"].fillna(0) + posts_f["Reposts"].fillna(0)
posts_f["Weekday"] = posts_f["Created date"].dt.day_name()
return posts_f
_JULY_REGISTER_RATINGS = _july_register_ratings()
_FORMAT_ICONS = {
"Carousel": "bi-images",
"Video": "bi-camera-video",
"Text/Image": "bi-file-earmark-text",
"Poll": "bi-bar-chart-line",
}
_STAGE_SEQUENCE = [
("Traction", "bi-graph-up-arrow", "Find the Voice"),
("Demographics", "bi-people", "Find the Audience"),
("Conversion", "bi-arrow-left-right", "Read the Room"),
("Growth", "bi-rocket-takeoff", "Scale What Works"),
]
# Page 4's own one-stage-per-month mapping — which stage is "active" on the Roadmap
# timeline changes with whichever month is selected, not fixed to July/Traction.
_STAGE_FOR_MONTH = {"2026-07": "Traction", "2026-08": "Demographics", "2026-09": "Conversion"}
def _format_day_confound_note(posts_f):
"""Generic version of the Carousel/Thursday finding: if this month's data happens
to have every format landing on exactly one weekday (format and day perfectly
confounded), say so — computed from whatever month is selected, not hardcoded to
July's specific Carousel/Thursday pattern. Returns None when there's no confound
to report (not enough posts, or formats/days actually vary independently)."""
if len(posts_f) < 2 or posts_f["Weekday"].nunique() < 2:
return None
fmt_days = posts_f.groupby("Content Type")["Weekday"].agg(lambda s: set(s))
if not all(len(days) == 1 for days in fmt_days):
return None
day_fmts = {}
for fmt, days in fmt_days.items():
day_fmts.setdefault(next(iter(days)), []).append(fmt)
if len(day_fmts) < 2:
return None
clauses = [f"every {'/'.join(fmts)} post was on a {day}" for day, fmts in day_fmts.items()]
return html.P(
f"This month, {'; '.join(clauses)}, so the data can't isolate a real "
f"day-of-week effect from a format effect. Picking a day deliberately, "
f"independent of format, would let day-of-week actually be tested.",
style={"fontSize": "0.78rem", "color": "#8a3a3a", "margin": "10px 0 0",
"backgroundColor": "#fbdcdc", "padding": "8px 12px", "borderRadius": "6px"})
def _stage_timeline(active):
"""Page 4's own four-stage sequence (Traction/Demographics/Conversion/Growth),
styled like Core Message's flow diagram so the two pages read as one family."""
def _box(name, icon, subtitle):
# Active stage gets pale green rather than the app's own navy — navy is already
# the dominant color on every border/header here, so it wouldn't stand out as
# "current" the way a distinct accent does (same reasoning as the rational/
# emotional gauge's colors elsewhere on this page).
is_active = name == active
border_color = GREEN if is_active else BLUE
text_col = html.Div([
html.Div(name, style={"fontWeight": "700", "fontSize": "0.85rem", "color": BLUE}),
html.Div(subtitle, style={"fontSize": "0.68rem", "color": "#6b7a8d"}),
])
return html.Div([
html.I(className=f"bi {icon}", style={"fontSize": "2.1rem", "lineHeight": "1",
"color": border_color}),
text_col,
], style={
"display": "flex", "alignItems": "center", "gap": "12px",
"border": f"2px solid {border_color}", "borderRadius": "8px", "padding": "8px 16px",
"backgroundColor": "#eaf7e0" if is_active else "white",
})
def _arrow():
return html.Div(style={
"width": "0", "height": "0", "flexShrink": "0",
"borderTop": "9px solid transparent", "borderBottom": "9px solid transparent",
"borderLeft": f"13px solid {NAVY2}",
})
children = []
for i, (name, icon, subtitle) in enumerate(_STAGE_SEQUENCE):
if i:
children.append(_arrow())
children.append(_box(name, icon, subtitle))
return html.Div(children, style={"display": "flex", "alignItems": "center", "gap": "8px",
"flexWrap": "wrap"})
def _calendar_strip(highlight_abbr):
"""Mon-Fri pill strip echoing the Roadmap PDF's own page-2 calendar visual —
circles whichever day actually won in July instead of the document's Tuesday."""
days = ["Mon", "Tue", "Wed", "Thu", "Fri"]
def _pill(day):
is_hl = day == highlight_abbr
return html.Div(day, style={
"width": "56px", "height": "56px", "borderRadius": "50%", "flexShrink": "0",
"display": "flex", "alignItems": "center", "justifyContent": "center",
"fontSize": "0.85rem", "fontWeight": "700",
"border": f"2px solid {ORANGE if is_hl else '#e0e7ef'}",
"color": ORANGE if is_hl else "#6b7a8d",
"backgroundColor": "#fff5ef" if is_hl else "white",
})
return html.Div([_pill(d) for d in days], style={"display": "flex", "gap": "10px"})
# Spelled out fully, with a fake-but-concrete worked example, and reused verbatim
# everywhere Real Eng % has its own callout — "explain the formula" was specific,
# repeated feedback, not a one-off. Round numbers only, never a real post's, so it
# can never be mistaken for actual data no matter which post/month is on screen.
_REAL_ENG_FORMULA_EXAMPLE = html.Div([
html.Div([
html.Strong("Formula: "),
"(Audience Likes + Comments + Reposts + Clicks) ÷ Impressions × 100",
], style={"marginBottom": "6px"}),
html.Div(
"Example: 3 Likes + 1 Comment + 0 Reposts + 40 Clicks = 44 audience actions. "
"44 ÷ 200 Impressions = 0.22 → 22%.",
style={"color": "#6b7a8d", "fontSize": "0.82rem"}),
], style={"marginBottom": "10px", "paddingBottom": "10px", "borderBottom": "1px solid #e0e7ef"})
# Same treatment for CTR % — its own explicit formula/example, not just prose about
# what it means, plus the "Attention, not traffic" correction (TXproducts links
# always live in a comment, never the post body, so LinkedIn's per-post Clicks
# metric can't be counting outbound website visits — see engagement_audit.py's
# real_eng_pct docstring for the same reasoning applied to Real Eng %'s numerator).
_CTR_FORMULA_EXAMPLE = html.Div([
html.Div([
html.Strong("CTR % formula: "), "Clicks ÷ Impressions × 100.",
], style={"marginBottom": "6px"}),
html.Div("Example: 40 Clicks ÷ 200 Impressions = 20%.",
style={"color": "#6b7a8d", "fontSize": "0.82rem", "marginBottom": "8px"}),
html.Div([
html.Strong("This measures attention, not website traffic."), " LinkedIn never says "
"whether a click went to a website — TXproducts' links always live in a comment, never "
"the post itself, so these clicks are people expanding “see more,” viewing an "
"image, or swiping a carousel.",
], style={"color": "#6b7a8d"}),
])
# For tables that show both Real Eng % and CTR % but, unlike Engagement Composition,
# never had any explainer at all — Best Posting Day and Report Overview's posts list.
_REAL_ENG_AND_CTR_INFO = html.Div([_REAL_ENG_FORMULA_EXAMPLE, _CTR_FORMULA_EXAMPLE])
_FORMAT_BREAKDOWN_INFO = html.Div([
_REAL_ENG_FORMULA_EXAMPLE,
html.Div([
"Real Avg Eng % is ", html.Strong("Audience-only"),
", hand-audited against each post's actual Reactions/Comments/Reposts lists "
"on LinkedIn. Team activity is fully excluded.",
], style={"marginBottom": "8px"}),
html.Div("Clicks are included although unaudited.", style={"color": "#6b7a8d"}),
])
_ENG_OVER_TIME_INFO = html.Div([
_REAL_ENG_FORMULA_EXAMPLE,
html.Div([
"This chart plots Real Eng % per post. Each bar is ", html.Strong("Audience-only"), ".",
], style={"marginBottom": "8px"}),
html.Div("Clicks are included although unaudited.", style={"color": "#6b7a8d"}),
])
def _format_breakdown_table(posts_f):
"""Per-format stats with the actual constituent posts named inline, so the
breakdown answers "which posts belong there" in the same table, not a second
one — reposts already excluded upstream by _reports_posts_f(). Comments/Reposts
columns dropped — that detail (plus the Audience vs. Team split) lives on All
Posts' Engagement Composition card, not duplicated here. Real Eng % already
lives on posts_f (see compute_period_stats) — no LinkedIn-reported column here
at all, to avoid the exact "which number is real" confusion that motivated
dropping it."""
by_format = (posts_f.groupby("Content Type")
.agg(Posts=("Content Type", "size"),
**{"Real Avg Eng %": ("Real Eng %", "mean")})
.round(1).sort_values("Real Avg Eng %", ascending=False))
dates_by_format = (posts_f.sort_values("Created date")
.groupby("Content Type")["Created date"]
.apply(lambda s: ", ".join(d.strftime("%d/%m") for d in s)))
_th_yellow = {"backgroundColor": "#fdf6d8"}
rows = []
for fmt, r in by_format.iterrows():
rows.append(html.Tr([
html.Td(html.I(className=f"bi {_FORMAT_ICONS.get(fmt, 'bi-file-post')}",
style={"color": BLUE, "fontSize": "1rem"})),
html.Td(fmt, style={"fontWeight": "600", "whiteSpace": "nowrap"}),
html.Td(int(r["Posts"]), style={"whiteSpace": "nowrap"}),
html.Td(dates_by_format.get(fmt, ""), style={"fontSize": "0.78rem"}),
html.Td("Pending audit" if pd.isna(r["Real Avg Eng %"]) else f"{r['Real Avg Eng %']}%",
style={"whiteSpace": "nowrap", "fontWeight": "700",
"color": "#9a9a9a" if pd.isna(r["Real Avg Eng %"]) else "inherit",
"fontStyle": "italic" if pd.isna(r["Real Avg Eng %"]) else "normal"}),
]))
table = dbc.Table([
html.Thead(html.Tr([html.Th(c, style=_th_yellow) for c in
["", "Format", "Posts", "Dates", "Real Avg Eng %"]])),
html.Tbody(rows),
], bordered=True, size="sm", style={"fontSize": "0.82rem", "marginBottom": "8px"})
return html.Div([table, _engagement_composition_link()])
def _engagement_composition_link():
"""Same look as _insights_link — bold, right-aligned — pointing to All Posts'
Engagement Composition card, which has the per-post Comments/Reposts and the
full Audience vs. Team breakdown behind every Real Eng % figure on this page."""
return html.Div(
dcc.Link("Real Engagement breakdown → All Posts Insights",
href="/all-posts-insights",
style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f",
"textDecoration": "none", "cursor": "pointer"}),
style={"textAlign": "right"})
def _weekday_detail_table(posts_f):
"""Full per-post metrics, chronological — same sort order as every other per-post
table in the app (Report Overview, All Posts, Core Message register), so cross-
referencing a specific post between them isn't confusing. The format/day confound
(e.g. every Carousel landing on the same weekday) is called out separately by
_format_day_confound_note's red-flag banner, not by grouping rows here."""
ordered = posts_f.sort_values("Created date")
_th_yellow = {"backgroundColor": "#fdf6d8"}
cols = ["Date", "Weekday", "Format", "Post", "Impressions", "Clicks", "CTR %", "Real Eng %", "Likes", "Comments", "Reposts"]
_post_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis", "maxWidth": "160px"}
rows = []
for _, r in ordered.iterrows():
real_eng = r["Real Eng %"]
rows.append(html.Tr([
html.Td(r["Created date"].strftime("%d/%m")),
html.Td(r["Weekday"]),
html.Td([html.I(className=f"bi {_FORMAT_ICONS.get(r['Content Type'], 'bi-file-post')}",
style={"color": BLUE, "marginRight": "6px"}), r["Content Type"]],
style={"whiteSpace": "nowrap"}),
html.Td(str(r["Short title"]), title=str(r["Short title"]), style=_post_style),
html.Td(f"{int(r['Impressions']):,}"),
html.Td(int(r["Clicks"])),
html.Td(f"{r['CTR %']}%"),
html.Td(f"{real_eng:.1f}%" if pd.notna(real_eng) else
html.Span("Pending audit", style={"color": "#9a9a9a", "fontStyle": "italic"})),
html.Td(int(r["Likes"])),
html.Td(int(r["Comments"])),
html.Td(int(r["Reposts"])),
]))
return dbc.Table([
html.Thead(html.Tr([html.Th(c, style=_th_yellow) for c in cols])),
html.Tbody(rows),
], bordered=True, size="sm", style={"fontSize": "0.78rem", "marginBottom": "0"})
def _roadmap_insights_page(month_value="2026-07"):
"""Grounded in Campaign Roadmap.pdf pages 2-5: the Tuesday/Thursday cadence claim
(p.2), CTA/traction-by-format claims (p.3), the four-stage sequence (p.4), and
each month's own "checkpoint" (p.5) — read against that month's real data, not
as a planned-vs-actual grade against a pre-campaign sketch the team has already
said was loosely thought out ("we are just learning"). Reposts excluded
everywhere via _reports_posts_f() (REPOST_DATES) — a reshare of someone else's
post isn't TXproducts' own content in any format sense. Real Avg Eng % and Real
Engagement Over Time both fall back to "Pending audit"/reported-only for any
month that hasn't been hand-audited yet — only July has been so far."""
posts_f = _reports_posts_f(month_value)
period_label = report._period_label(month_value)
active_stage = _STAGE_FOR_MONTH.get(month_value, "Traction")
stage_index = next((i for i, (name, *_r) in enumerate(_STAGE_SEQUENCE) if name == active_stage), 0)
if posts_f.empty:
return dbc.Container([
dbc.Alert(f"No posts published in {period_label} yet.", color="info"),
], fluid=True, className="pb-3")
# Real Eng % already lives on posts_f (see compute_period_stats). month_audited
# gates the "best day"/chart fallback to LinkedIn's raw Eng % only when NOTHING
# this month has been audited yet — a partially-audited month still means among
# its audited posts, since groupby().mean() already skips NaN per row.
month_audited = posts_f["Real Eng %"].notna().any()
eng_col = "Real Eng %" if month_audited else "Eng %"
by_day = (posts_f.groupby("Weekday").agg(**{"Avg Eng %": (eng_col, "mean")}).round(1))
best_day = by_day["Avg Eng %"].idxmax()
timeline_card = dbc.Card([
dbc.CardHeader(html.Span("Branding Campaign's Four Stages in Sequence (p.4)",
style={"color": "#FFD54F"})),
dbc.CardBody(
html.Div([
_stage_timeline(active_stage),
html.P(
f"The Roadmap lays out one stage per month. {period_label} is "
f"Stage {stage_index + 1}, {active_stage}"
+ (" — the rest follow later in the campaign." if stage_index < 3
else " — the campaign's final stage."),
style={"fontSize": "0.85rem", "margin": "0", "flex": "1", "minWidth": "260px"}),
], style={"display": "flex", "alignItems": "center", "gap": "24px", "flexWrap": "wrap"})
),
], className="mb-3 shadow-sm")
format_card = dbc.Card([
dbc.CardHeader([html.Span("Traction by Format (p.3)", style={"color": "#FFD54F"}),
info_popover("format-breakdown", _FORMAT_BREAKDOWN_INFO)]),
dbc.CardBody(_format_breakdown_table(posts_f)),
], className="shadow-sm h-100")
confound_note = _format_day_confound_note(posts_f)
day_card = dbc.Card([
dbc.CardHeader([html.Span("Best Posting Day (p.2)" if month_audited
else "Best Posting Day (p.2) (Pending Audit)",
style={"color": "#FFD54F"}),
info_popover("best-posting-day", _REAL_ENG_AND_CTR_INFO)]),
dbc.CardBody([
html.Div([
_calendar_strip(best_day[:3]),
html.P([
html.Strong(best_day), " shows the highest average engagement this month.",
], style={"fontSize": "0.85rem", "margin": "0", "flex": "1", "minWidth": "200px"}),
], style={"display": "flex", "alignItems": "center", "gap": "24px", "flexWrap": "wrap",
"marginBottom": "14px"}),
html.P(f"{period_label} hasn't been hand-audited yet — showing LinkedIn's "
"reported Eng %, Team activity included.",
style={"fontSize": "0.76rem", "color": "#9a9a9a", "fontStyle": "italic",
"margin": "0 0 10px"}) if not month_audited else None,
_weekday_detail_table(posts_f),
] + ([confound_note] if confound_note is not None else [])),
], className="mb-3 shadow-sm")
posts_f_sorted = posts_f.sort_values("Created date")
eng_fig = px.bar(posts_f_sorted, x="Created date",
y="Real Eng %" if month_audited else "Eng %",
color="Content Type", color_discrete_map=FORMAT_COLORS,
template=TMPL, labels={"Content Type": "Format", "Created date": "",
"Real Eng %": "", "Eng %": ""})
eng_fig = _std(eng_fig)
eng_fig.update_layout(margin=dict(t=36, b=20, l=10, r=10),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,
font=dict(size=9), title=None))
year, month = (int(p) for p in month_value.split("-"))
range_start = f"{year}-{month:02d}-01"
range_end = f"{year + 1}-01-01" if month == 12 else f"{year}-{month + 1:02d}-01"
eng_fig.update_xaxes(range=[range_start, range_end], tickformat="%b %d", fixedrange=True)
eng_fig.update_yaxes(fixedrange=True, ticksuffix="%")
y_col = "Real Eng %" if month_audited else "Eng %"
eng_fig.add_trace(go.Scatter(
x=posts_f_sorted["Created date"], y=posts_f_sorted[y_col], mode="markers", showlegend=False,
marker=dict(symbol="star", size=11, color="#E53935", line=dict(width=1, color="white")),
name="Post published", customdata=posts_f_sorted["Short title"],
hovertemplate="<b>Post published — %{x|%b %d, %Y}</b><br>%{customdata}<extra></extra>",
))
growth_card = dbc.Card([
dbc.CardHeader([html.Span("Real Engagement Over Time" if month_audited
else "Engagement Over Time (Pending Audit)",
style={"color": "#FFD54F"}),
info_popover("eng-over-time", _ENG_OVER_TIME_INFO)]),
dbc.CardBody([
G(eng_fig, style={"height": "170px"}),
html.P(f"{period_label} hasn't been hand-audited yet — showing LinkedIn's "
"reported Eng %, Team activity included.",
style={"fontSize": "0.76rem", "color": "#9a9a9a", "fontStyle": "italic",
"margin": "4px 0 0"}) if not month_audited else None,
_engagement_composition_link(),
]),
], className="shadow-sm")
main_row = html.Div([format_card, growth_card],
style={"display": "grid", "gridTemplateColumns": "3fr 2fr", "gap": "1rem", "marginBottom": "1rem"})
return dbc.Container([timeline_card, main_row, day_card], fluid=True, className="pb-3")
_ENGAGEMENT_CELL_WIDTH = "128px"
_AUDIENCE_COLOR = "#fbe6d2" # very pale orange
_TEAM_COLOR = "#dbe9f8" # very pale blue
def _audience_team_td(audience_n, team_n):
total = audience_n + team_n
audience_pct = (audience_n / total * 100) if total else 0
team_pct = (team_n / total * 100) if total else 0
# The <td> itself carries the diagonal split — two triangles via clip-path
# (not a fixed-angle gradient, which cuts corner-to-corner only for a square;
# clip-path polygons always scale to the cell's actual width/height, so the
# dividing line reaches true corner to corner no matter the cell's shape).
# No A/T letters inside the triangles — the legend above already maps color to
# meaning, so the number can just be the number. Wider/shorter box (elongated)
# keeps the text on one line at a shorter cell height. Module-level (not just
# All Posts' own helper) so Best Posting Day/Content tab can reuse it too.
return html.Td([
html.Div(style={
"position": "absolute", "inset": "0", "backgroundColor": _AUDIENCE_COLOR,
"clipPath": "polygon(0% 0%, 100% 0%, 0% 100%)"}),
html.Div(style={
"position": "absolute", "inset": "0", "backgroundColor": _TEAM_COLOR,
"clipPath": "polygon(100% 0%, 100% 100%, 0% 100%)"}),
html.Div(f"{audience_n}", style={
"position": "absolute", "top": "3px", "left": "5px", "fontSize": "0.78rem",
"color": "#5a3a1a", "lineHeight": "1", "whiteSpace": "nowrap", "zIndex": "1"}),
html.Div(f"{team_n}", style={
"position": "absolute", "bottom": "3px", "right": "5px", "fontSize": "0.78rem",
"color": "#1a3a5a", "lineHeight": "1", "whiteSpace": "nowrap", "zIndex": "1"}),
], title=f"Audience: {audience_n} ({audience_pct:.0f}%) · Team: {team_n} ({team_pct:.0f}%)", style={
"position": "relative", "padding": "0", "height": "26px",
"width": _ENGAGEMENT_CELL_WIDTH, "minWidth": _ENGAGEMENT_CELL_WIDTH,
"maxWidth": _ENGAGEMENT_CELL_WIDTH})
def _most_reach_post(posts_f):
"""Whichever audited post has the highest combined Audience Acknowledgment
(Likes) + Conversation (Comments) + Amplification (Reposts) this month — the
Read column's own three categories, summed. Not Real Eng % (dominated by
Clicks, which have no ripple/reach potential at all since nobody but the
clicker ever sees it) and not raw Reposts alone (ties too easily, e.g. July's
02/07 vs 30/07 tie at 2 each). Unaudited posts aren't eligible — there's
nothing to compare. Returns None if no post this month has been audited."""
best_row, best_total = None, -1
for _, r in posts_f.iterrows():
date_ddmm = r["Created date"].strftime("%d/%m")
audit = _AUDIENCE_TEAM_AUDIT.get(date_ddmm)
if not audit:
continue
total = (audit["likes"]["audience"] + audit["comments"]["audience"]
+ audit["reposts"]["audience"])
if total > best_total:
best_total, best_row = total, r
return None if best_row is None else (best_row, best_total)
def _most_reach_sticker(posts_f):
"""Sleek circular badge, not a chart — a pointer to which row to read, not a
second place to read numbers (the old composition chart's problem). Shows only
the winning post's format icon and date; every actual number stays in the
table itself. Returns an empty Div if nothing this month is audited yet."""
result = _most_reach_post(posts_f)
if result is None:
return html.Div()
r, _total = result
icon_class = _FORMAT_ICONS.get(r["Content Type"], "bi-file-post")
return html.Div([
html.Div("Most Reach", style={
"fontSize": "0.75rem", "fontWeight": "800", "textTransform": "uppercase",
"letterSpacing": "0.05em", "color": "#8a6d1a", "marginBottom": "14px"}),
# A medallion, not a flat dot: an outer rim (darker gold ring) with an inner
# disc (lighter gold-to-amber gradient) inset inside it, plus a subtle inset
# highlight — the layering is what reads as "medal" rather than "sticker,"
# without resorting to a literal trophy/ribbon icon.
html.Div(
html.Div([
html.I(className=f"bi {icon_class}", style={"fontSize": "1.8rem", "color": "#6b4e00"}),
html.Div(r["Created date"].strftime("%d/%m"), style={
"fontWeight": "800", "fontSize": "1.3rem", "color": "#6b4e00",
"marginTop": "4px", "lineHeight": "1"}),
], style={
"width": "104px", "height": "104px", "borderRadius": "50%",
"background": "radial-gradient(circle at 35% 30%, #FFEDA8 0%, #FFC947 55%, #E8A317 100%)",
"display": "flex", "flexDirection": "column", "alignItems": "center", "justifyContent": "center",
"boxShadow": "inset 0 0 0 3px rgba(255,255,255,0.5), inset 0 -4px 8px rgba(150,100,0,0.25)",
}),
style={
"width": "124px", "height": "124px", "borderRadius": "50%",
"backgroundColor": "#C9960C", "display": "flex", "alignItems": "center", "justifyContent": "center",
"boxShadow": "0 4px 10px rgba(0,0,0,0.22)",
}),
], style={"textAlign": "center", "display": "flex", "flexDirection": "column", "alignItems": "center"})
def _all_posts_insights_page(month_value="2026-07"):
"""Mirrors the Content tab itself, scoped to the selected month: the post list
first, then the same format-breakdown and daily-activity charts, in the same
order Content shows them — Traction by Format is Roadmap's own table now (with
dates/reposts split out), so it isn't repeated here."""
stats = report.compute_period_stats(_build_report_ctx(), month_value)
period_label = stats["period_label"]
posts_f_all = stats["posts_f"]
cts_f = stats["cts_f"]
if posts_f_all.empty:
return dbc.Container([
dbc.Alert(f"No posts published in {period_label} yet.", color="info"),
], fluid=True, className="pb-3")
kpi_row = html.Div([
html.Div(kpi("Posts Published", f"{stats['posts_published']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Impressions", f"{stats['total_impressions']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Best Post Real Eng.", _fmt_best_eng(stats["best_eng"]), color=GREEN), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Avg. Eng. Rate", f"{stats['avg_eng']:.2f}%" if stats["avg_eng"] else "—"),
style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"})
_comp_df = _reports_posts_f(month_value).sort_values("Created date").copy()
# _reports_posts_f() correctly excludes REPOST_DATES from every calculation, but
# per Mckinley's request the repost should still be VISIBLE here, struck through
# and marked Excluded, not silently missing from the post list — same
# transparency treatment Core Message's register table already gives it.
_repost_rows = posts_f_all[posts_f_all["Created date"].dt.strftime("%Y-%m-%d").isin(REPOST_DATES)]
if not _repost_rows.empty:
_comp_df = pd.concat([_comp_df, _repost_rows]).sort_values("Created date")
_comp_df["Date"] = _comp_df["Created date"].dt.strftime("%d/%m")
def _first_n_words(text, n=5):
words = str(text).split()
return " ".join(words[:n]) + ("..." if len(words) > n else "")
_th_yellow_comp = {"backgroundColor": "#fdf6d8"}
_post_style_comp = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis", "maxWidth": "90px"}
_type_abbrev = {"Text/Image": "T", "Carousel": "C", "Video": "V", "Poll": "P"}
def _comp_row(r):
if r["Created date"].strftime("%Y-%m-%d") in REPOST_DATES:
# Visible, not silently dropped, but struck through and clearly marked —
# same pale-red/strikethrough treatment as Core Message's register table.
excl = {"backgroundColor": "#fbdcdc", "textDecoration": "line-through"}
return html.Tr([
html.Td([_first_n_words(r["Short title"], 3), " ", html.Strong("Excluded")],
title=str(r["Short title"]), style={**_post_style_comp, **excl}),
html.Td(_type_abbrev.get(r["Content Type"], r["Content Type"]),
title=r["Content Type"], style={"whiteSpace": "nowrap", "textAlign": "center", **excl}),
html.Td(r["Date"], style={"whiteSpace": "nowrap", **excl}),
html.Td(f"{int(r['Impressions']):,}", style={"whiteSpace": "nowrap", **excl}),
html.Td(int(r["Clicks"]), style={"whiteSpace": "nowrap", **excl}),
html.Td(f"{r['CTR %']:.1f}%", style={"whiteSpace": "nowrap", **excl}),
html.Td("Excluded", style={"whiteSpace": "nowrap", **excl}),
html.Td(int(r["Likes"]), style={"textAlign": "center", **excl}),
html.Td(int(r["Comments"]), style={"textAlign": "center", **excl}),
html.Td(int(r["Reposts"]), style={"textAlign": "center", **excl}),
html.Td("Repost, excluded from comparison", style={"fontSize": "0.76rem", **excl}),
])
audit = _AUDIENCE_TEAM_AUDIT.get(r["Date"])
if audit:
# Plain counts, not percentages — a share-of-total on a small organic base
# (e.g. 7 Likes/0/0) inflates into a misleading "100%", the same small-
# denominator distortion already flagged for the Poll. Counts can't inflate.
read = (f"{audit['likes']['audience']} Acknowledgment / "
f"{audit['comments']['audience']} Conversation / "
f"{audit['reposts']['audience']} Amplification")
likes_cell = _audience_team_td(audit["likes"]["audience"], audit["likes"]["team"])
comments_cell = _audience_team_td(audit["comments"]["audience"], audit["comments"]["team"])
reposts_cell = _audience_team_td(audit["reposts"]["audience"], audit["reposts"]["team"])
else:
# No rule-based label here — the old max-share "Balanced/Passive/..." rule
# was already found misleading on small totals (the Poll) and dropped in
# favor of plain counts for audited posts. An unaudited post gets the same
# treatment now too: the raw count, not a share-of-total percentage — same
# small-denominator reasoning, and it keeps the column's units consistent
# (a number, split or not) rather than switching to a percentage only when
# unaudited.
read = "Pending audit"
_plain_style = {"whiteSpace": "nowrap", "textAlign": "center", "width": _ENGAGEMENT_CELL_WIDTH,
"minWidth": _ENGAGEMENT_CELL_WIDTH, "maxWidth": _ENGAGEMENT_CELL_WIDTH}
likes_cell = html.Td(int(r["Likes"]), style=_plain_style)
comments_cell = html.Td(int(r["Comments"]), style=_plain_style)
reposts_cell = html.Td(int(r["Reposts"]), style=_plain_style)
real_eng = r["Real Eng %"]
real_eng_cell = (html.Td(f"{real_eng:.1f}%", style={"whiteSpace": "nowrap", "fontWeight": "700"})
if pd.notna(real_eng) else
html.Td("Pending audit", style={"whiteSpace": "nowrap", "color": "#9a9a9a",
"fontStyle": "italic"}))
return html.Tr([
html.Td(_first_n_words(r["Short title"], 3), title=str(r["Short title"]), style=_post_style_comp),
html.Td(_type_abbrev.get(r["Content Type"], r["Content Type"]), title=r["Content Type"],
style={"whiteSpace": "nowrap", "textAlign": "center"}),
html.Td(r["Date"], style={"whiteSpace": "nowrap"}),
html.Td(f"{int(r['Impressions']):,}", style={"whiteSpace": "nowrap"}),
html.Td(int(r["Clicks"]), style={"whiteSpace": "nowrap"}),
html.Td(f"{r['CTR %']:.1f}%", style={"whiteSpace": "nowrap"}),
real_eng_cell,
likes_cell,
comments_cell,
reposts_cell,
html.Td(read, style={"fontSize": "0.76rem"}),
])
_comp_legend = html.Div([
html.Span(style={"display": "inline-block", "width": "10px", "height": "10px",
"backgroundColor": _AUDIENCE_COLOR, "borderRadius": "2px", "marginRight": "4px"}),
html.Span("A = Audience", style={"marginRight": "12px", "fontSize": "0.72rem"}),
html.Span(style={"display": "inline-block", "width": "10px", "height": "10px",
"backgroundColor": _TEAM_COLOR, "borderRadius": "2px", "marginRight": "4px"}),
html.Span("T = Team", style={"marginRight": "12px", "fontSize": "0.72rem"}),
html.Span("Type: T=Text/Image, C=Carousel, V=Video, P=Poll", style={"fontSize": "0.72rem", "color": "#6b7a8d"}),
], style={"marginBottom": "6px"})
_comp_table = html.Div([
_comp_legend,
dbc.Table([
html.Thead(html.Tr([html.Th(c, style=_th_yellow_comp) for c in
["Post", "Type", "Date", "Impressions", "Clicks", "CTR %", "Real Eng %",
"Likes", "Comments", "Reposts", "Read (Audience only, Team excluded)"]])),
html.Tbody([_comp_row(r) for _, r in _comp_df.iterrows()]),
], bordered=True, size="sm", style={"fontSize": "0.78rem", "marginBottom": "0", "width": "100%"}),
], style={"flex": "1", "minWidth": "0"})
_engagement_composition_info_content = html.Div([
_REAL_ENG_FORMULA_EXAMPLE,
html.Div([
html.Strong("Real Eng % is hand-audited."), " Likes, Comments, and Reposts are split "
"Audience vs Team — Team (TXproducts staff) are excluded from the formula.",
], style={"marginBottom": "8px"}),
html.Div([
html.Strong("Impressions, Clicks, CTR %"), " stay plain, never split — these can't be "
"audited, since LinkedIn never reveals who clicked or was shown a post.",
], style={"marginBottom": "8px", "color": "#6b7a8d"}),
html.Div([
"Only ", html.Strong("Audience"), " numbers feed Real Eng % and the Read. Unaudited "
"posts show ", html.Strong("Pending audit"), ".",
], style={"marginBottom": "10px", "color": "#6b7a8d"}),
_CTR_FORMULA_EXAMPLE,
])
_reach_sticker = _most_reach_sticker(_comp_df)
composition_card = section(
"Engagement Composition",
html.Div([
_comp_table,
] + ([
html.Div(style={"width": "1px", "backgroundColor": "#e0e7ef", "margin": "0 20px",
"alignSelf": "stretch"}),
html.Div(_reach_sticker, style={"flexShrink": "0", "display": "flex", "alignItems": "center"}),
] if _reach_sticker.children else []),
style={"display": "flex", "alignItems": "stretch"}),
info=("engagement-composition", _engagement_composition_info_content),
title_style={"fontSize": "0.78rem"},
)
_impressions_fmt_fig = impressions_by_format_figure(posts_f_all)
_impressions_fmt_fig.update_xaxes(title=None)
_engagement_fmt_fig = engagement_by_format_figure(posts_f_all)
_engagement_fmt_fig.update_xaxes(title=None)
_ctr_fmt_fig = ctr_by_format_figure(posts_f_all)
_ctr_fmt_fig.update_xaxes(title=None)
_scatter_fig = impressions_vs_engagement_figure(posts_f_all)
mirror_row1 = dbc.Row([
dbc.Col(section("Impressions by Format",
G(_impressions_fmt_fig, style={"height": "180px"},
margin=CONTENT_BAR_MARGIN), title_style={"fontSize": "0.72rem"}), md=3),
dbc.Col(section("Avg. Engagement Rate by Format",
G(_engagement_fmt_fig, style={"height": "180px"},
margin=CONTENT_BAR_MARGIN), title_style={"fontSize": "0.72rem"}), md=3),
dbc.Col(section("Avg. CTR by Format",
G(_ctr_fmt_fig, style={"height": "180px"},
margin=CONTENT_BAR_MARGIN), title_style={"fontSize": "0.72rem"}), md=3),
dbc.Col(section("Impressions vs Engagement Rate",
G(_scatter_fig, style={"height": "180px"}, legend=CONTENT_SCATTER_LEGEND),
title_style={"fontSize": "0.72rem"}), md=3),
], className="gx-3 mb-3")
_reactions_fig = reactions_figure(cts_f)
_reactions_fig.update_xaxes(title=None, tickformat="%b %d")
_clicks_fig = clicks_figure(cts_f)
_clicks_fig.update_xaxes(title=None, tickformat="%b %d")
mirror_row2 = dbc.Row([
dbc.Col(section(f"Daily Reactions, Comments & Reposts, {period_label}",
G(_reactions_fig, style={"height": "180px"})), md=6),
dbc.Col(section(f"Daily Clicks, {period_label}",
G(_clicks_fig, style={"height": "180px"})), md=6),
], className="gx-3 mb-3")
return dbc.Container([
kpi_row,
composition_card,
mirror_row1,
mirror_row2,
], fluid=True, className="pb-3")
def _follower_growth_insights_page(month_value="2026-07"):
"""Mirrors the Overview tab's own Follower Gains calendar and New Followers per
Day chart, plus the Growth tab's Total Followers Over Time and Rolling Best
charts — all pinned to the selected month (no year/month picker, no History Log
table). The calendar drops zero-gain post days (include_zero_gain_posts=False):
this page is scoped to growth days specifically, not the combined post+growth
view Overview/Followers show."""
stats = report.compute_period_stats(_build_report_ctx(), month_value)
period_label = stats["period_label"]
year, month = (int(p) for p in month_value.split("-"))
kpi_row = html.Div([
html.Div(kpi("Month Start",
f"{stats['start_of_month_followers']:,}" if stats["start_of_month_followers"] is not None else "—"),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("New Followers", f"{stats['new_followers']:,}", color=ORANGE),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Growth Rate",
f"{stats['monthly_growth_rate']:+.1f}%" if stats["monthly_growth_rate"] is not None else "—",
color=GREEN if (stats["monthly_growth_rate"] or 0) >= 0 else "#d43f3a"),
style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"})
mirror_row = dbc.Row([
dbc.Col(section(f"Follower Gains — Calendar, {period_label} (growth days only)",
html.Div([
_month_day_tiles(D["fts"], D["posts"], year, month, include_zero_gain_posts=False),
calendar_legend(),
], style={"height": "270px", "display": "flex", "flexDirection": "column"})
), md=8),
dbc.Col(section(f"New Followers per Day, {period_label.split()[0]} (★ = post)",
G(daily_followers_figure(D["fts"], D["posts"], year, month), style={"height": "270px"})
), md=4),
], className="gx-3 mb-3")
growth_history = growth.load_history(UPLOAD_DIR)
month_history = [h for h in growth_history if h["date"].startswith(month_value)]
if not month_history:
growth_row = dbc.Alert(f"No growth-tracking history recorded for {period_label} yet.", color="info")
else:
growth_row = dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader(f"Total Followers Over Time, {period_label}"),
dbc.CardBody(G(growth_trend_figure(month_history), style={"height": "260px"})),
], className="mb-3 shadow-sm"), md=6),
dbc.Col(dbc.Card([
dbc.CardHeader([html.Strong(f"Rolling Best, {period_label}"),
info_popover("fg-insights-growth-pace", GROWTH_OBJECTIVE_EXPLAINER)]),
dbc.CardBody(G(growth_pace_figure(month_history), style={"height": "260px"})),
], className="mb-3 shadow-sm"), md=6),
])
return dbc.Container([
kpi_row,
mirror_row,
growth_row,
], fluid=True, className="pb-3")
def _core_message_insights_page(month_value="2026-07"):
"""Evaluates the selected month against the Core Message document itself
(Core_Message.pdf), not as a compliance check but as the document's own revision
cycle in action — it says its own Analyze/Extract/Formulate/Validate steps are
"also the framework for revising it" (p.2) once real audience response comes in.
Structure, agreed by brainstorm 2026-08-07, corrected 2026-08-08 (p.8 is
Methodology feeding Formulate, not its own step — the real 4 steps are
p.3/p.4/p.5/p.6):
p.2 four-step framework + the 5 documented revisit triggers
p.3 ANALYZE — the original audience/differentiator guess (made without deep
industry research) vs. who actually showed up. Same Directory data as the
Audience Composition card/page and Follower Study — worth naming that
overlap rather than presenting it as independent evidence. Fully
computable per month via the Directory.
p.4 EXTRACT, p.5 FORMULATE, p.6 VALIDATE — all three are hand-curated per
month (trilogy identification, Rational/Emotional register
classification, validation check), the same kind of manual work behind
the Follower Directory and the Audience-vs-Team audit. Only July has
been done so far; other months show a plain "not yet written up" card
instead of pretending July's specific findings apply elsewhere.
"""
period_label = report._period_label(month_value)
is_july = month_value == "2026-07"
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value)
_th_yellow = {"backgroundColor": "#fdf6d8"}
func_table = dbc.Table([
html.Thead(html.Tr([html.Th(f"Function ({period_label} cohort)", style=_th_yellow),
html.Th("Count", style=_th_yellow)])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["function"].most_common()]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
sector_table = dbc.Table([
html.Thead(html.Tr([html.Th(f"Sector ({period_label} cohort)", style=_th_yellow),
html.Th("Count", style=_th_yellow)])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["sector"].most_common()]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
_CORE_MESSAGE_ICONS = {
"Analyze": "bi-search", "Extract": "bi-funnel",
"Formulate": "bi-chat-quote", "Validate": "bi-clipboard-check",
}
def _flow_box(label):
return html.Div([
html.I(className=f"bi {_CORE_MESSAGE_ICONS[label]}",
style={"fontSize": "1.4rem", "color": BLUE}),
html.Div(label, style={"color": BLUE, "fontWeight": "600", "fontSize": "0.95rem"}),
], style={
"display": "flex", "alignItems": "center", "gap": "8px",
"border": f"2px solid {BLUE}", "borderRadius": "8px", "padding": "8px 18px",
"backgroundColor": "white",
})
def _flow_arrow():
return html.Div(style={
"width": "0", "height": "0", "flexShrink": "0",
"borderTop": "11px solid transparent", "borderBottom": "11px solid transparent",
"borderLeft": f"15px solid {NAVY2}",
})
flow_diagram = html.Div([
_flow_box("Analyze"), _flow_arrow(),
_flow_box("Extract"), _flow_arrow(),
_flow_box("Formulate"), _flow_arrow(),
_flow_box("Validate"),
], style={"display": "flex", "alignItems": "center", "gap": "10px", "flexWrap": "wrap"})
framework_card = dbc.Card([
dbc.CardHeader(html.Span("Four Step Core Message Framework", style={"color": "#FFD54F"})),
dbc.CardBody(
html.Div([
flow_diagram,
html.P(
"These four steps were used to develop the messaging. They are also "
"the framework for revising it.",
style={"fontSize": "0.85rem", "textAlign": "center", "margin": "0"}),
], style={"display": "flex", "alignItems": "center", "gap": "24px", "flexWrap": "wrap"})
),
], className="mb-3 shadow-sm")
audience_card = dbc.Card([
dbc.CardHeader(html.Span("ANALYZE: Who is the target audience and what is their "
"problem? (p.3)", style={"color": "#FFD54F"})),
dbc.CardBody([
html.P(
(f"{cohort['sector'].most_common(1)[0][0]} is the largest single sector "
"this month." if cohort["total"] else f"No {period_label} cohort profiled yet."),
style={"fontSize": "0.85rem", "marginBottom": "10px"}),
dbc.Row([dbc.Col(sector_table, md=6), dbc.Col(func_table, md=6)]),
]),
], className="shadow-sm h-100")
if not is_july:
_not_yet_written_up = dbc.CardBody(
html.P(f"Not yet written up for {period_label} — this section is hand-curated "
"per month (trilogy identification, Rational/Emotional register "
"classification, validation check), the same kind of manual work behind "
"the Audience-vs-Team audit. Only July has been done so far.",
style={"fontSize": "0.85rem", "color": "#9a9a9a", "fontStyle": "italic"}))
extract_card = dbc.Card([
dbc.CardHeader(html.Span("EXTRACT: The Formula and the Final Message (p.4)",
style={"color": "#FFD54F"})),
_not_yet_written_up,
], className="shadow-sm h-100")
register_card = dbc.Card([
dbc.CardHeader(html.Span("FORMULATE: Boilerplate Register (p.5)", style={"color": "#FFD54F"})),
_not_yet_written_up,
], className="shadow-sm h-100")
validate_card = dbc.Card([
dbc.CardHeader(html.Span("VALIDATE (p.6)", style={"color": "#FFD54F"})),
_not_yet_written_up,
], className="shadow-sm h-100")
analyze_extract_row = html.Div(
[audience_card, extract_card],
style={"display": "grid", "gridTemplateColumns": "1fr 1fr", "gap": "1rem", "marginBottom": "1rem"},
)
formulate_validate_row = html.Div(
[register_card, validate_card],
style={"display": "grid", "gridTemplateColumns": "4fr 1fr", "gap": "1rem", "marginBottom": "1rem"},
)
return dbc.Container([framework_card, analyze_extract_row, formulate_validate_row],
fluid=True, className="pb-3")
extract_card = dbc.Card([
dbc.CardHeader(html.Span("EXTRACT: The Formula and the Final Message (p.4)",
style={"color": "#FFD54F"})),
dbc.CardBody([
html.P(
"Focusing on \"SO THAT\" from page 4's formula, as this is the part of the "
"message meant to speak directly to the audience.",
style={"fontSize": "0.85rem"}),
html.P(
"We seem to have something closer to an actual playbook where we clearly "
"see the value proposition.",
style={"fontSize": "0.85rem"}),
html.Ul([
html.Li([html.Strong("14/07"), " — introduces the problem (operation modes, "
"frustration of waiting)"]),
html.Li([html.Strong("28/07"), " — deepens it with the Ferris wheel analogy, "
"ends on a cliffhanger CTA"]),
html.Li([html.Strong("30/07"), " — delivers the solution, in Daniel's own "
"voice, closing the loop"]),
], style={"fontSize": "0.85rem"}),
html.P(
"The trilogy of three posts brings in honesty in a way that matters for "
"credibility, further underlined by technical substance delivered through "
"a personal-voice format.",
style={"fontSize": "0.85rem"}),
]),
], className="shadow-sm h-100")
register_left = html.Div([
_rational_emotional_gauge(75, "July"),
_july_register_table(),
], style={"paddingRight": "16px", "borderRight": "1px solid #e0e7ef"})
register_right = html.Div([
html.Div([
_dimension_card("Prior Knowledge and Language", [
"This cohort is mostly Academic and Engineering/Technical and understands "
"the underlying science.",
]),
_dimension_card("Decision Context", [
"Most of this cohort do not have a budget-holding role, so they're not "
"positioned to act on a rational business case.",
]),
_dimension_card("Trust and Reservations", [
"Emotional posts outperformed the rational ones. Leading with a human touch "
"to a skeptical audience wins.",
]),
], style={"display": "flex", "flexDirection": "column", "gap": "12px"}),
], style={"paddingLeft": "16px"})
register_card = dbc.Card([
dbc.CardHeader(html.Span(
"FORMULATE: Boilerplate Register (p.5), Analytical Dimensions Methodology "
"(p.8) - (applied as interpretive judgment calls, not computed stats)",
style={"color": "#FFD54F"})),
dbc.CardBody(
html.Div([register_left, register_right],
style={"display": "grid", "gridTemplateColumns": "2fr 1fr", "gap": "0"})
),
], className="shadow-sm h-100")
validate_card = dbc.Card([
dbc.CardHeader(html.Span("VALIDATE (p.6)", style={"color": "#FFD54F"})),
dbc.CardBody([
html.P([
"Validation was tried with the July 21 poll in which ",
html.Strong("zero"),
" people took part in.",
], style={"fontSize": "0.85rem"}),
html.P(
"That points to one of two things: the question wasn't formulated to invite "
"a response, or this audience doesn't engage with the poll format at all.",
style={"fontSize": "0.85rem"}),
]),
], className="shadow-sm h-100")
analyze_extract_row = html.Div(
[audience_card, extract_card],
style={"display": "grid", "gridTemplateColumns": "1fr 1fr", "gap": "1rem", "marginBottom": "1rem"},
)
formulate_validate_row = html.Div(
[register_card, validate_card],
style={"display": "grid", "gridTemplateColumns": "4fr 1fr", "gap": "1rem", "marginBottom": "1rem"},
)
return dbc.Container([framework_card, analyze_extract_row, formulate_validate_row],
fluid=True, className="pb-3")
def _composition_count_table(counter, header):
_th_yellow = {"backgroundColor": "#fdf6d8"}
return dbc.Table([
html.Thead(html.Tr([html.Th(header, style=_th_yellow), html.Th("Count", style=_th_yellow)])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))])
for k, v in (counter.most_common() if hasattr(counter, "most_common") else counter)]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
_NOTABLE_SENIORITIES = ("C-Level/Founder", "Director/Head/Principal")
def _audience_composition_findings(cohort, month_rows, period_label):
"""Turns the raw Sector/Function/Seniority/Country counts into three named
findings instead of leaving a reader to notice them in four separate tables —
computed fresh from whatever month is selected, not hardcoded prose, so it
stays true for every month rather than just describing July forever."""
total = cohort["total"]
top_sector, top_sector_n = cohort["sector"].most_common(1)[0]
top_country, top_country_n = cohort["country"][0]
notable_this_month = [d for d in month_rows if d.get("seniority") in _NOTABLE_SENIORITIES]
bullets = [
f"{top_sector_n} of {total} new followers this month are {top_sector} — the "
f"largest single group.",
f"{top_country_n} of {total} are based in {top_country}"
+ (f" ({top_country_n / total * 100:.0f}%)." if total else "."),
]
if notable_this_month:
names = ", ".join(f"{d.get('name', '')} ({d.get('company', '') or d.get('seniority', '')})"
for d in notable_this_month)
bullets.append(
f"{len(notable_this_month)} notable (C-Level/Founder or Director/Head/Principal) "
f"new follower{'s' if len(notable_this_month) != 1 else ''} this month: {names}.")
return dbc.Card([
dbc.CardHeader("What This Month's Followers Tell Us"),
dbc.CardBody(html.Ul([html.Li(b, style={"marginBottom": "4px"}) for b in bullets],
style={"fontSize": "0.85rem", "paddingLeft": "1.1rem", "marginBottom": "0"})),
], className="mb-3 shadow-sm")
def _audience_composition_insights_page(month_value="2026-07"):
"""Sector/Function/Seniority/Country, all four together, for the selected
month's cohort specifically — the full demographic/firmographic profile of who
joined that month, proving each entry was individually researched and
classified, not auto-tagged. Follower Study carries the same four dimensions at
full historical scale plus a Sector x Seniority cross-tab."""
period_label = report._period_label(month_value)
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value)
if not cohort["total"]:
return dbc.Container([
dbc.Alert(f"No {period_label} cohort profiled yet.", color="info"),
], fluid=True, className="pb-3")
month_rows = [d for d in DIRECTORY_DATA if d.get("followedSort") == month_value]
return dbc.Container([
_audience_composition_findings(cohort, month_rows, period_label),
dbc.Card([
dbc.CardHeader(f"{period_label}'s Followers, by Sector, Function, Seniority and Country"),
dbc.CardBody(dbc.Row([
dbc.Col(_composition_count_table(cohort["sector"], "Sector"), md=3),
dbc.Col(_composition_count_table(cohort["function"], "Function"), md=3),
dbc.Col(_composition_count_table(cohort["seniority"], "Seniority"), md=3),
dbc.Col(_composition_count_table(cohort["country"], "Country"), md=3),
])),
], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
def _monthly_notes_insights_page(month_value="2026-07"):
"""Full detail behind the Monthly Notes card's one-line description — moved to its
own page so the Reports tab's Audience Composition / Monthly Notes row can stay as
compact side-by-side abstracts, same pattern as Core Message / Campaign Roadmap."""
period_label = report._period_label(month_value)
notes = monthly_report_notes.NOTES.get(month_value)
if not notes:
return dbc.Alert(f"Notes for {period_label} have not been written yet.", color="info")
return dbc.Container([
dbc.Card([
dbc.CardHeader(f"Monthly Notes — {period_label}"),
dbc.CardBody([
_narrative_section("Features / Fixes Shipped", notes.get("features_shipped", [])),
_narrative_section("Data Quality Issues Found and Fixed", notes.get("data_quality", [])),
_narrative_section("Follower Study / Directory Updates", notes.get("follower_study", [])),
_narrative_section("Outstanding", notes.get("outstanding", [])),
]),
], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
def _sector_seniority_crosstab(rows):
"""Sector x Seniority pivot over the whole (pre-August) Directory — only
meaningful at this scale (102 people), which is why it lives here rather than
on the July-only Audience Composition page (n=22, too sparse to read)."""
from collections import Counter
sectors = Counter(r.get("sector", "Unknown") for r in rows).most_common(6)
seniorities = [s for s, _ in Counter(r.get("seniority", "Unknown") for r in rows).most_common(6)]
sector_names = [s for s, _ in sectors]
grid = {s: Counter() for s in sector_names}
for r in rows:
s = r.get("sector", "Unknown")
if s in grid:
grid[s][r.get("seniority", "Unknown")] += 1
_th_yellow = {"backgroundColor": "#fdf6d8"}
return dbc.Table([
html.Thead(html.Tr([html.Th("Sector \\ Seniority", style=_th_yellow)] +
[html.Th(sen, style={**_th_yellow, "fontSize": "0.68rem"}) for sen in seniorities])),
html.Tbody([
html.Tr([html.Td(s, style={"fontWeight": "600", "whiteSpace": "nowrap"})] +
[html.Td(grid[s].get(sen, 0) or "") for sen in seniorities])
for s in sector_names
]),
], bordered=True, size="sm", style={"fontSize": "0.78rem"})
_SENIOR_TIERS = ("C-Level/Founder", "Director/Head/Principal", "Senior IC/Lead")
_JUNIOR_TIERS = ("Student/Trainee",)
def _follower_study_findings(whole, non_team, period_label, month_rows=None, clusters=None):
"""Two dynamically-computed cumulative findings in the same spirit as
assets/follower_study.html's own hand-written ones ("bimodal seniority,"
"shallow-network audience") — but computed live from whatever's in the
Directory through the selected month, not hardcoded to July's specific
numbers, so the finding stays honest for any month instead of silently
describing a snapshot that's since moved on.
Plus, ahead of those two, what actually happened THIS month specifically —
a monthly report showing "112 followers to date" as its headline is
answering the wrong question; the reader wants to know what changed in the
period this report covers, with the cumulative picture as supporting
context, not the other way around."""
total = whole["total"]
if not total:
return html.Div()
senior_n = sum(whole["seniority"].get(t, 0) for t in _SENIOR_TIERS)
junior_n = sum(whole["seniority"].get(t, 0) for t in _JUNIOR_TIERS)
first_degree_n = sum(1 for d in non_team if d.get("connection") == "1st")
this_month_bullets = []
if month_rows:
new_notable = [d for d in month_rows if d.get("seniority") in _NOTABLE_SENIORITIES]
if new_notable:
names = ", ".join(f"{d.get('name', '')} ({d.get('company', '') or d.get('seniority', '')})"
for d in new_notable)
this_month_bullets.append(
f"{len(new_notable)} new notable contact{'s' if len(new_notable) != 1 else ''} "
f"joined this month: {names}.")
if clusters:
from collections import Counter
month_companies = Counter(d.get("company", "").strip() for d in month_rows
if d.get("company", "").strip())
grown = [f"{c} ({total_n} total, {month_companies[c]} new this month)"
for c, total_n in clusters if month_companies.get(c)]
if grown:
this_month_bullets.append("Account clusters that grew this month: "
+ "; ".join(grown) + ".")
this_month_bullets = [html.Li(html.Strong(b), style={"marginBottom": "4px"}) for b in this_month_bullets]
if not this_month_bullets:
this_month_bullets = [html.Li(html.Em("No new notable contacts or growing account "
"clusters this month."), style={"marginBottom": "4px"})]
overall_bullets = [
f"Overall, {senior_n} of {total} ({senior_n / total * 100:.0f}%) sit at C-Level/Founder, "
f"Director/Head/Principal, or Senior IC/Lead level, while {junior_n} "
f"({junior_n / total * 100:.0f}%) are students or trainees — both ends of the "
f"seniority range are represented, not just the middle.",
f"{first_degree_n} of {total} ({first_degree_n / total * 100:.0f}%) are 1st-degree "
f"connections — the rest found the Page through shares, group overlap, or "
f"algorithmic surfacing rather than a direct personal tie.",
]
return dbc.Card([
dbc.CardHeader(f"What Changed This Month vs. The Full Picture"),
dbc.CardBody([
html.Div(f"New this month ({period_label}):", style={"fontWeight": "700", "fontSize": "0.82rem",
"marginBottom": "4px"}),
html.Ul(this_month_bullets, style={"fontSize": "0.85rem", "paddingLeft": "1.1rem",
"marginBottom": "12px"}),
html.Div("The full picture, to date:", style={"fontWeight": "700", "fontSize": "0.82rem",
"color": "#6b7a8d", "marginBottom": "4px"}),
html.Ul([html.Li(b, style={"marginBottom": "4px"}) for b in overall_bullets],
style={"fontSize": "0.85rem", "paddingLeft": "1.1rem", "marginBottom": "0",
"color": "#6b7a8d"}),
]),
], className="mb-3 shadow-sm")
def _follower_study_insights_page(month_value="2026-07"):
"""A native Dash page for Follower Study, computed live from the Directory, rather
than re-narrating the hand-authored assets/follower_study.html here — that would
just create a second copy of the same numbers to keep in sync by hand (the exact
Study/Directory drift problem already known to happen). Scoped to entries followed
on or before the selected month, so picking July shows the July report's own
snapshot rather than a preview of later months already in the Directory.
This is the "depth of manual work" page: the same four dimensions as Audience
Composition but across everyone profiled to date, a Sector x Seniority
cross-tab only meaningful at this scale, account clusters (2+ followers from the
same company — a composition fact, not an outreach claim: LinkedIn's free tier
doesn't support in-app outreach, so that angle is deliberately not pursued here),
and named individual VIP-tier contacts."""
period_label = report._period_label(month_value)
directory_as_of_month = [d for d in DIRECTORY_DATA
if not d.get("followedSort") or d.get("followedSort") <= month_value]
non_team = [d for d in directory_as_of_month if not d.get("is_team")]
whole = follower_directory_data.composition_stats(non_team)
month_rows = [d for d in non_team if d.get("followedSort") == month_value]
from collections import Counter
companies = Counter(d.get("company", "").strip() for d in non_team if d.get("company", "").strip())
clusters = [(c, n) for c, n in companies.most_common() if n >= 2]
month_companies = Counter(d.get("company", "").strip() for d in month_rows
if d.get("company", "").strip())
# Followers column shows this month's contribution inline (e.g. "5 (+3 this
# month)") rather than a flat all-time count with no monthly story — the whole
# point of surfacing this in an End of Month report is seeing where this
# month's people actually landed, not repeating a near-identical cumulative
# table every month with a marginally bigger number.
cluster_table = dbc.Table([
html.Thead(html.Tr([html.Th("Company", style={"backgroundColor": "#fdf6d8"}),
html.Th("Followers", style={"backgroundColor": "#fdf6d8"})])),
html.Tbody([
html.Tr([
html.Td(c),
html.Td([str(n), html.Span(f" (+{month_companies[c]} this month)",
style={"color": "#2e7d32", "fontWeight": "600"})
if month_companies.get(c) else None]),
]) for c, n in clusters
]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
notable = [d for d in non_team if d.get("seniority") in _NOTABLE_SENIORITIES]
notable.sort(key=lambda d: (d.get("seniority") != "C-Level/Founder", d.get("name", "")))
def _notable_row(d):
# Rows added this month are highlighted in place, in context with everyone
# else at that seniority level, rather than only being called out in a
# separate list. Bootstrap's table CSS sets its own background on <td>
# itself, which paints over a <tr>-level style — the highlight has to go
# on each cell.
td_style = {"backgroundColor": "#e8f5e9"} if d.get("followedSort") == month_value else {}
return html.Tr([html.Td(d.get("name", ""), style=td_style),
html.Td(d.get("seniority", ""), style=td_style),
html.Td(d.get("company", ""), style=td_style),
html.Td(d.get("country", ""), style=td_style)])
notable_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Name", "Seniority", "Company", "Country"]])),
html.Tbody([_notable_row(d) for d in notable]),
], bordered=True, size="sm")
return dbc.Container([
_follower_study_findings(whole, non_team, period_label, month_rows, clusters),
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader("Sector x Seniority (cumulative, to date)"),
dbc.CardBody(_sector_seniority_crosstab(non_team)),
], className="mb-3 shadow-sm h-100"), md=7),
dbc.Col(dbc.Card([
dbc.CardHeader("Account Clusters (2+ Followers, Same Company)"),
dbc.CardBody(cluster_table),
], className="mb-3 shadow-sm h-100"), md=5),
]),
dbc.Card([
dbc.CardHeader("Notable Contacts (C-Level/Founder and Director/Head/Principal)"),
dbc.CardBody([
html.P([
f"{len(notable)} followers sit at C-Level/Founder or Director/Head/Principal "
"seniority (internal team members excluded). ",
html.Span("Highlighted rows", style={"backgroundColor": "#e8f5e9", "fontWeight": "600"}),
" joined this month.",
], style={"fontSize": "0.85rem"}),
notable_table,
]),
], className="mb-3 shadow-sm"),
dbc.Card([
dbc.CardBody(
_insights_link_external("Click here for the full Follower Study",
"/assets/follower_study.html")
),
], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
# Not in NAV_ITEMS/the sidebar — reached only via the "Click here for the insights"
# links on the Reports tab's Core Message / Campaign Roadmap cards, same trick as
# growth-indicator-btn (a non-sidebar Input into the shared _switch_tab callback).
#
# Builder FUNCTIONS, not pre-built content — each insights page must reflect
# whichever month is selected on Report Overview (reports-selected-month), so it's
# called fresh on every visit via _switch_to_insights_page rather than computed once
# at startup and cached in TAB_CONTENT_MAP the way the non-Reports tabs are.
_INSIGHTS_PAGE_BUILDERS = {
"roadmap-insights": _roadmap_insights_page,
"all-posts-insights": _all_posts_insights_page,
"follower-growth-insights": _follower_growth_insights_page,
"core-message-insights": _core_message_insights_page,
"audience-composition-insights": _audience_composition_insights_page,
"monthly-notes-insights": _monthly_notes_insights_page,
"follower-study-insights": _follower_study_insights_page,
}
@app.callback(
Output("reports-title", "children"),
Output("reports-content-area", "children"),
Output("reports-selected-month", "data"),
Input("reports-month-select", "value"),
)
def _render_reports_tab(month_value):
# "campaign-end" isn't a real YYYY-MM period (no per-post data behind it), so the
# shared store keeps whatever real month was last selected rather than adopting a
# value none of the insights pages know how to compute against.
store_value = month_value if month_value and month_value != "campaign-end" else no_update
if not month_value:
return "", dbc.Alert("No campaign months have been written up yet.", color="info"), store_value
if month_value == "campaign-end":
title = html.H3("End of Campaign Report", style={"color": "#1b3a5c", "marginBottom": "0",
"fontWeight": "700", "fontSize": "2rem",
"whiteSpace": "nowrap"})
placeholder = dbc.Alert(
"The End of Campaign report has its own design, not built yet, this isn't just "
"another empty month. Check back once the 3-month campaign wraps up.",
color="info")
return title, placeholder, store_value
stats = report.compute_period_stats(_build_report_ctx(), month_value)
notes = monthly_report_notes.NOTES.get(month_value)
fts_f, posts_f = stats["fts_f"], stats["posts_f"]
title_heading = html.H3(f"{stats['period_label']} End of Month Campaign Report",
style={"color": "#1b3a5c", "marginBottom": "0", "fontWeight": "700",
"fontSize": "2rem", "whiteSpace": "nowrap"})
_reports_label_style = {"fontSize": "0.56rem", "whiteSpace": "nowrap"}
growth_rate_info = info_popover(
"reports-growth-rate",
html.Div([
html.Div(html.B("Monthly Growth Rate"), style={"marginBottom": "6px"}),
html.Div(
"This month's net new followers as a percentage of the followers already "
"on the page at the start of the month. Resets every month against its own "
"starting count, not a single fixed baseline for the whole campaign.",
style={"color": "#6b7a8d"}),
html.Div(
f"{stats['period_label']} started at {stats['start_of_month_followers']:,} and "
f"gained {stats['new_followers']:,}." if stats["start_of_month_followers"] is not None else "",
style={"marginTop": "6px"}),
]),
)
_reports_big_centered_value = {"textAlign": "center", "fontSize": "1.2rem"}
# Fixed height so the Growth Rate tile's inline info_popover badge (22px) doesn't
# make that one label row taller than the other 8, which would push its value down
# out of alignment with the rest of the row.
_reports_centered_label = {**_reports_label_style, "textAlign": "center", "height": "18px",
"display": "flex", "alignItems": "center", "justifyContent": "center"}
# Orange, not the app's own navy accent (BLUE, #1b3a5c, already used everywhere else
# as headers/borders), sets this row apart from every other tab's tiles.
_REPORTS_ACCENT = ORANGE
_reports_card_style = {"boxShadow": "0 4px 14px rgba(0,0,0,0.18)"}
_reports_kpi_body_style = {"padding": "0.4rem 0.7rem"}
kpi_row = html.Div([
html.Div(kpi("Month Start",
f"{stats['start_of_month_followers']:,}" if stats["start_of_month_followers"] is not None else "—",
color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("New Followers", f"{stats['new_followers']:,}", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Followers", f"{stats['end_of_month_followers']:,}" if stats["end_of_month_followers"] is not None else "—",
"month end", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Posts Published", f"{stats['posts_published']:,}", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Impressions", f"{stats['total_impressions']:,}", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Page Views", f"{stats['total_page_views']:,}", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Best Post Real Eng.", _fmt_best_eng(stats["best_eng"]), color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Avg. Eng. Rate", f"{stats['avg_eng']:.2f}%" if stats["avg_eng"] else "—",
color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style, body_style=_reports_kpi_body_style),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi(
"Growth Rate",
f"{stats['monthly_growth_rate']:+.1f}%" if stats["monthly_growth_rate"] is not None else "—",
color=GREEN if (stats["monthly_growth_rate"] or 0) >= 0 else "#d43f3a",
label_style=_reports_centered_label, label_extra=growth_rate_info,
value_style=_reports_big_centered_value, card_style=_reports_card_style, body_style=_reports_kpi_body_style,
), style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"})
_reports_abstract_body_style = {"fontSize": "0.8rem"}
# Core Message sizes to its own content (flex: 0 0 auto) instead of splitting the
# stack 50/50 with Roadmap — it no longer needs to match Roadmap's height, only the
# STACK as a whole needs to reach the row height below.
# 2026-08-07 layout experiment: CSS Grid instead of nested Bootstrap rows/cols, since
# this shape (Core Message + Roadmap side by side on top, Follower Growth spanning
# both of their combined width below, All Posts spanning both rows on the right)
# needs real cell-spanning that dbc.Row/Col can't express without deep nesting.
# Grid's own default stretch behavior handles the equal-height alignment natively
# (no more manual flex/h-100 juggling) — Core Message and Roadmap auto-match each
# other's row height, and All Posts auto-stretches to match Roadmap-row + Follower
# Growth-row combined. Prior (pre-grid) layout measurements, for reference/revert:
# Core Message 304x188.7, Roadmap 304x227.1 (stack 427.8 total), Follower Growth
# 304x427.8, All Posts 632x427.8, all four sharing top=290.1/bottom=717.95.
core_message_card = dbc.Card([
dbc.CardHeader("Core Message"),
dbc.CardBody([
_rich_text(notes.get("core_message_abstract", []), style=_reports_abstract_body_style)
if notes else html.Div("Not written up yet.", className="text-muted"),
_insights_link("/core-message-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "cm"})
roadmap_card = dbc.Card([
dbc.CardHeader("Campaign Roadmap"),
dbc.CardBody([
_rich_text(notes.get("roadmap_abstract", []), style=_reports_abstract_body_style)
if notes else html.Div("Not written up yet.", className="text-muted"),
_insights_link("/roadmap-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "rm"})
if fts_f.empty:
chart_content = html.Div("No follower data for this period.", className="text-muted")
else:
_reports_growth_fig = daily_followers_figure(fts_f, posts_f)
# No legend, no autorange padding above the tallest bar: this compact card is an
# at-a-glance summary (full chart with its legend lives on the Followers tab and
# via the "insights" link), so every pixel of its fixed height goes to bar height
# instead of chrome around it.
_reports_growth_fig.update_layout(showlegend=False)
# A star sitting exactly on the y=0 baseline (a post published on a day with no
# net follower change) gets its bottom half clipped by the plot area's own edge
# under rangemode="tozero" — small negative headroom keeps zero off that edge.
_reports_ymax = (fts_f["Organic followers"] + fts_f["Auto-invited followers"]).max()
_reports_growth_fig.update_yaxes(range=[-max(_reports_ymax * 0.12, 0.4), _reports_ymax * 1.15])
# Date tick labels dropped: the exact day is already on hover for every bar/star,
# and freeing the vertical space they took lets the card shrink without touching
# bar height itself.
_reports_growth_fig.update_xaxes(showticklabels=False)
# Total height reduced by roughly what the tick labels used to take, so the plot
# area (and thus bar height) stays the same as before instead of auto-expanding
# into the freed space — the box shrinks, the bars don't grow past what was approved.
chart_content = G(_reports_growth_fig, style={"height": "165px"}, margin=dict(t=10, b=2, l=10, r=10))
chart_card = dbc.Card([
dbc.CardHeader("Follower Growth"),
# Tighter padding than the other cards (8px vs the dbc default 16px each side):
# the chart's own 190px height is fixed and not to be touched, so shaving the
# padding around it is the only real lever left to shrink this card's total
# height without shrinking the bars themselves.
dbc.CardBody([
chart_content,
_insights_link("/follower-growth-insights"),
], style={**_reports_card_body_style(), "padding": "8px"}),
], className="shadow-sm", style={"gridArea": "fg"})
posts_card = dbc.Card([
dbc.CardHeader([f"All Posts, {stats['period_label']} ",
info_popover("reports-posts-list", _REAL_ENG_AND_CTR_INFO)]),
dbc.CardBody([
_reports_posts_table(posts_f),
_insights_link("/all-posts-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "ap"})
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value)
if cohort["total"]:
composition_body = [_composition_table(cohort["sector"], "Sector (this month)")]
else:
composition_body = [html.P(
f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}, "
f"but none have been identified and profiled in the Follower Directory yet.",
style={"fontSize": "0.85rem"})]
composition_info = info_popover(
"reports-composition",
"The Follower Directory is updated by hand each time a new follower comes in, "
"so it can run ahead of the dashboard's weekly LinkedIn export. Seniority and "
"country breakdowns are on the insights page below.",
)
composition_card = dbc.Card([
dbc.CardHeader(["Audience Composition (Follower Directory Coverage)", composition_info]),
dbc.CardBody(composition_body + [_insights_link("/audience-composition-insights")],
style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "ac"})
_study_description = (
"Narrative analysis of who's following us: key findings, growth timeline, "
"seniority/function/sector/geography breakdowns, and notable organizational "
"clusters. Hand-maintained alongside the Directory, not computed from it."
)
study_card = dbc.Card([
dbc.CardHeader("Follower Study"),
dbc.CardBody([
html.P(_study_description, style={"fontSize": "0.8rem"}),
_insights_link("/follower-study-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "fs"})
charts_row = html.Div(
[core_message_card, roadmap_card, chart_card, posts_card],
# Single-quoted CSS strings, not double: the static exporter concatenates this
# value directly into an HTML style="..." attribute (itself double-quoted), so a
# double-quoted grid-template-areas value would terminate that attribute early
# and silently break the whole layout in the static snapshot (harmless live,
# since React sets styles via the DOM API, not string concatenation).
style={"display": "grid", "gridTemplateColumns": "1fr 1fr 2fr",
"gridTemplateAreas": "'cm rm ap' 'fg fg ap'",
"gap": "1rem", "marginBottom": "1rem"},
)
composition_row = html.Div(
[composition_card, study_card],
# Same column-fr-and-gap math as charts_row above (1fr 1fr 2fr, ac spanning the
# first two like fg does) so ac's pixel width matches Follower Growth's exactly —
# a plain "1fr 1fr" here computes a different width since it has one fewer gap.
style={"display": "grid", "gridTemplateColumns": "1fr 1fr 2fr", "gridTemplateAreas": "'ac ac fs'",
"gap": "1rem", "marginBottom": "1rem"},
)
return title_heading, html.Div([kpi_row, charts_row, composition_row]), store_value
@app.callback(
Output("reports-download", "data"),
Input("reports-download-btn", "n_clicks"),
State("reports-month-select", "value"),
prevent_initial_call=True,
)
def _download_reports_pdf(_n_clicks, month_value):
if month_value == "campaign-end":
return no_update
ctx_data = _build_report_ctx()
ctx_data["monthly_notes"] = monthly_report_notes.NOTES.get(month_value)
docx_bytes = report.build_report_docx(ctx_data, month_value)
label = next((o["label"] for o in _REPORTS_MONTH_OPTIONS if o["value"] == month_value), month_value)
filename = f"TXproducts_LinkedIn_Report_{label.replace(' ', '_')}.docx"
return dcc.send_bytes(lambda buf: buf.write(docx_bytes), filename=filename)
if __name__ == "__main__":
app.run(debug=False, port=8051)