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