Files
LinkedIn-Analytics-Dashboard/app.py
T
beantownbabe cbb308cf37 Add Core Message/Roadmap/Monthly Notes cards and cross-page nav to Reports tab
Restructures the Reports tab into a CSS Grid layout (Core Message + Campaign
Roadmap stacked, spanning Follower Growth below, alongside All Posts; Audience
Composition + Follower Study + Monthly Notes as a second row), each card
carrying a short abstract with a link to its full detail. Adds three new
"insights" sub-pages (Roadmap, Core Message, Monthly Notes) reachable via a
shared hero-banner nav so hopping between them doesn't require detouring
through the main Reports page each time. Also fixes two content-type
misclassifications (23/07, 30/07 should be Carousel) and trims chart chrome
(legend, axis labels/padding) to reclaim vertical space without shrinking bar
height.

PDF (report.py) and static export (build_static_export.py) intentionally not
yet updated to mirror these Reports tab changes — deferred until layout
iteration is done.
2026-08-07 12:29:21 +02:00

3006 lines
161 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
# 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",
}
# 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",
}
# ── 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)
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] + "…"
# Table should always read newest post first, regardless of which file/upload order
# contributed each row.
posts = posts.sort_values("Created date", ascending=False).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())
best_eng = float(D["posts"]["Eng %"].max()) if len(D["posts"]) else 0.0
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 Eng.", f"{best_eng:.1f}%", 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", "Eng %", "CTR %", "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.
post_highlight_styles = [{"if": {"row_index": "odd"}, "backgroundColor": "#f8fafc"}]
if not posts.empty:
best_eng_val = posts["Eng %"].max()
best_ctr_val = posts["CTR %"].max()
same_post_has_both = not posts[
(posts["Eng %"] == best_eng_val) & (posts["CTR %"] == best_ctr_val)
].empty
post_highlight_styles.append({
"if": {"filter_query": "{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 engagement rate")], style={"marginBottom": "3px"}),
html.Div("The metric LinkedIn's algorithm weighs most heavily when deciding whether to keep "
"distributing a post further.", 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 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."),
])
IMPRESSIONS_VS_ENGAGEMENT_EXPLAINER = html.Div([
html.Div("Each dot is a post — x is reach, y is engagement rate, color is content type, and bubble "
"size is clicks.", 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()
if selected_month and selected_month != "all":
year, month = (int(part) for part in selected_month.split("-"))
df = df[(posts["Created date"].dt.year == year) & (posts["Created date"].dt.month == month)]
df["Created date"] = df["Created date"].dt.strftime("%d/%m/%Y")
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):
df = _format_breakdown(posts_df, "Eng %", "mean")
fig = px.bar(df, x="Eng %", y="Content Type", orientation="h", template=TMPL,
color="Content Type", color_discrete_map=FORMAT_COLORS,
labels={"Eng %": "Engagement Rate (%)"})
return fig.update_layout(showlegend=False, bargap=0.6)
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):
fig = px.scatter(posts_df, x="Impressions", y="Eng %", color="Content Type", size="Clicks",
hover_name="Short title", template=TMPL, color_discrete_map=FORMAT_COLORS,
labels={"Eng %": "Engagement Rate (%)", "Content Type": ""})
return fig.update_traces(marker=dict(sizemin=8))
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()
_posts_table_df["Created date"] = _posts_table_df["Created date"].dt.strftime("%d/%m/%Y")
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": "Eng %"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
{"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", 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",
}
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):
"""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."""
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)
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={"padding": "0.6rem 0.8rem"}), className="h-100 shadow-sm", style=outer_style)
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):
"""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."""
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} | 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 PDF", 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],
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", "July's Month 1 checkpoint: traction by format, top posts by comments and shares, and best posting day."),
"core-message-insights": ("Core Message — Insights", "How July's new followers compare to the Core Message's foundation, by function and sector."),
"monthly-notes-insights": ("Monthly Notes — Insights", "Features/fixes shipped, data quality issues found and fixed, Follower Study/Directory updates, and outstanding items for July."),
}
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.8rem", "fontWeight": "600",
"textDecoration": "none", "padding": "7px 14px",
"backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px",
"border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap",
}
# The 3 sub-pages reachable from Reports cards, plus Reports itself — every one of
# these 4 gets a nav back to the other 3 (+ the external Follower Study) in its own
# hero banner, so hopping between insights doesn't require detouring through Reports
# each time.
_REPORT_SECTIONS = [
("reports", "Reports", "/reports"),
("roadmap-insights", "Roadmap Insights", "/roadmap-insights"),
("core-message-insights", "Core Message Insights", "/core-message-insights"),
("monthly-notes-insights", "Monthly Notes Insights", "/monthly-notes-insights"),
]
def _report_section_nav(active):
links = [dcc.Link(label, href=href, style=_REPORT_HERO_LINK_STYLE)
for key, label, href in _REPORT_SECTIONS if key != active]
links.append(html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE))
for link in links[1:]:
link.style = {**link.style, "marginLeft": "8px"}
return links
def _hero_actions(active):
"""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 {key for key, _, _ in _REPORT_SECTIONS}:
return _report_section_nav(active)
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.Button(label, id=f"nav-{key}", n_clicks=0,
className="sidebar-nav-btn active" if key == "overview" else "sidebar-nav-btn")
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.
app.layout = html.Div([dcc.Location(id="url-router", refresh=False), 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).
return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active),
_hero_actions(active), "/"]
_INSIGHTS_ROUTES = {"/roadmap-insights": "roadmap-insights", "/core-message-insights": "core-message-insights",
"/monthly-notes-insights": "monthly-notes-insights", "/reports": "reports"}
@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"),
prevent_initial_call=True,
)
def _switch_to_insights_page(pathname):
"""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."""
active = _INSIGHTS_ROUTES.get(pathname)
if active is None:
raise PreventUpdate
classes = ["sidebar-nav-btn" for _ in NAV_ITEMS]
hero_title = _hero_title_children(active)
return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), _hero_actions(active)]
@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():
"""Shared by the sidebar's generic PDF control and the Reports tab, so both build
the exact same ctx_data dict for report.build_report_pdf/compute_period_stats."""
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,
}
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 _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."""
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"}
return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in
["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]])),
html.Tbody([
html.Tr([
html.Td(str(r["Short title"]), title=str(r["Short title"]), style=_post_title_style),
html.Td(r["Created date"].strftime("%d/%m/%Y")),
html.Td(r["Content Type"]),
html.Td(f"{int(r['Impressions']):,}"),
html.Td(f"{r['Eng %']:.1f}%"),
html.Td(f"{r['CTR %']:.1f}%"),
]) for _, r in all_posts.iterrows()
]),
], bordered=True, size="sm", style={"fontSize": "0.75rem"})
def _roadmap_insights_page():
"""Reads the Roadmap's own Month 1 checkpoint (page 5): traction by format, top
posts by comments+shares, and best posting day, computed live from July's posts
rather than hand-typed, so it can't drift from the dashboard's own numbers."""
stats = report.compute_period_stats(_build_report_ctx(), "2026-07")
posts_f = stats["posts_f"].copy()
posts_f["Shares"] = posts_f["Comments"].fillna(0) + posts_f["Reposts"].fillna(0)
posts_f["Weekday"] = posts_f["Created date"].dt.day_name()
by_format = (posts_f.groupby("Content Type")
.agg(Posts=("Content Type", "size"), **{"Avg Eng %": ("Eng %", "mean")},
Shares=("Shares", "sum"))
.round(1).sort_values("Avg Eng %", ascending=False).reset_index())
format_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Format", "Posts", "Avg Eng %", "Comments + Shares"]])),
html.Tbody([html.Tr([html.Td(r["Content Type"]), html.Td(int(r["Posts"])),
html.Td(f"{r['Avg Eng %']}%"), html.Td(int(r["Shares"]))])
for _, r in by_format.iterrows()]),
], bordered=True, size="sm")
top_posts = posts_f.sort_values("Shares", ascending=False).head(3)
top_posts_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Post", "Date", "Format", "Comments", "Reposts"]])),
html.Tbody([html.Tr([html.Td(str(r["Short title"])[:60]), html.Td(r["Created date"].strftime("%d/%m/%Y")),
html.Td(r["Content Type"]), html.Td(int(r["Comments"])), html.Td(int(r["Reposts"]))])
for _, r in top_posts.iterrows()]),
], bordered=True, size="sm")
by_day = (posts_f.groupby("Weekday").agg(Posts=("Weekday", "size"), **{"Avg Eng %": ("Eng %", "mean")})
.round(1).sort_values("Avg Eng %", ascending=False).reset_index())
day_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Day", "Posts", "Avg Eng %"]])),
html.Tbody([html.Tr([html.Td(r["Weekday"]), html.Td(int(r["Posts"])), html.Td(f"{r['Avg Eng %']}%")])
for _, r in by_day.iterrows()]),
], bordered=True, size="sm")
return dbc.Container([
dbc.Card([dbc.CardHeader("Traction by Format"), dbc.CardBody(format_table)], className="mb-3 shadow-sm"),
dbc.Card([dbc.CardHeader("Top Posts by Comments + Shares"), dbc.CardBody(top_posts_table)],
className="mb-3 shadow-sm"),
dbc.Card([dbc.CardHeader("Best Posting Day"), dbc.CardBody(day_table)], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
def _core_message_insights_page():
"""Checks July's actual new followers against the Core Message's foundation: how
many sit in Research/Science or Engineering/Technical roles (the audience the
content itself speaks to), computed live from the Follower Directory."""
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, "2026-07")
func_table = dbc.Table([
html.Thead(html.Tr([html.Th("Function (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["function"].most_common()]),
], bordered=True, size="sm")
sector_table = dbc.Table([
html.Thead(html.Tr([html.Th("Sector (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["sector"].most_common()]),
], bordered=True, size="sm")
seniority_table = dbc.Table([
html.Thead(html.Tr([html.Th("Seniority (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["seniority"].most_common()]),
], bordered=True, size="sm")
country_table = dbc.Table([
html.Thead(html.Tr([html.Th("Country (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["country"]]),
], bordered=True, size="sm")
return dbc.Container([
dbc.Card([
dbc.CardHeader("July's Followers, by Function and Sector"),
dbc.CardBody([
html.P(f"{cohort['total']} new followers were identified and profiled in July.",
style={"fontSize": "0.85rem"}),
dbc.Row([dbc.Col(func_table, md=6), dbc.Col(sector_table, md=6)]),
]),
], className="mb-3 shadow-sm"),
dbc.Card([
dbc.CardHeader("July's Followers, by Seniority and Country"),
dbc.CardBody(dbc.Row([dbc.Col(seniority_table, md=6), dbc.Col(country_table, md=6)])),
], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
def _monthly_notes_insights_page():
"""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."""
notes = monthly_report_notes.NOTES.get("2026-07")
if not notes:
return dbc.Alert("Notes for this month have not been written yet.", color="info")
return dbc.Container([
dbc.Card([
dbc.CardHeader("Monthly Notes — July 2026"),
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")
# 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).
TAB_CONTENT_MAP["roadmap-insights"] = _roadmap_insights_page()
TAB_CONTENT_MAP["core-message-insights"] = _core_message_insights_page()
TAB_CONTENT_MAP["monthly-notes-insights"] = _monthly_notes_insights_page()
@app.callback(
Output("reports-title", "children"),
Output("reports-content-area", "children"),
Input("reports-month-select", "value"),
)
def _render_reports_tab(month_value):
if not month_value:
return "", dbc.Alert("No campaign months have been written up yet.", color="info")
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
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.5rem"}
# 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": "22px",
"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)"}
kpi_row = html.Div([
html.Div(kpi("Total Followers", f"{current_total_followers:,}", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_style),
style={"flex": "1", "minWidth": "0"}),
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),
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),
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),
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),
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),
style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Best Post Eng.", f"{stats['best_eng']:.1f}%", color=_REPORTS_ACCENT,
label_style=_reports_centered_label, value_style=_reports_big_centered_value,
card_style=_reports_card_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),
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,
), 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("/roadmap-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']}"),
dbc.CardBody([
_reports_posts_table(posts_f),
_insights_link("/roadmap-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("/core-message-insights")],
style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "ac"})
_notes_description = (
"Contains Features/Fixes Shipped, Data Quality Issues Found and Fixed, "
"Follower Study/Directory Updates, and any Outstanding areas that need "
"further attention."
)
notes_card = dbc.Card([
dbc.CardHeader("Monthly Notes"),
dbc.CardBody([
html.P(_notes_description, style={"fontSize": "0.8rem"}) if notes
else html.Div("Not written up yet.", className="text-muted"),
_insights_link("/monthly-notes-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "mn"})
_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_external("Click here for the study", "/assets/follower_study.html"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "fs"})
charts_row = html.Div(
[core_message_card, roadmap_card, chart_card, posts_card],
style={"display": "grid", "gridTemplateColumns": "1fr 1fr 2fr",
"gridTemplateAreas": '"cm rm ap" "fg fg ap"',
"gap": "1rem", "marginBottom": "1rem"},
)
composition_row = html.Div(
[composition_card, notes_card, study_card],
style={"display": "grid", "gridTemplateColumns": "1fr 1fr 1fr", "gridTemplateAreas": '"ac fs mn"',
"gap": "1rem", "marginBottom": "1rem"},
)
return title_heading, html.Div([kpi_row, charts_row, composition_row])
@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)
pdf_bytes = report.build_report_pdf(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(' ', '_')}.pdf"
return dcc.send_bytes(lambda buf: buf.write(pdf_bytes), filename=filename)
if __name__ == "__main__":
app.run(debug=False, port=8051)