New "Reports" tab for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory rather than a standalone HTML/PDF workflow. For each campaign month it shows KPI tiles (including a new Monthly Growth Rate, this month's net new followers as a percentage of the followers already on the page at the start of that month, resets every month against its own starting count), the follower growth chart, every post published that month, an Audience Composition breakdown sourced live from the Follower Directory (sector/seniority/country, both this month's cohort and the all-time snapshot), and curated monthly narrative notes (features shipped, data quality fixes, Directory updates, outstanding items). Reuses the existing reportlab PDF pipeline, extended with the same sections, and mirrored in the static exporter with live-only controls disabled. New files: monthly_report_notes.py (per-month curated narrative, July populated), follower_directory_data.py (parses the Directory's DATA array directly so numbers can't drift from the hand-maintained source of truth). Also fixes three pre-existing bugs found while building this: average engagement rate was displaying 100x too small (a 0-1 fraction shown as-is instead of scaled to a percentage), the Sector/Seniority/Country breakdown tables were silently truncated below their own stated totals (a hardcoded top-5 display cap plus a separate top-10 country cap inside composition_stats itself), and the static exporter had no support for dbc.Table, dropping the <table> tag entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2742 lines
145 KiB
Python
2742 lines
145 KiB
Python
"""TXproducts LinkedIn Analytics Dashboard"""
|
||
import warnings; warnings.filterwarnings("ignore")
|
||
|
||
import base64
|
||
import os
|
||
|
||
from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update
|
||
import dash_bootstrap_components as dbc
|
||
import plotly.express as px
|
||
import plotly.graph_objects as go
|
||
import pandas as pd
|
||
import report
|
||
import growth
|
||
import visitor_history
|
||
import monthly_report_notes
|
||
import follower_directory_data
|
||
|
||
# Overridable via env var so the same code runs unmodified in Docker/internal deployments —
|
||
# defaults to this machine's path for local dev.
|
||
DATA_DIR = os.environ.get("LINKEDIN_DATA_DIR", "/Users/mckinleyblack/Documents")
|
||
UPLOAD_DIR = os.path.join(DATA_DIR, "linkedin_analytics")
|
||
ASSETS_DIR = os.path.join(UPLOAD_DIR, "assets")
|
||
|
||
# Loaded once at startup, not re-parsed per callback — re-run the app to pick up new
|
||
# assets/follower_directory.html entries, same as every other data source here.
|
||
DIRECTORY_DATA = follower_directory_data.load_directory(ASSETS_DIR)
|
||
|
||
# LinkedIn's export only gives daily gains, never an absolute follower total. This is
|
||
# the one manual anchor point: the count going into the very first day this export
|
||
# ever covered (2026-06-16). Every other day's exact total is baseline + cumulative
|
||
# gains since, computed automatically — this constant should never need to change.
|
||
BASELINE_FOLLOWERS = 84
|
||
BASELINE_DATE = "2026-06-16"
|
||
|
||
# LinkedIn exports no caption text for some posts (image/video-only). Fill in titles by hand here.
|
||
MANUAL_POST_TITLES = {
|
||
"2026-07-09": "How it all started",
|
||
"2026-07-14": "Time resolution",
|
||
}
|
||
|
||
# LinkedIn's export doesn't distinguish carousel/document posts from generic Image — override by hand.
|
||
MANUAL_CONTENT_TYPES = {
|
||
"2026-07-09": "Carousel",
|
||
"2026-07-16": "Carousel",
|
||
"2026-07-21": "Poll",
|
||
}
|
||
|
||
# ── Palette — matched to txdata_project/portal.py's navy/blue design language ──
|
||
BLUE = "#1b3a5c" # navy — primary accent (was LinkedIn blue #0A66C2)
|
||
NAVY2 = "#2d6a9f" # secondary blue, used in gradients alongside BLUE
|
||
ORANGE = "#E8825A"
|
||
GREEN = "#52C41A"
|
||
AMBER = "#FFA726"
|
||
LIGHT_BLUE = "#4a90d9"
|
||
TEAL = "#2d6a9f" # aligned to the secondary blue instead of a separate teal
|
||
TMPL = "plotly_white"
|
||
BG = "rgba(0,0,0,0)"
|
||
|
||
FORMAT_COLORS = {
|
||
"Video": "#E8825A",
|
||
"Image": "#1b3a5c",
|
||
"Article": "#52C41A",
|
||
"Text": "#FFA726",
|
||
"Carousel": "#9C27B0",
|
||
"Poll": "#00838F",
|
||
"Unknown": "#888888",
|
||
}
|
||
|
||
# ── Chart helper ──────────────────────────────────────────────────────────────
|
||
def _std(fig):
|
||
fig.update_layout(
|
||
plot_bgcolor=BG, paper_bgcolor=BG,
|
||
margin=dict(t=30, b=30, l=10, r=10),
|
||
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
|
||
font=dict(size=11),
|
||
)
|
||
return fig
|
||
|
||
def _finalize_fig(fig, margin=None, legend=None):
|
||
"""Apply the shared _std() styling plus any per-chart margin/legend overrides —
|
||
used both when building a chart inline and when rebuilding one inside a callback,
|
||
so the two paths always render identically."""
|
||
fig = _std(fig)
|
||
if margin:
|
||
fig.update_layout(margin=margin)
|
||
if legend:
|
||
fig.update_layout(legend=legend)
|
||
return fig
|
||
|
||
def G(fig, style=None, margin=None, legend=None, **kw):
|
||
s = {"height": "320px"}
|
||
if style:
|
||
s.update(style)
|
||
fig = _finalize_fig(fig, margin=margin, legend=legend)
|
||
return dcc.Graph(figure=fig, config={"displayModeBar": False}, style=s, **kw)
|
||
|
||
def mark_posts(fig, fts_df, posts_df):
|
||
"""Overlay a star marker on days a post was published, with the post title on hover."""
|
||
if posts_df.empty:
|
||
return fig
|
||
day_totals = fts_df.set_index("Date")["Total followers"]
|
||
matched = posts_df[posts_df["Created date"].isin(fts_df["Date"])]
|
||
if matched.empty:
|
||
return fig
|
||
fig.add_trace(go.Scatter(
|
||
x=matched["Created date"],
|
||
y=[day_totals.get(d, 0) for d in matched["Created date"]],
|
||
mode="markers",
|
||
marker=dict(symbol="star", size=13, color="#E53935", line=dict(width=1, color="white")),
|
||
name="Post published",
|
||
customdata=matched["Short title"],
|
||
hovertemplate="<b>Post published — %{x|%b %d, %Y}</b><br>%{customdata}<extra></extra>",
|
||
))
|
||
return fig
|
||
|
||
|
||
def _is_linkedin_export(fname, keyword, extensions):
|
||
"""True only for real LinkedIn export files (e.g. txproducts_followers_<id>.xls) —
|
||
excludes unrelated files that happen to contain the keyword (like a manually-built
|
||
roster) and Office lock files (~$...) left behind by a file currently open in Excel."""
|
||
lower = fname.lower()
|
||
if lower.startswith("~$") or lower.startswith("."):
|
||
return False
|
||
if not lower.startswith("txproducts_"):
|
||
return False
|
||
return keyword.lower() in lower and lower.endswith(extensions)
|
||
|
||
|
||
def _find_latest_file(keyword, extensions=(".xls", ".xlsx")):
|
||
"""Most recently modified real LinkedIn export across DATA_DIR and UPLOAD_DIR whose
|
||
name contains `keyword` — replaces matching one exact hardcoded filename, so any
|
||
correctly-named LinkedIn export (including freshly uploaded ones) is picked up without
|
||
needing the file's exact auto-generated export ID."""
|
||
candidates = []
|
||
for folder in (DATA_DIR, UPLOAD_DIR):
|
||
if not os.path.isdir(folder):
|
||
continue
|
||
for fname in os.listdir(folder):
|
||
if _is_linkedin_export(fname, keyword, extensions):
|
||
candidates.append(os.path.join(folder, fname))
|
||
if not candidates:
|
||
raise FileNotFoundError(
|
||
f"No file containing '{keyword}' found in {DATA_DIR} or {UPLOAD_DIR}. "
|
||
"Upload the matching LinkedIn export to get the dashboard running.")
|
||
return max(candidates, key=os.path.getmtime)
|
||
|
||
|
||
def _find_all_files(keyword, extensions=(".xls", ".xlsx")):
|
||
"""Like _find_latest_file, but returns every match sorted oldest-to-newest instead of
|
||
just the newest — Content merges all historical exports rather than replacing them."""
|
||
candidates = []
|
||
for folder in (DATA_DIR, UPLOAD_DIR):
|
||
if not os.path.isdir(folder):
|
||
continue
|
||
for fname in os.listdir(folder):
|
||
if _is_linkedin_export(fname, keyword, extensions):
|
||
candidates.append(os.path.join(folder, fname))
|
||
return sorted(set(candidates), key=os.path.getmtime)
|
||
|
||
|
||
# ── Load data ─────────────────────────────────────────────────────────────────
|
||
def load():
|
||
d = {}
|
||
file_mtimes = []
|
||
|
||
# Followers — "New followers" is a rolling ~30-day window in each export, so merge every
|
||
# historical file by date (newest file wins on an overlapping date) the same way Content
|
||
# already does. Otherwise a day that ages out of the newest file's window silently
|
||
# disappears from the cumulative total and any month it belonged to.
|
||
follower_files = _find_all_files("followers")
|
||
file_mtimes.extend(os.path.getmtime(fp) for fp in follower_files)
|
||
fts_frames = []
|
||
for fp in follower_files:
|
||
f = pd.read_excel(fp, sheet_name="New followers")
|
||
f["Date"] = pd.to_datetime(f["Date"])
|
||
f["_src"] = os.path.getmtime(fp)
|
||
fts_frames.append(f)
|
||
fts = (pd.concat(fts_frames)
|
||
.sort_values("_src")
|
||
.drop_duplicates(subset=["Date"], keep="last")
|
||
.drop(columns=["_src"])
|
||
.sort_values("Date")
|
||
.reset_index(drop=True))
|
||
# Cumulative math is anchored at BASELINE_DATE — drop any merged rows before it so the
|
||
# baseline stays the true day-zero rather than double-counting pre-baseline history.
|
||
fts = fts[fts["Date"] >= pd.Timestamp(BASELINE_DATE)].reset_index(drop=True)
|
||
# Exact follower count on each day = the one manual baseline anchor + every gain since.
|
||
fts["Cumulative Followers"] = BASELINE_FOLLOWERS + fts["Total followers"].cumsum()
|
||
d["fts"] = fts
|
||
# Breakdown sheets are point-in-time snapshots, not a time series — always the newest file
|
||
# for the "current" values, but also keep one snapshot per (year, month) below so the
|
||
# "Followers by" panel can show an accurate historical split instead of always today's.
|
||
fp = follower_files[-1]
|
||
d["floc"] = pd.read_excel(fp, sheet_name="Location").sort_values("Total followers", ascending=False)
|
||
d["fjob"] = pd.read_excel(fp, sheet_name="Job function").sort_values("Total followers")
|
||
d["fsen"] = pd.read_excel(fp, sheet_name="Seniority").sort_values("Total followers")
|
||
d["find"] = pd.read_excel(fp, sheet_name="Industry").sort_values("Total followers", ascending=False)
|
||
d["fcs"] = pd.read_excel(fp, sheet_name="Company size").sort_values("Total followers")
|
||
|
||
# One breakdown snapshot per (year, month) — the latest export within that month wins,
|
||
# so e.g. selecting June shows June's actual demographic split, not today's.
|
||
breakdown_history = {}
|
||
for hfp in follower_files:
|
||
mtime = pd.Timestamp(os.path.getmtime(hfp), unit="s")
|
||
breakdown_history[(mtime.year, mtime.month)] = {
|
||
"floc": pd.read_excel(hfp, sheet_name="Location").sort_values("Total followers", ascending=False),
|
||
"fjob": pd.read_excel(hfp, sheet_name="Job function").sort_values("Total followers"),
|
||
"fsen": pd.read_excel(hfp, sheet_name="Seniority").sort_values("Total followers"),
|
||
"find": pd.read_excel(hfp, sheet_name="Industry").sort_values("Total followers", ascending=False),
|
||
"fcs": pd.read_excel(hfp, sheet_name="Company size").sort_values("Total followers"),
|
||
}
|
||
d["breakdown_history"] = breakdown_history
|
||
|
||
# Visitors — same rolling-window issue as Followers, same fix.
|
||
visitor_files = _find_all_files("visitors")
|
||
file_mtimes.extend(os.path.getmtime(vp) for vp in visitor_files)
|
||
vts_frames = []
|
||
for vp in visitor_files:
|
||
v = pd.read_excel(vp, sheet_name="Visitor metrics")
|
||
v["Date"] = pd.to_datetime(v["Date"])
|
||
v["_src"] = os.path.getmtime(vp)
|
||
vts_frames.append(v)
|
||
vts = (pd.concat(vts_frames)
|
||
.sort_values("_src")
|
||
.drop_duplicates(subset=["Date"], keep="last")
|
||
.drop(columns=["_src"])
|
||
.sort_values("Date")
|
||
.reset_index(drop=True))
|
||
d["vts"] = vts
|
||
vp = visitor_files[-1]
|
||
d["vloc"] = pd.read_excel(vp, sheet_name="Location").sort_values("Total views", ascending=False)
|
||
d["vsen"] = pd.read_excel(vp, sheet_name="Seniority").sort_values("Total views")
|
||
d["vind"] = pd.read_excel(vp, sheet_name="Industry")
|
||
d["vcs"] = pd.read_excel(vp, sheet_name="Company size").sort_values("Total views")
|
||
|
||
# Persistent running total per category (Location/Seniority/Company size), accumulated
|
||
# across every weekly export ever uploaded — see visitor_history.py for why this exists:
|
||
# LinkedIn only shows a category if it clears a minimum-visits threshold that week, so a
|
||
# single-snapshot approach (like Followers' breakdown_history) would silently lose a
|
||
# location the moment one week's export didn't happen to include it.
|
||
d["visitor_category_history"] = visitor_history.backfill_history(UPLOAD_DIR, visitor_files)
|
||
|
||
# Visitor -> Follower conversion: correlates daily unique visitors against daily new followers.
|
||
# Not a true funnel — LinkedIn doesn't expose which specific visitor became a follower, only
|
||
# daily totals for each, so this is a same-day correlation, not individual-level attribution.
|
||
conv = pd.merge(
|
||
fts[["Date", "Total followers"]].rename(columns={"Total followers": "New Followers"}),
|
||
vts[["Date", "Total unique visitors (desktop)", "Total unique visitors (mobile)"]],
|
||
on="Date", how="inner",
|
||
)
|
||
conv["Unique Visitors"] = conv["Total unique visitors (desktop)"] + conv["Total unique visitors (mobile)"]
|
||
conv["Conversion Rate"] = conv.apply(
|
||
lambda r: (r["New Followers"] / r["Unique Visitors"] * 100) if r["Unique Visitors"] > 0 else None, axis=1
|
||
)
|
||
d["conv"] = conv
|
||
|
||
# Content — merge all content files from both locations, newest data wins on duplicate dates
|
||
content_files = _find_all_files("content")
|
||
file_mtimes.extend(os.path.getmtime(cp) for cp in content_files)
|
||
metrics_frames, posts_frames = [], []
|
||
for cp in content_files:
|
||
m = pd.read_excel(cp, sheet_name="Metrics", header=1)
|
||
m["Date"] = pd.to_datetime(m["Date"])
|
||
m["_src"] = os.path.getmtime(cp)
|
||
metrics_frames.append(m)
|
||
p = pd.read_excel(cp, sheet_name="All posts", header=1)
|
||
p["_src"] = os.path.getmtime(cp)
|
||
posts_frames.append(p)
|
||
|
||
# Merge timeseries: keep newest file's row when dates overlap
|
||
cts = (pd.concat(metrics_frames)
|
||
.sort_values("_src")
|
||
.drop_duplicates(subset=["Date"], keep="last")
|
||
.drop(columns=["_src"])
|
||
.sort_values("Date")
|
||
.reset_index(drop=True))
|
||
cts["CTR (organic) %"] = (cts["Clicks (organic)"] / cts["Impressions (organic)"] * 100).where(
|
||
cts["Impressions (organic)"] > 0, 0)
|
||
cts["CTR (sponsored) %"] = (cts["Clicks (sponsored)"] / cts["Impressions (sponsored)"] * 100).where(
|
||
cts["Impressions (sponsored)"] > 0, 0)
|
||
d["cts"] = cts
|
||
|
||
# Follow Rate Relative to Reach: New Followers / Impressions * 100, per day. Replaces
|
||
# Conversion Rate (New Followers / Unique Visitors) for the cases where that metric silently
|
||
# drops a day — LinkedIn lets people follow a Page straight from a post in their feed, with
|
||
# zero page visit, so a real follower gain can land on a day with 0 tracked visitors. Impressions
|
||
# are almost never zero on a day with any reach at all, so those days no longer disappear.
|
||
frr = pd.merge(
|
||
conv[["Date", "New Followers", "Unique Visitors"]],
|
||
cts[["Date", "Impressions (organic)", "Impressions (sponsored)"]],
|
||
on="Date", how="inner",
|
||
)
|
||
frr["Impressions"] = frr["Impressions (organic)"] + frr["Impressions (sponsored)"]
|
||
frr["Follow Rate"] = frr.apply(
|
||
lambda r: (r["New Followers"] / r["Impressions"] * 100) if r["Impressions"] > 0 else None, axis=1
|
||
)
|
||
frr["No Visitor Gain"] = (frr["Unique Visitors"] == 0) & (frr["New Followers"] > 0)
|
||
d["frr"] = frr
|
||
|
||
# Merge posts: deduplicate by Post link, keep newest file's version
|
||
posts = (pd.concat(posts_frames)
|
||
.sort_values("_src")
|
||
.drop_duplicates(subset=["Post link"], keep="last")
|
||
.drop(columns=["_src"])
|
||
.reset_index(drop=True))
|
||
posts["Created date"] = pd.to_datetime(posts["Created date"], errors="coerce")
|
||
posts["Eng %"] = (posts["Engagement rate"] * 100).round(2)
|
||
posts["CTR %"] = (posts["Click through rate (CTR)"] * 100).round(2)
|
||
posts["Content Type"] = posts["Content Type"].fillna("Text/Image")
|
||
posts["Post title"] = posts["Post title"].fillna("(no text — image/video-only post)")
|
||
# Manual titles for posts LinkedIn exported with no caption text (image/video-only posts).
|
||
for post_date, manual_title in MANUAL_POST_TITLES.items():
|
||
posts.loc[posts["Created date"].dt.date == pd.Timestamp(post_date).date(), "Post title"] = manual_title
|
||
# Manual content-type corrections (e.g. carousels LinkedIn exports as plain Image).
|
||
for post_date, manual_type in MANUAL_CONTENT_TYPES.items():
|
||
posts.loc[posts["Created date"].dt.date == pd.Timestamp(post_date).date(), "Content Type"] = manual_type
|
||
posts["Short title"] = posts["Post title"].str[:75] + "…"
|
||
# Table should always read newest post first, regardless of which file/upload order
|
||
# contributed each row.
|
||
posts = posts.sort_values("Created date", ascending=False).reset_index(drop=True)
|
||
d["posts"] = posts
|
||
|
||
# Competitors
|
||
comp_path = _find_latest_file("competitor")
|
||
file_mtimes.append(os.path.getmtime(comp_path))
|
||
d["comp"] = pd.read_excel(comp_path, sheet_name="COMPETITORS", header=1)
|
||
|
||
# Most recent export drop across all sources — shown in the header
|
||
d["last_updated"] = pd.Timestamp(max(file_mtimes), unit="s")
|
||
|
||
return d
|
||
|
||
|
||
def date_range_label(df, col="Date"):
|
||
if df.empty:
|
||
return ""
|
||
lo, hi = df[col].min(), df[col].max()
|
||
return f"{lo.strftime('%b %d')} – {hi.strftime('%b %d')}"
|
||
|
||
|
||
_MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
|
||
"July", "August", "September", "October", "November", "December"]
|
||
_DELTA_MONTH_CAP = 20
|
||
|
||
|
||
def refresh_data():
|
||
global D, GROWTH_SNAPSHOT, current_total_followers, current_total_followers_as_of
|
||
global new_followers_30d, total_impressions, total_page_views, best_eng, num_posts
|
||
global total_clicks, avg_eng_rate, conv_total_followers, conv_total_visitors
|
||
global overall_conversion_rate, overview, posts, post_table_cols, content_tab
|
||
global followers_tab, visitors_tab, comp, comp_ordered, competitors_tab, growth_tab
|
||
global TAB_CONTENT_MAP, _breakdown_figure, _visitor_breakdown_figure
|
||
global _breakdown_delta_figure, _followers_delta_months
|
||
global _followers_count_only_months, _monthly_count_summary
|
||
global _visitor_category_months
|
||
global _posts_table_records, _content_posts_filtered, _content_cts_filtered
|
||
global impressions_by_format_figure, engagement_by_format_figure, ctr_by_format_figure
|
||
global impressions_vs_engagement_figure, reactions_figure, clicks_figure
|
||
global FOLLOWER_BREAKDOWNS, VISITOR_BREAKDOWNS, CONTENT_BAR_MARGIN, CONTENT_SCATTER_LEGEND
|
||
global _content_month_options, post_highlight_styles
|
||
|
||
D = load()
|
||
GROWTH_SNAPSHOT = growth.backfill_history(D["fts"], UPLOAD_DIR)[-1]
|
||
|
||
current_total_followers = int(D["fts"]["Cumulative Followers"].iloc[-1])
|
||
current_total_followers_as_of = D["fts"]["Date"].max().strftime("%b %d, %Y")
|
||
new_followers_30d = int(D["fts"]["Total followers"].sum())
|
||
total_impressions = int(D["cts"]["Impressions (total)"].sum())
|
||
total_page_views = int(D["vts"]["Total page views (total)"].sum())
|
||
best_eng = float(D["posts"]["Eng %"].max()) if len(D["posts"]) else 0.0
|
||
num_posts = len(D["posts"])
|
||
total_clicks = int(D["cts"]["Clicks (total)"].sum())
|
||
avg_eng_rate = float(D["cts"]["Engagement rate (total)"].replace(0, float("nan")).mean())
|
||
conv_total_followers = int(D["conv"]["New Followers"].sum())
|
||
conv_total_visitors = int(D["conv"]["Unique Visitors"].sum())
|
||
overall_conversion_rate = (conv_total_followers / conv_total_visitors * 100) if conv_total_visitors else None
|
||
|
||
_ov_year = int(D["fts"]["Date"].dt.year.max())
|
||
_ov_month = _default_active_month(D["fts"], D["posts"], _ov_year)
|
||
|
||
overview = dbc.Container([
|
||
html.Div([
|
||
html.Div(kpi("Starting Followers", f"{BASELINE_FOLLOWERS:,}", f"as of {pd.Timestamp(BASELINE_DATE).strftime('%b %d, %Y')}", "#999"), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Total Followers", f"{current_total_followers:,}", f"as of {current_total_followers_as_of}", TEAL), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("New Followers", f"{new_followers_30d:,}", date_range_label(D["fts"])), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Total Impressions", f"{total_impressions:,}", date_range_label(D["cts"])), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Total Page Views", f"{total_page_views:,}", date_range_label(D["vts"])), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Total Clicks", f"{total_clicks:,}", date_range_label(D["cts"])), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Best Post Eng.", f"{best_eng:.1f}%", f"across {num_posts} posts", GREEN), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Avg. Eng. Rate", f"{avg_eng_rate:.2f}%" if not pd.isna(avg_eng_rate) else "—", "daily avg (organic)", TEAL), style={"flex": "1", "minWidth": "0"}),
|
||
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"}),
|
||
|
||
dbc.Row([
|
||
dbc.Col(section("Follower Gains — Calendar (★ = post published)",
|
||
followers_calendar(D["fts"], D["posts"], id_prefix="overview", height="270px")
|
||
), md=8),
|
||
dbc.Col(section("New Followers per Day (★ = post published)",
|
||
G(daily_followers_figure(D["fts"], D["posts"], _ov_year, _ov_month),
|
||
id="overview-daily-followers-graph", style={"height": "270px"})
|
||
), md=4),
|
||
], className="gx-3 mb-3"),
|
||
|
||
dbc.Row([
|
||
dbc.Col(section("Impressions",
|
||
G(impressions_figure(_filtered_by_month(D["cts"], _ov_year, _ov_month)),
|
||
id="overview-impressions-graph", style={"height": "240px"}),
|
||
info=("impressions", IMPRESSIONS_EXPLAINER),
|
||
), md=3),
|
||
dbc.Col(section("Daily Engagement Rate",
|
||
G(engagement_figure(_filtered_by_month(D["cts"], _ov_year, _ov_month)),
|
||
id="overview-engagement-graph", style={"height": "240px"}),
|
||
info=("engagement", ENGAGEMENT_EXPLAINER),
|
||
), md=3),
|
||
dbc.Col(section("Daily CTR",
|
||
G(ctr_figure(_filtered_by_month(D["cts"], _ov_year, _ov_month)),
|
||
id="overview-ctr-graph", style={"height": "240px"}),
|
||
info=("ctr", CTR_EXPLAINER),
|
||
), md=3),
|
||
dbc.Col(section("Page Views — Desktop vs Mobile",
|
||
G(page_views_figure(_filtered_by_month(D["vts"], _ov_year, _ov_month)),
|
||
id="overview-pageviews-graph", style={"height": "240px"}),
|
||
info=("page-views", PAGE_VIEWS_EXPLAINER),
|
||
), md=3),
|
||
], className="gx-3"),
|
||
], fluid=True, className="pb-3")
|
||
|
||
|
||
# ───── CONTENT PERFORMANCE ────────────────────────────────────────────────────
|
||
posts = D["posts"]
|
||
post_table_cols = ["Short title", "Created date", "Post type", "Content Type",
|
||
"Impressions", "Clicks", "Eng %", "CTR %", "Likes", "Comments", "Reposts"]
|
||
|
||
# Highlight the top-engagement post in pale yellow, and the top-CTR post in
|
||
# teal if it's a different post from the top-engagement one.
|
||
post_highlight_styles = [{"if": {"row_index": "odd"}, "backgroundColor": "#f8fafc"}]
|
||
if not posts.empty:
|
||
best_eng_val = posts["Eng %"].max()
|
||
best_ctr_val = posts["CTR %"].max()
|
||
same_post_has_both = not posts[
|
||
(posts["Eng %"] == best_eng_val) & (posts["CTR %"] == best_ctr_val)
|
||
].empty
|
||
|
||
post_highlight_styles.append({
|
||
"if": {"filter_query": "{Eng %} = " + str(best_eng_val)},
|
||
"backgroundColor": "#FFF9C4",
|
||
})
|
||
if not same_post_has_both:
|
||
post_highlight_styles.append({
|
||
"if": {"filter_query": "{CTR %} = " + str(best_ctr_val)},
|
||
"backgroundColor": "#B2EBF2",
|
||
})
|
||
|
||
CONTENT_HIGHLIGHT_EXPLAINER = html.Div([
|
||
html.Div([color_dot("#FFF9C4"), html.B("Highest engagement rate")], style={"marginBottom": "3px"}),
|
||
html.Div("The metric LinkedIn's algorithm weighs most heavily when deciding whether to keep "
|
||
"distributing a post further.", style={"color": "#6b7a8d", "marginBottom": "10px"}),
|
||
|
||
html.Div([color_dot("#B2EBF2"), html.B("Highest CTR")], style={"marginBottom": "3px"}),
|
||
html.Div("Best at driving clicks off LinkedIn — matters most when the goal is traffic to a "
|
||
"website rather than reach.", style={"color": "#6b7a8d", "marginBottom": "10px"}),
|
||
|
||
html.Hr(style={"margin": "8px 0"}),
|
||
|
||
html.B("Engagement rate vs. impressions vs. CTR — which one matters?"),
|
||
html.Div("Impressions mostly reflect audience size and algorithmic luck, not content quality.",
|
||
style={"marginTop": "6px", "marginBottom": "8px"}),
|
||
html.Div("Engagement rate is what LinkedIn uses as its main signal in the first hour to decide "
|
||
"how far to keep pushing a post, so it's the closest thing to a leading indicator of "
|
||
"future reach.", style={"marginBottom": "8px"}),
|
||
html.Div("CTR only matters when the goal is specifically driving off-platform traffic. LinkedIn "
|
||
"doesn't reward outbound clicks the way it rewards on-platform engagement, so a "
|
||
"high-CTR post can still get less reach than a high-engagement one with zero clicks."),
|
||
])
|
||
|
||
IMPRESSIONS_VS_ENGAGEMENT_EXPLAINER = html.Div([
|
||
html.Div("Each dot is a post — x is reach, y is engagement rate, color is content type, and bubble "
|
||
"size is clicks.", style={"marginBottom": "8px"}),
|
||
html.Div('It answers "does reach actually translate into resonance, or are some posts getting '
|
||
'distributed widely without anyone caring?"', style={"marginBottom": "8px"}),
|
||
html.Div("You can spot high-impression/low-engagement posts (reach without resonance) versus "
|
||
"low-impression/high-engagement posts (content LinkedIn under-distributed but people liked).",
|
||
style={"color": "#6b7a8d"}),
|
||
])
|
||
|
||
def _posts_table_records(selected_month):
|
||
"""Rows for the All Posts table, optionally filtered to one 'YYYY-MM' month value."""
|
||
df = posts[post_table_cols].copy()
|
||
if selected_month and selected_month != "all":
|
||
year, month = (int(part) for part in selected_month.split("-"))
|
||
df = df[(posts["Created date"].dt.year == year) & (posts["Created date"].dt.month == month)]
|
||
df["Created date"] = df["Created date"].dt.strftime("%d/%m/%Y")
|
||
return df.to_dict("records")
|
||
|
||
|
||
def _content_month_options():
|
||
import calendar as calmod
|
||
months = sorted({(d.year, d.month) for d in posts["Created date"].dropna()})
|
||
return [{"label": "All Months", "value": "all"}] + [
|
||
{"label": f"{calmod.month_name[m]} {y}", "value": f"{y}-{m:02d}"} for y, m in months
|
||
]
|
||
|
||
|
||
def _content_posts_filtered(selected_month):
|
||
if not selected_month or selected_month == "all":
|
||
return posts
|
||
year, month = (int(part) for part in selected_month.split("-"))
|
||
return posts[(posts["Created date"].dt.year == year) & (posts["Created date"].dt.month == month)]
|
||
|
||
|
||
def _content_cts_filtered(selected_month):
|
||
cts = D["cts"]
|
||
if not selected_month or selected_month == "all":
|
||
return cts
|
||
year, month = (int(part) for part in selected_month.split("-"))
|
||
return cts[(cts["Date"].dt.year == year) & (cts["Date"].dt.month == month)]
|
||
|
||
|
||
def _format_breakdown(posts_df, col, agg):
|
||
if posts_df.empty:
|
||
return pd.DataFrame(columns=["Content Type", col])
|
||
grouped = posts_df.groupby("Content Type", as_index=False)[col]
|
||
return (grouped.sum() if agg == "sum" else grouped.mean()).sort_values(col)
|
||
|
||
|
||
def impressions_by_format_figure(posts_df):
|
||
df = _format_breakdown(posts_df, "Impressions", "sum")
|
||
fig = px.bar(df, x="Impressions", y="Content Type", orientation="h", template=TMPL,
|
||
color="Content Type", color_discrete_map=FORMAT_COLORS)
|
||
return fig.update_layout(showlegend=False, bargap=0.6)
|
||
|
||
|
||
def engagement_by_format_figure(posts_df):
|
||
df = _format_breakdown(posts_df, "Eng %", "mean")
|
||
fig = px.bar(df, x="Eng %", y="Content Type", orientation="h", template=TMPL,
|
||
color="Content Type", color_discrete_map=FORMAT_COLORS,
|
||
labels={"Eng %": "Engagement Rate (%)"})
|
||
return fig.update_layout(showlegend=False, bargap=0.6)
|
||
|
||
|
||
def ctr_by_format_figure(posts_df):
|
||
df = _format_breakdown(posts_df, "CTR %", "mean")
|
||
fig = px.bar(df, x="CTR %", y="Content Type", orientation="h", template=TMPL,
|
||
color="Content Type", color_discrete_map=FORMAT_COLORS,
|
||
labels={"CTR %": "Click-Through Rate (%)"})
|
||
return fig.update_layout(showlegend=False, bargap=0.6)
|
||
|
||
|
||
def impressions_vs_engagement_figure(posts_df):
|
||
fig = px.scatter(posts_df, x="Impressions", y="Eng %", color="Content Type", size="Clicks",
|
||
hover_name="Short title", template=TMPL, color_discrete_map=FORMAT_COLORS,
|
||
labels={"Eng %": "Engagement Rate (%)", "Content Type": ""})
|
||
return fig.update_traces(marker=dict(sizemin=8))
|
||
|
||
|
||
def reactions_figure(cts_df):
|
||
df = cts_df[["Date", "Reactions (organic)", "Comments (organic)", "Reposts (organic)"]].rename(
|
||
columns={"Reactions (organic)": "Reactions", "Comments (organic)": "Comments", "Reposts (organic)": "Reposts"})
|
||
df["Sponsored"] = cts_df["Reactions (sponsored)"] + cts_df["Comments (sponsored)"] + cts_df["Reposts (sponsored)"]
|
||
return px.line(df, x="Date", y=["Reactions", "Comments", "Reposts", "Sponsored"],
|
||
template=TMPL, color_discrete_sequence=[BLUE, GREEN, "#9C27B0", AMBER],
|
||
labels={"value": "Count", "variable": ""})
|
||
|
||
|
||
def clicks_figure(cts_df):
|
||
df = cts_df[["Date", "Clicks (organic)", "Clicks (sponsored)"]].rename(
|
||
columns={"Clicks (organic)": "Organic", "Clicks (sponsored)": "Sponsored"})
|
||
return px.line(df, x="Date", y=["Organic", "Sponsored"], template=TMPL,
|
||
color_discrete_sequence=[BLUE, AMBER], labels={"value": "Clicks", "variable": ""})
|
||
|
||
|
||
CONTENT_BAR_MARGIN = dict(t=10, b=20, l=10, r=10)
|
||
CONTENT_SCATTER_LEGEND = dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0,
|
||
font=dict(size=8), itemwidth=30, tracegroupgap=0)
|
||
|
||
|
||
_posts_table_df = posts[post_table_cols].copy()
|
||
_posts_table_df["Created date"] = _posts_table_df["Created date"].dt.strftime("%d/%m/%Y")
|
||
|
||
content_tab = dbc.Container([
|
||
dbc.Card([
|
||
dbc.CardHeader([
|
||
html.Strong("Posts"),
|
||
dcc.Dropdown(
|
||
id="content-month-select",
|
||
options=_content_month_options(), value="all", clearable=False, searchable=False,
|
||
className="compact-dropdown",
|
||
style={"width": "150px", "fontWeight": "700", "color": "#1a2533"},
|
||
),
|
||
info_popover("content-highlight", CONTENT_HIGHLIGHT_EXPLAINER),
|
||
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
|
||
dbc.CardBody(
|
||
dash_table.DataTable(
|
||
id="posts-table",
|
||
data=_posts_table_df.to_dict("records"),
|
||
columns=[{"name": c, "id": c} for c in post_table_cols],
|
||
page_action="none",
|
||
fixed_rows={"headers": True},
|
||
style_table={"overflowX": "auto", "overflowY": "auto", "maxHeight": "260px"},
|
||
style_header={"backgroundColor": "#f0f4f8", "color": "#333", "fontWeight": "bold", "border": "1px solid #ccc",
|
||
"fontSize": "11px", "padding": "3px 6px", "height": "22px", "lineHeight": "16px",
|
||
"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis"},
|
||
style_cell={"backgroundColor": "#ffffff", "color": "#333", "border": "1px solid #e0e0e0",
|
||
"fontSize": "11px", "padding": "3px 6px", "height": "22px", "lineHeight": "16px",
|
||
"textAlign": "left", "whiteSpace": "nowrap",
|
||
"maxWidth": "300px", "overflow": "hidden", "textOverflow": "ellipsis"},
|
||
style_cell_conditional=[
|
||
{"if": {"column_id": "Short title"}, "minWidth": "250px", "maxWidth": "none"},
|
||
{"if": {"column_id": "Created date"}, "width": "95px", "minWidth": "95px", "maxWidth": "95px"},
|
||
{"if": {"column_id": "Post type"}, "width": "75px", "minWidth": "75px", "maxWidth": "75px"},
|
||
{"if": {"column_id": "Content Type"}, "width": "100px", "minWidth": "100px", "maxWidth": "100px"},
|
||
{"if": {"column_id": "Impressions"}, "width": "85px", "minWidth": "85px", "maxWidth": "85px"},
|
||
{"if": {"column_id": "Clicks"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
|
||
{"if": {"column_id": "Eng %"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
|
||
{"if": {"column_id": "CTR %"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
|
||
{"if": {"column_id": "Likes"}, "width": "60px", "minWidth": "60px", "maxWidth": "60px"},
|
||
{"if": {"column_id": "Comments"}, "width": "70px", "minWidth": "70px", "maxWidth": "70px"},
|
||
{"if": {"column_id": "Reposts"}, "width": "70px", "minWidth": "70px", "maxWidth": "70px"},
|
||
],
|
||
style_data_conditional=post_highlight_styles,
|
||
tooltip_data=[{c: {"value": str(row[c]), "type": "markdown"} for c in post_table_cols}
|
||
for row in _posts_table_df.to_dict("records")],
|
||
tooltip_duration=None,
|
||
)),
|
||
], className="mb-3 shadow-sm"),
|
||
|
||
dbc.Row([
|
||
dbc.Col(section("Impressions by Content Format",
|
||
G(impressions_by_format_figure(posts), id="content-impressions-fig",
|
||
style={"height": "180px"}, margin=CONTENT_BAR_MARGIN),
|
||
info=("content-impressions", IMPRESSIONS_EXPLAINER),
|
||
title_style={"fontSize": "0.72rem"},
|
||
), md=3),
|
||
dbc.Col(section("Avg. Engagement Rate by Content Format",
|
||
G(engagement_by_format_figure(posts), id="content-engagement-fig",
|
||
style={"height": "180px"}, margin=CONTENT_BAR_MARGIN),
|
||
info=("content-engagement", ENGAGEMENT_EXPLAINER),
|
||
title_style={"fontSize": "0.72rem"},
|
||
), md=3),
|
||
dbc.Col(section("Avg. CTR by Content Format",
|
||
G(ctr_by_format_figure(posts), id="content-ctr-fig",
|
||
style={"height": "180px"}, margin=CONTENT_BAR_MARGIN),
|
||
info=("content-ctr", CTR_EXPLAINER),
|
||
title_style={"fontSize": "0.72rem"},
|
||
), md=3),
|
||
dbc.Col(section("Impressions vs Engagement Rate",
|
||
G(impressions_vs_engagement_figure(posts), id="content-scatter-fig",
|
||
style={"height": "180px"}, legend=CONTENT_SCATTER_LEGEND),
|
||
info=("impressions-vs-engagement", IMPRESSIONS_VS_ENGAGEMENT_EXPLAINER),
|
||
title_style={"fontSize": "0.72rem"},
|
||
), md=3),
|
||
], className="gx-3 mb-3"),
|
||
|
||
dbc.Row([
|
||
dbc.Col(section("Daily Reactions, Comments & Reposts",
|
||
G(reactions_figure(D["cts"]), id="content-reactions-fig", style={"height": "180px"})
|
||
), md=6),
|
||
dbc.Col(section("Daily Clicks",
|
||
G(clicks_figure(D["cts"]), id="content-clicks-fig", style={"height": "180px"})
|
||
), md=6),
|
||
], className="gx-3"),
|
||
], fluid=True, className="pb-3")
|
||
|
||
|
||
# ───── FOLLOWERS ──────────────────────────────────────────────────────────────
|
||
# key -> (label, dataframe sorted largest-first, category column, bar color)
|
||
# All breakdowns share one light-blue accent — the color was never meaningful data, just visual noise.
|
||
LIGHT_BLUE = "#4a90d9"
|
||
FOLLOWER_BREAKDOWN_MAP = {
|
||
"company_size": ("Company Size", D["fcs"].sort_values("Total followers", ascending=False), "Company size", LIGHT_BLUE),
|
||
"job_function": ("Job Function", D["fjob"].sort_values("Total followers", ascending=False), "Job function", LIGHT_BLUE),
|
||
"industry": ("Industry", D["find"], "Industry", LIGHT_BLUE),
|
||
"location": ("Location", D["floc"], "Location", LIGHT_BLUE),
|
||
"seniority": ("Seniority", D["fsen"].sort_values("Total followers", ascending=False), "Seniority", LIGHT_BLUE),
|
||
}
|
||
FOLLOWER_BREAKDOWNS = [(key, label) for key, (label, *_rest) in FOLLOWER_BREAKDOWN_MAP.items()]
|
||
|
||
_BREAKDOWN_SHEET_KEY = {"company_size": "fcs", "job_function": "fjob",
|
||
"industry": "find", "location": "floc", "seniority": "fsen"}
|
||
|
||
def _breakdown_figure(key, expanded, year=None, month=None):
|
||
label, df, col, color = FOLLOWER_BREAKDOWN_MAP[key]
|
||
if year is not None and month is not None:
|
||
hist = D["breakdown_history"]
|
||
available = sorted(k for k in hist if k <= (year, month))
|
||
if available:
|
||
df = hist[available[-1]][_BREAKDOWN_SHEET_KEY[key]]
|
||
total_n = len(df)
|
||
show_df = df if expanded else df.head(10)
|
||
fig = px.bar(show_df.sort_values("Total followers"), x="Total followers", y=col,
|
||
orientation="h", template=TMPL, color_discrete_sequence=[color])
|
||
fig.update_layout(showlegend=False)
|
||
return label, fig, total_n
|
||
|
||
# Months where a prior snapshot exists to diff against — the earliest snapshot in
|
||
# breakdown_history has nothing before it, so it can never be a delta option.
|
||
def _followers_delta_months():
|
||
return sorted(D["breakdown_history"].keys())[1:]
|
||
|
||
def _breakdown_delta_figure(key, year, month):
|
||
label, _df, col, _color = FOLLOWER_BREAKDOWN_MAP[key]
|
||
sheet_key = _BREAKDOWN_SHEET_KEY[key]
|
||
hist = D["breakdown_history"]
|
||
available = sorted(hist.keys())
|
||
idx = available.index((year, month))
|
||
prev_year, prev_month = available[idx - 1]
|
||
cur = hist[(year, month)][sheet_key][[col, "Total followers"]]
|
||
prev = hist[(prev_year, prev_month)][sheet_key][[col, "Total followers"]]
|
||
merged = cur.merge(prev, on=col, how="outer", suffixes=("_cur", "_prev")).fillna(0)
|
||
merged["Delta"] = merged["Total followers_cur"] - merged["Total followers_prev"]
|
||
merged = merged[merged["Delta"] != 0].copy()
|
||
merged["AbsDelta"] = merged["Delta"].abs()
|
||
changed_n = len(merged)
|
||
show_df = merged.sort_values("AbsDelta", ascending=False).head(_DELTA_MONTH_CAP)
|
||
overflow = changed_n - len(show_df)
|
||
show_df = show_df.sort_values("Delta")
|
||
fig = px.bar(show_df, x="Delta", y=col, orientation="h", template=TMPL,
|
||
labels={"Delta": "Change in Followers"})
|
||
fig.update_traces(marker_color=show_df["Delta"].apply(
|
||
lambda d: "#52C41A" if d > 0 else "#d43f3a"))
|
||
fig.update_layout(showlegend=False)
|
||
return label, fig, changed_n, overflow
|
||
|
||
# Months with real follower-count activity (from the daily fts time series) but no
|
||
# category breakdown to attribute it to — i.e. months before the first-ever demographic
|
||
# export existed to serve as a "prior" snapshot. Currently only June 2026: the campaign's
|
||
# baseline count starts Jun 16, but the first Followers export with a category breakdown
|
||
# wasn't taken until Jun 24, so the loss/gains between Jun 16-24 have no category history.
|
||
def _followers_count_only_months():
|
||
delta_months = set(_followers_delta_months())
|
||
fts_months = sorted(set(zip(D["fts"]["Date"].dt.year, D["fts"]["Date"].dt.month)))
|
||
earliest_breakdown = min(D["breakdown_history"].keys()) if D["breakdown_history"] else None
|
||
return [ym for ym in fts_months
|
||
if ym not in delta_months and (earliest_breakdown is None or ym <= earliest_breakdown)]
|
||
|
||
def _monthly_count_summary(year, month):
|
||
fts = D["fts"]
|
||
sub = fts[(fts["Date"].dt.year == year) & (fts["Date"].dt.month == month)]
|
||
net = int(sub["Total followers"].sum())
|
||
events = sub[sub["Total followers"] != 0]
|
||
gains = events[events["Total followers"] > 0]
|
||
losses = events[events["Total followers"] < 0]
|
||
|
||
def _fmt(rows):
|
||
return ", ".join(f"{d.strftime('%b %-d')}" for d in rows["Date"])
|
||
|
||
parts = []
|
||
if len(gains):
|
||
parts.append(f"gained {int(gains['Total followers'].sum())} ({_fmt(gains)})")
|
||
if len(losses):
|
||
parts.append(f"lost {int(-losses['Total followers'].sum())} ({_fmt(losses)})")
|
||
detail = "; ".join(parts) if parts else "no change"
|
||
sign = "+" if net >= 0 else ""
|
||
return net, sign, detail
|
||
|
||
# Approximate geographic centroid for every country — not just the ones that happen to
|
||
# appear in this week's export. This used to be a 14-entry dict covering only the countries
|
||
# present when the map was first built; any new country in a future export would silently
|
||
# vanish from the map (dropped by the dropna() below) with no visible sign anything was
|
||
# missing. Now covers essentially every UN member state plus commonly-seen LinkedIn
|
||
# location names, so a country is never invisible just because nobody thought to add it —
|
||
# see the fallback warning in followers_country_map_figure() for the rare remaining gap.
|
||
_COUNTRY_CENTROIDS = {
|
||
"Afghanistan": (33.9391, 67.7100), "Albania": (41.1533, 20.1683),
|
||
"Algeria": (28.0339, 1.6596), "Andorra": (42.5462, 1.6016),
|
||
"Angola": (-11.2027, 17.8739), "Argentina": (-38.4161, -63.6167),
|
||
"Armenia": (40.0691, 45.0382), "Australia": (-25.2744, 133.7751),
|
||
"Austria": (47.5162, 14.5501), "Azerbaijan": (40.1431, 47.5769),
|
||
"Bahamas": (25.0343, -77.3963), "Bahrain": (26.0667, 50.5577),
|
||
"Bangladesh": (23.6850, 90.3563), "Belarus": (53.7098, 27.9534),
|
||
"Belgium": (50.5039, 4.4699), "Belize": (17.1899, -88.4976),
|
||
"Benin": (9.3077, 2.3158), "Bhutan": (27.5142, 90.4336),
|
||
"Bolivia": (-16.2902, -63.5887), "Bosnia and Herzegovina": (43.9159, 17.6791),
|
||
"Botswana": (-22.3285, 24.6849), "Brazil": (-14.2350, -51.9253),
|
||
"Brunei": (4.5353, 114.7277), "Bulgaria": (42.7339, 25.4858),
|
||
"Burkina Faso": (12.2383, -1.5616), "Burundi": (-3.3731, 29.9189),
|
||
"Cambodia": (12.5657, 104.9910), "Cameroon": (7.3697, 12.3547),
|
||
"Canada": (56.1304, -106.3468), "Cape Verde": (16.5388, -23.0418),
|
||
"Central African Republic": (6.6111, 20.9394), "Chad": (15.4542, 18.7322),
|
||
"Chile": (-35.6751, -71.5430), "China": (35.8617, 104.1954),
|
||
"Colombia": (4.5709, -74.2973), "Comoros": (-11.6455, 43.3333),
|
||
"Costa Rica": (9.7489, -83.7534), "Croatia": (45.1000, 15.2000),
|
||
"Cuba": (21.5218, -77.7812), "Cyprus": (35.1264, 33.4299),
|
||
"Czechia": (49.8175, 15.4730), "Denmark": (56.2639, 9.5018),
|
||
"Djibouti": (11.8251, 42.5903), "Dominican Republic": (18.7357, -70.1627),
|
||
"DR Congo": (-4.0383, 21.7587), "Democratic Republic of the Congo": (-4.0383, 21.7587),
|
||
"Ecuador": (-1.8312, -78.1834), "Egypt": (26.8206, 30.8025),
|
||
"El Salvador": (13.7942, -88.8965), "Estonia": (58.5953, 25.0136),
|
||
"Eswatini": (-26.5225, 31.4659), "Ethiopia": (9.1450, 40.4897),
|
||
"Fiji": (-17.7134, 178.0650), "Finland": (61.9241, 25.7482),
|
||
"France": (46.6034, 1.8883), "Gabon": (-0.8037, 11.6094),
|
||
"Gambia": (13.4432, -15.3101), "Georgia": (42.3154, 43.3569),
|
||
"Germany": (51.1657, 10.4515), "Ghana": (7.9465, -1.0232),
|
||
"Greece": (39.0742, 21.8243), "Greenland": (71.7069, -42.6043),
|
||
"Guatemala": (15.7835, -90.2308), "Guinea": (9.9456, -9.6966),
|
||
"Guyana": (4.8604, -58.9302), "Haiti": (18.9712, -72.2852),
|
||
"Honduras": (15.2000, -86.2419), "Hong Kong": (22.3193, 114.1694),
|
||
"Hungary": (47.1625, 19.5033), "Iceland": (64.9631, -19.0208),
|
||
"India": (20.5937, 78.9629), "Indonesia": (-0.7893, 113.9213),
|
||
"Iran": (32.4279, 53.6880), "Iraq": (33.2232, 43.6793),
|
||
"Ireland": (53.4129, -8.2439), "Israel": (31.0461, 34.8516),
|
||
"Italy": (41.8719, 12.5674), "Ivory Coast": (7.5400, -5.5471),
|
||
"Jamaica": (18.1096, -77.2975), "Japan": (36.2048, 138.2529),
|
||
"Jordan": (30.5852, 36.2384), "Kazakhstan": (48.0196, 66.9237),
|
||
"Kenya": (-0.0236, 37.9062), "Kosovo": (42.6026, 20.9030),
|
||
"Kuwait": (29.3117, 47.4818), "Kyrgyzstan": (41.2044, 74.7661),
|
||
"Laos": (19.8563, 102.4955), "Latvia": (56.8796, 24.6032),
|
||
"Lebanon": (33.8547, 35.8623), "Lesotho": (-29.6100, 28.2336),
|
||
"Liberia": (6.4281, -9.4295), "Libya": (26.3351, 17.2283),
|
||
"Liechtenstein": (47.1660, 9.5554), "Lithuania": (55.1694, 23.8813),
|
||
"Luxembourg": (49.8153, 6.1296), "Madagascar": (-18.7669, 46.8691),
|
||
"Malawi": (-13.2543, 34.3015), "Malaysia": (4.2105, 101.9758),
|
||
"Maldives": (3.2028, 73.2207), "Mali": (17.5707, -3.9962),
|
||
"Malta": (35.9375, 14.3754), "Mauritania": (21.0079, -10.9408),
|
||
"Mauritius": (-20.3484, 57.5522), "Mexico": (23.6345, -102.5528),
|
||
"Moldova": (47.4116, 28.3699), "Monaco": (43.7384, 7.4246),
|
||
"Mongolia": (46.8625, 103.8467), "Montenegro": (42.7087, 19.3744),
|
||
"Morocco": (31.7917, -7.0926), "Mozambique": (-18.6657, 35.5296),
|
||
"Myanmar": (21.9162, 95.9560), "Namibia": (-22.9576, 18.4904),
|
||
"Nepal": (28.3949, 84.1240), "Netherlands": (52.1326, 5.2913),
|
||
"New Zealand": (-40.9006, 174.8860), "Nicaragua": (12.8654, -85.2072),
|
||
"Niger": (17.6078, 8.0817), "Nigeria": (9.0820, 8.6753),
|
||
"North Korea": (40.3399, 127.5101), "North Macedonia": (41.6086, 21.7453),
|
||
"Norway": (60.4720, 8.4689), "Oman": (21.4735, 55.9754),
|
||
"Pakistan": (30.3753, 69.3451), "Palestine": (31.9522, 35.2332),
|
||
"Panama": (8.5380, -80.7821), "Papua New Guinea": (-6.3150, 143.9555),
|
||
"Paraguay": (-23.4425, -58.4438), "Peru": (-9.1900, -75.0152),
|
||
"Philippines": (12.8797, 121.7740), "Poland": (51.9194, 19.1451),
|
||
"Portugal": (39.3999, -8.2245), "Puerto Rico": (18.2208, -66.5901),
|
||
"Qatar": (25.3548, 51.1839), "Romania": (45.9432, 24.9668),
|
||
"Russia": (61.5240, 105.3188), "Rwanda": (-1.9403, 29.8739),
|
||
"Saudi Arabia": (23.8859, 45.0792), "Senegal": (14.4974, -14.4524),
|
||
"Serbia": (44.0165, 21.0059), "Singapore": (1.3521, 103.8198),
|
||
"Slovakia": (48.6690, 19.6990), "Slovenia": (46.1512, 14.9955),
|
||
"Somalia": (5.1521, 46.1996), "South Africa": (-30.5595, 22.9375),
|
||
"South Korea": (35.9078, 127.7669), "South Sudan": (6.8770, 31.3070),
|
||
"Spain": (40.4637, -3.7492), "Sri Lanka": (7.8731, 80.7718),
|
||
"Sudan": (12.8628, 30.2176), "Suriname": (3.9193, -56.0278),
|
||
"Sweden": (60.1282, 18.6435), "Switzerland": (46.8182, 8.2275),
|
||
"Syria": (34.8021, 38.9968), "Taiwan": (23.6978, 120.9605),
|
||
"Tajikistan": (38.8610, 71.2761), "Tanzania": (-6.3690, 34.8888),
|
||
"Thailand": (15.8700, 100.9925), "Togo": (8.6195, 0.8248),
|
||
"Trinidad and Tobago": (10.6918, -61.2225), "Tunisia": (33.8869, 9.5375),
|
||
"Turkey": (38.9637, 35.2433), "Turkmenistan": (38.9697, 59.5563),
|
||
"Uganda": (1.3733, 32.2903), "Ukraine": (48.3794, 31.1656),
|
||
"United Arab Emirates": (23.4241, 53.8478), "United Kingdom": (55.3781, -3.4360),
|
||
"United States": (39.8283, -98.5795), "Uruguay": (-32.5228, -55.7658),
|
||
"Uzbekistan": (41.3775, 64.5853), "Venezuela": (6.4238, -66.5897),
|
||
"Vietnam": (14.0583, 108.2772), "Yemen": (15.5527, 48.5164),
|
||
"Zambia": (-13.1339, 27.8493), "Zimbabwe": (-19.0154, 29.1549),
|
||
}
|
||
|
||
# LinkedIn omits the country entirely for some well-known metro areas (e.g. "San
|
||
# Francisco Bay Area" instead of "San Francisco Bay Area, United States") — these have
|
||
# no comma to split on, so they need an explicit lookup instead of the split-on-comma rule.
|
||
_LOCATION_COUNTRY_OVERRIDES = {
|
||
"San Francisco Bay Area": "United States",
|
||
"Greater Madison Area": "United States",
|
||
}
|
||
|
||
def _followers_by_country(floc_df):
|
||
"""Location -> Country rollup shared by the map and the country-count caption, so both
|
||
always agree. Deliberately computed before any centroid lookup, so the count reflects
|
||
every country actually present in the export — even one missing from _COUNTRY_CENTROIDS
|
||
(which would otherwise silently vanish from the map with no visible sign anything's off)."""
|
||
by_country = floc_df.copy()
|
||
by_country["Country"] = by_country["Location"].map(_LOCATION_COUNTRY_OVERRIDES).fillna(
|
||
by_country["Location"].str.split(",").str[-1].str.strip())
|
||
return by_country.groupby("Country", as_index=False)["Total followers"].sum()
|
||
|
||
def followers_country_count(floc_df):
|
||
return len(_followers_by_country(floc_df))
|
||
|
||
def followers_country_map_figure(floc_df):
|
||
"""One marker per country (not per city) showing follower count, on a plain
|
||
white-land / gray-water base map — no country borders, no choropleth shading."""
|
||
by_country = _followers_by_country(floc_df)
|
||
by_country["lat"] = by_country["Country"].map(lambda c: _COUNTRY_CENTROIDS.get(c, (None, None))[0])
|
||
by_country["lon"] = by_country["Country"].map(lambda c: _COUNTRY_CENTROIDS.get(c, (None, None))[1])
|
||
missing = by_country.loc[by_country["lat"].isna(), "Country"].tolist()
|
||
if missing:
|
||
# _COUNTRY_CENTROIDS now covers essentially every country, so this should be rare —
|
||
# if it ever fires, add the missing name(s) to that dict rather than ignoring this.
|
||
print(f"[followers_country_map_figure] WARNING: no centroid for {missing} — "
|
||
f"dropped from the map (but still counted in followers_country_count).")
|
||
by_country = by_country.dropna(subset=["lat", "lon"])
|
||
|
||
fig = go.Figure(go.Scattergeo(
|
||
lat=by_country["lat"], lon=by_country["lon"],
|
||
hovertext=by_country["Country"] + ": " + by_country["Total followers"].astype(str),
|
||
mode="markers",
|
||
marker=dict(size=14, color="#E53935", line=dict(width=1, color="white"), opacity=0.85),
|
||
hovertemplate="%{hovertext}<extra></extra>",
|
||
))
|
||
# Explicit lon/lat window + a linear (equirectangular) projection, sized to approximate
|
||
# this card's aspect ratio (md=4 column at 240px tall, narrower than the md=8 this was
|
||
# first tuned for) — fitbounds="locations" left large blank margins here, since our data's
|
||
# true shape (Canada-to-China, ~7.8:1) doesn't match a narrow box's shape, and Plotly pads
|
||
# rather than crops to preserve true geographic proportions. Widening the latitude window
|
||
# (vs. the data's actual ~27° spread) trades some empty sky above/below for less empty
|
||
# space left/right, which fills a narrower box better without cropping any country's
|
||
# marker. This is tuned to an estimated box width, not a measured one (no browser
|
||
# available to verify pixel dimensions here) — nudge further if it's still off.
|
||
# No `template=` here deliberately — applying plotly_white to a geo figure resets/breaks
|
||
# its geo styling (landcolor/oceancolor/projection all get overridden). We already control
|
||
# every visual aspect of this map explicitly via update_geos() above.
|
||
fig.update_geos(showland=True, landcolor="white", showocean=True, oceancolor="#d9d9d9",
|
||
showcountries=False, showcoastlines=False, showframe=False,
|
||
projection_type="equirectangular",
|
||
lonaxis_range=[-120, 118], lataxis_range=[0, 85])
|
||
return fig
|
||
|
||
|
||
_init_label, _init_fig, _init_total = _breakdown_figure("company_size", False)
|
||
|
||
FOLLOWERS_BY_EXPLAINER = html.Div([
|
||
html.Div([html.B("Total"), " shows the ", html.B("Top Ten of ALL"), " followers."],
|
||
style={"marginBottom": "6px"}),
|
||
html.Div([html.B("When choosing a month"), ", you see the followers gained or lost that "
|
||
"month, regardless of whether they show up in the Top Ten shown in Total."],
|
||
style={"marginBottom": "10px"}),
|
||
html.Hr(style={"margin": "10px 0"}),
|
||
html.Div("These are LinkedIn's standard demographic splits of who is following your page — the "
|
||
"point is to check whether your content is reaching the audience you actually care about, "
|
||
"not just growing follower count in the abstract.", style={"marginBottom": "10px"}),
|
||
html.Div([html.B("Company Size"), " and ", html.B("Industry"), " tell you what kind of "
|
||
"organizations are watching."], style={"marginBottom": "6px"}),
|
||
html.Div([html.B("Seniority"), " tells you whether you're reaching people who can actually "
|
||
"greenlight a deal — if most followers sit at Entry level or student tier, your content "
|
||
"is resonating with people who use the tools but can't approve budget. Growth in "
|
||
"Director/VP/Owner tiers is a stronger signal for business development than raw "
|
||
"follower count."], style={"color": "#6b7a8d"}),
|
||
])
|
||
|
||
FOLLOW_RATE_EXPLAINER = html.Div([
|
||
html.Div([html.B("Follow Rate = New Followers ÷ Impressions × 100"), ", per day."],
|
||
style={"marginBottom": "8px"}),
|
||
html.Div("This replaces Conversion Rate (New Followers ÷ Unique Visitors), which silently "
|
||
"drops any day with 0 tracked visitors — even if real followers were gained that day "
|
||
"(e.g. someone follows straight from a post in their feed, without ever visiting the "
|
||
"Page). Impressions are almost never zero on a day with any reach at all, so those "
|
||
"days no longer disappear.", style={"marginBottom": "10px"}),
|
||
html.Hr(style={"margin": "10px 0"}),
|
||
html.Div([html.B("Days flagged red"), " still had 0 tracked visitors ", html.B("and"),
|
||
" gained followers — the exact case the old chart used to hide. They're not "
|
||
"errors; they're the days this fix was built for."], style={"color": "#6b7a8d"}),
|
||
])
|
||
|
||
followers_tab = dbc.Container([
|
||
html.Div([
|
||
html.Div(kpi("Starting Followers", f"{BASELINE_FOLLOWERS:,}", f"as of {pd.Timestamp(BASELINE_DATE).strftime('%b %d, %Y')}", "#999"), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Total Followers", f"{current_total_followers:,}", f"as of {current_total_followers_as_of}", TEAL), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("New Followers", f"{new_followers_30d:,}", date_range_label(D["fts"])), style={"flex": "1", "minWidth": "0"}),
|
||
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.5rem"}),
|
||
|
||
dbc.Row([
|
||
dbc.Col(section("Follower Gains — Calendar (★ = post published)",
|
||
followers_calendar(D["fts"], D["posts"], id_prefix="followers", height="300px")
|
||
), md=8),
|
||
dbc.Col(section("New Followers per Day (★ = post published)",
|
||
G(daily_followers_figure(D["fts"], D["posts"],
|
||
int(D["fts"]["Date"].dt.year.max()),
|
||
_default_active_month(D["fts"], D["posts"], int(D["fts"]["Date"].dt.year.max()))),
|
||
id="followers-daily-followers-graph", style={"height": "300px"})
|
||
), md=4),
|
||
], className="gx-3 mb-3"),
|
||
|
||
dbc.Row([
|
||
dbc.Col(dbc.Card([
|
||
dbc.CardHeader([
|
||
html.Strong("Followers by:"),
|
||
dcc.Dropdown(
|
||
id="fb-period-select",
|
||
options=[{"label": "Total", "value": "total"}] + [
|
||
{"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"}
|
||
for y, m in sorted(set(_followers_delta_months()) | set(_followers_count_only_months()))
|
||
],
|
||
value="total", clearable=False, searchable=False,
|
||
className="compact-dropdown",
|
||
style={"width": "110px", "fontWeight": "700", "color": "#1a2533"},
|
||
),
|
||
dcc.Dropdown(
|
||
id="fb-select",
|
||
options=[{"label": label, "value": key} for key, label in FOLLOWER_BREAKDOWNS],
|
||
value="company_size", clearable=False, searchable=False,
|
||
className="compact-dropdown",
|
||
style={"width": "150px", "fontWeight": "700", "color": "#1a2533"},
|
||
),
|
||
html.Button("Show all", id="fb-expand-btn", n_clicks=0,
|
||
style={"display": "inline-block" if _init_total > 10 else "none",
|
||
"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
|
||
"background": "none", "color": "white", "textDecoration": "underline",
|
||
"cursor": "pointer", "padding": "0"}),
|
||
info_popover("followers-by", FOLLOWERS_BY_EXPLAINER),
|
||
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
|
||
html.Div(id="fb-month-label", style={"fontSize": "0.72rem", "color": "#6b7a8d",
|
||
"textAlign": "right", "marginBottom": "2px"}),
|
||
dbc.CardBody(G(_init_fig, style={"height": "240px"}), id="followers-breakdown-area"),
|
||
dcc.Store(id="fb-expanded", data=False),
|
||
dcc.Store(id="followers-selected-month", data={
|
||
"year": int(D["fts"]["Date"].dt.year.max()),
|
||
"month": _default_active_month(D["fts"], D["posts"], int(D["fts"]["Date"].dt.year.max())),
|
||
}),
|
||
], className="mb-3 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="mb-3 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="mb-3 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):
|
||
return dbc.Card(dbc.CardBody([
|
||
html.P(label, className="text-muted mb-1",
|
||
style={"fontSize": "0.62rem", "textTransform": "uppercase", "letterSpacing": "0.05em"}),
|
||
html.H3(value, className="mb-0", style={"color": color, "fontWeight": "800", "fontSize": "1.2rem"}),
|
||
html.P(note, className="text-muted mb-0", style={"fontSize": "0.62rem"}),
|
||
], style={"padding": "0.6rem 0.8rem"}), className="h-100 shadow-sm", style={"borderLeft": f"4px solid {color}"})
|
||
|
||
|
||
def color_dot(color):
|
||
"""Small inline color swatch used inside popover content instead of describing the color in words."""
|
||
return html.Span(style={"width": "10px", "height": "10px", "backgroundColor": color, "display": "inline-block",
|
||
"borderRadius": "2px", "marginRight": "5px", "verticalAlign": "middle"})
|
||
|
||
def info_popover(id_suffix, content):
|
||
"""A clickable (?) badge that reveals an explanation popover — used to annotate
|
||
metrics throughout the dashboard without cluttering the page with permanent text."""
|
||
return html.Span([
|
||
html.Span("?", id=f"info-{id_suffix}", style={
|
||
"display": "inline-flex", "alignItems": "center", "justifyContent": "center",
|
||
"width": "22px", "height": "22px", "borderRadius": "50%",
|
||
"backgroundColor": "#FFD600", "color": "#1a2533", "fontSize": "0.8rem",
|
||
"fontWeight": "800", "cursor": "pointer", "marginLeft": "8px", "verticalAlign": "middle",
|
||
"boxShadow": "0 1px 4px rgba(0,0,0,0.3)", "border": "2px solid white",
|
||
}),
|
||
dbc.Popover(
|
||
dbc.PopoverBody(content, style={"fontSize": "0.82rem", "maxWidth": "360px", "color": "#1a2533", "lineHeight": "1.55"}),
|
||
target=f"info-{id_suffix}", trigger="legacy", placement="right",
|
||
),
|
||
])
|
||
|
||
def section(title, *children, info=None, title_style=None, card_class=""):
|
||
title_span_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis",
|
||
"display": "inline-block", "maxWidth": "calc(100% - 32px)", "verticalAlign": "middle"}
|
||
if title_style:
|
||
title_span_style.update(title_style)
|
||
header_children = [html.Strong(title, style=title_span_style)]
|
||
if info:
|
||
info_id, info_text = info
|
||
header_children.append(info_popover(info_id, info_text))
|
||
return dbc.Card([
|
||
dbc.CardHeader(header_children),
|
||
dbc.CardBody(list(children)),
|
||
], className=f"mb-3 shadow-sm {card_class}".strip())
|
||
|
||
|
||
def _available_years(fts_df, posts_df):
|
||
"""All years with export data, most recent first — powers the calendar's year picker."""
|
||
years = set(fts_df["Date"].dt.year)
|
||
if not posts_df.empty:
|
||
years |= set(posts_df["Created date"].dropna().dt.year)
|
||
return sorted(years, reverse=True)
|
||
|
||
|
||
def _default_active_month(fts_df, posts_df, year):
|
||
"""The most recent month (within `year`) that has export data — used to decide which
|
||
month's posts to show underneath the calendar before any month box has been clicked."""
|
||
months = set(fts_df.loc[fts_df["Date"].dt.year == year, "Date"].dt.month)
|
||
if not posts_df.empty:
|
||
months |= set(posts_df.loc[posts_df["Created date"].dt.year == year, "Created date"].dt.month)
|
||
return max(months) if months else None
|
||
|
||
|
||
def _monthly_summary(fts_df, posts_df, year):
|
||
"""Net follower change and post count per month, within `year`."""
|
||
monthly_net = {}
|
||
for d, v in zip(fts_df["Date"].dt.date, fts_df["Total followers"]):
|
||
if d.year == year:
|
||
monthly_net[d.month] = monthly_net.get(d.month, 0) + int(v)
|
||
|
||
monthly_posts = {}
|
||
if not posts_df.empty:
|
||
for d in posts_df["Created date"].dropna().dt.date:
|
||
if d.year == year:
|
||
monthly_posts[d.month] = monthly_posts.get(d.month, 0) + 1
|
||
|
||
months_with_data = set(monthly_net) | set(monthly_posts)
|
||
max_gain = max((v for v in monthly_net.values() if v > 0), default=0)
|
||
return monthly_net, monthly_posts, months_with_data, max_gain
|
||
|
||
|
||
def _month_box_style(monthly_net, months_with_data, max_gain, month, selected):
|
||
if month not in months_with_data:
|
||
bg = "#f8f9fa"
|
||
else:
|
||
net = monthly_net.get(month, 0)
|
||
if net < 0:
|
||
bg = "#FFCDD2"
|
||
elif net == 0:
|
||
bg = "#f0f4f8"
|
||
else:
|
||
intensity = 0.15 + 0.55 * min(net / max(max_gain, 1), 1.0)
|
||
bg = f"rgba(82,196,26,{intensity:.2f})"
|
||
return {
|
||
"flex": "1", "minWidth": "0", "textAlign": "center",
|
||
"border": f"2px solid {BLUE}" if selected else "1px solid #e8e8e8",
|
||
"borderRadius": "6px", "padding": "8px 4px", "backgroundColor": bg,
|
||
"cursor": "pointer", "outline": "none", "fontFamily": "inherit", "color": "inherit",
|
||
}
|
||
|
||
|
||
def _month_box_children(monthly_net, monthly_posts, months_with_data, month):
|
||
import calendar as calmod
|
||
has_data = month in months_with_data
|
||
net = monthly_net.get(month)
|
||
post_count = monthly_posts.get(month, 0)
|
||
net_label = "—" if net is None else (f"+{net}" if net > 0 else str(net))
|
||
return [
|
||
html.Div(calmod.month_abbr[month], style={"fontWeight": "700", "fontSize": "13px"}),
|
||
html.Div(net_label, style={"fontWeight": "700", "fontSize": "16px", "marginTop": "4px",
|
||
"color": "#333" if has_data else "#bbb"}),
|
||
html.Div("followers", style={"fontSize": "9px", "color": "#888"}),
|
||
html.Div(f"{post_count} post{'s' if post_count != 1 else ''}",
|
||
style={"fontSize": "10px", "color": "#666", "marginTop": "3px"}),
|
||
]
|
||
|
||
|
||
def _month_day_tiles(fts_df, posts_df, year, month):
|
||
"""One tile per active day in `month` — weekday/day, running total, delta, and a
|
||
star + content type for days a post went out. No post titles, just date + action."""
|
||
import calendar as calmod
|
||
|
||
if month is None:
|
||
return html.Div("No activity yet.", className="text-muted", style={"padding": "8px 0", "fontSize": "0.8rem"})
|
||
|
||
month_fts = fts_df[(fts_df["Date"].dt.year == year) & (fts_df["Date"].dt.month == month)]
|
||
gains = {d: int(v) for d, v in zip(month_fts["Date"].dt.day, month_fts["Total followers"])}
|
||
totals = {d: int(v) for d, v in zip(month_fts["Date"].dt.day, month_fts["Cumulative Followers"])}
|
||
|
||
post_types_by_day = {}
|
||
if not posts_df.empty:
|
||
pmask = (posts_df["Created date"].dt.year == year) & (posts_df["Created date"].dt.month == month)
|
||
for d, ct in zip(posts_df.loc[pmask, "Created date"].dt.day, posts_df.loc[pmask, "Content Type"]):
|
||
post_types_by_day.setdefault(d, []).append(ct)
|
||
post_days = set(post_types_by_day)
|
||
|
||
active_days = {d for d, v in gains.items() if v != 0} | post_days
|
||
if not active_days:
|
||
return html.Div(f"No activity in {calmod.month_name[month]}.", className="text-muted",
|
||
style={"padding": "8px 0", "fontSize": "0.8rem"})
|
||
|
||
max_gain = max((v for v in gains.values() if v > 0), default=0)
|
||
|
||
def cell_color(v):
|
||
if v is None:
|
||
return "#f0f4f8"
|
||
if v < 0:
|
||
return "#FFCDD2"
|
||
intensity = 0.15 + 0.55 * min(v / max(max_gain, 1), 1.0)
|
||
return f"rgba(82,196,26,{intensity:.2f})"
|
||
|
||
tiles = []
|
||
for day in sorted(active_days):
|
||
v = gains.get(day)
|
||
total = totals.get(day)
|
||
starred = day in post_days
|
||
types = post_types_by_day.get(day, [])
|
||
type_label = ", ".join(sorted(set(types))) if types else None
|
||
weekday_abbr = calmod.day_abbr[calmod.weekday(year, month, day)]
|
||
if v is None:
|
||
delta_label = ""
|
||
elif v > 0:
|
||
delta_label = f"+{v}"
|
||
elif v < 0:
|
||
delta_label = str(v)
|
||
else:
|
||
delta_label = ""
|
||
tiles.append(html.Div([
|
||
html.Div(f"{weekday_abbr} {day}", style={"fontSize": "10px", "color": "#999"}),
|
||
html.Div(str(total) if total is not None else "—", style={"fontWeight": "700", "fontSize": "15px"}),
|
||
html.Div(delta_label, style={"fontSize": "10px", "color": "#666"}),
|
||
html.Div([
|
||
html.Span("★ ", style={"color": "#E53935"}),
|
||
html.Span(type_label, style={"color": FORMAT_COLORS.get(type_label, "#666")}),
|
||
], style={"fontSize": "9px", "fontWeight": "600", "marginTop": "2px"}) if starred else None,
|
||
], style={
|
||
"minWidth": "72px", "minHeight": "66px", "border": "1px solid #e8e8e8", "borderRadius": "4px",
|
||
"padding": "4px", "backgroundColor": cell_color(v), "position": "relative", "flex": "0 0 auto",
|
||
}))
|
||
return html.Div(tiles, style={"display": "flex", "gap": "4px", "flexWrap": "nowrap", "overflowX": "auto"})
|
||
|
||
|
||
def followers_calendar(fts_df, posts_df, id_prefix, height="320px"):
|
||
"""Whole-year overview: one clickable box per month (Jan-Dec), each showing net
|
||
follower change and post count. Clicking a month reveals its day tiles underneath.
|
||
A year dropdown sits above — only one year exists today, but this keeps the
|
||
calendar ready for whenever a second year of export data shows up."""
|
||
if fts_df.empty:
|
||
return html.Div("No follower data.", className="text-muted")
|
||
|
||
available_years = _available_years(fts_df, posts_df)
|
||
year = available_years[0]
|
||
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(fts_df, posts_df, year)
|
||
default_month = _default_active_month(fts_df, posts_df, year)
|
||
|
||
boxes = []
|
||
for m in range(1, 13):
|
||
boxes.append(html.Button(
|
||
_month_box_children(monthly_net, monthly_posts, months_with_data, m),
|
||
id=f"{id_prefix}-month-btn-{m}", n_clicks=0,
|
||
style=_month_box_style(monthly_net, months_with_data, max_gain, m, selected=(m == default_month))))
|
||
|
||
return html.Div([
|
||
dcc.Dropdown(
|
||
id=f"{id_prefix}-year-select",
|
||
options=[{"label": str(y), "value": y} for y in available_years],
|
||
value=year, clearable=False, searchable=False,
|
||
className="compact-dropdown",
|
||
style={"width": "90px", "marginBottom": "4px", "fontWeight": "800", "fontSize": "0.85rem"},
|
||
),
|
||
html.Div(boxes, style={"display": "flex", "gap": "6px"}),
|
||
html.Div(_month_day_tiles(fts_df, posts_df, year, default_month), id=f"{id_prefix}-posts-row",
|
||
style={"flex": "1", "overflowY": "auto", "marginTop": "8px"}),
|
||
calendar_legend(),
|
||
], style={"height": height, "display": "flex", "flexDirection": "column"})
|
||
|
||
|
||
def daily_followers_figure(fts_df, posts_df, year=None, month=None):
|
||
"""The 'New Followers per Day' bar chart, optionally scoped to one calendar month
|
||
so it can track whichever month box is selected in the calendar next to it."""
|
||
df, pdf = fts_df, posts_df
|
||
if year is not None:
|
||
mask = df["Date"].dt.year == year
|
||
if month is not None:
|
||
mask &= df["Date"].dt.month == month
|
||
df = df[mask]
|
||
if not pdf.empty:
|
||
pmask = pdf["Created date"].dt.year == year
|
||
if month is not None:
|
||
pmask &= pdf["Created date"].dt.month == month
|
||
pdf = pdf[pmask]
|
||
fig = px.bar(df, x="Date", y=["Organic followers", "Auto-invited followers"],
|
||
template=TMPL, barmode="stack",
|
||
color_discrete_sequence=[BLUE, AMBER],
|
||
labels={"value": "Followers", "variable": ""})
|
||
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")
|
||
|
||
|
||
def _reports_month_options():
|
||
"""Only months with curated narrative notes are selectable here, not every month
|
||
with export data — this tab is about the campaign's written progress reports, not
|
||
a generic timeframe filter (that's what the sidebar's PDF control already does)."""
|
||
import calendar as calmod
|
||
opts = []
|
||
for key in sorted(monthly_report_notes.NOTES.keys()):
|
||
year, month = (int(p) for p in key.split("-"))
|
||
opts.append({"label": f"{calmod.month_name[month]} {year}", "value": key})
|
||
return opts
|
||
|
||
|
||
_REPORTS_MONTH_OPTIONS = _reports_month_options()
|
||
_REPORTS_DEFAULT_MONTH = _REPORTS_MONTH_OPTIONS[-1]["value"] if _REPORTS_MONTH_OPTIONS else None
|
||
|
||
reports_tab = dbc.Container([
|
||
html.Div([
|
||
html.Div([
|
||
html.Label("Reporting Month", style={"fontSize": "0.75rem", "fontWeight": "600",
|
||
"marginBottom": "4px", "display": "block"}),
|
||
dcc.Dropdown(
|
||
id="reports-month-select",
|
||
options=_REPORTS_MONTH_OPTIONS, value=_REPORTS_DEFAULT_MONTH,
|
||
clearable=False, searchable=False, style={"width": "260px"},
|
||
),
|
||
]),
|
||
html.Div([
|
||
html.Button("Download PDF", id="reports-download-btn", n_clicks=0,
|
||
style={"padding": "8px 16px",
|
||
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
|
||
"color": "white", "border": "none", "borderRadius": "8px",
|
||
"fontSize": "0.8rem", "fontWeight": "700", "cursor": "pointer"}),
|
||
dcc.Download(id="reports-download"),
|
||
]),
|
||
], style={"display": "flex", "justifyContent": "space-between", "alignItems": "flex-end",
|
||
"marginBottom": "1.25rem"}),
|
||
html.Div(id="reports-content-area"),
|
||
], fluid=True, className="pb-3")
|
||
|
||
|
||
refresh_data()
|
||
|
||
|
||
# ── App layout ────────────────────────────────────────────────────────────────
|
||
app = Dash(
|
||
__name__,
|
||
external_stylesheets=[dbc.themes.FLATLY],
|
||
title="TXproducts LinkedIn Analytics",
|
||
)
|
||
|
||
NAV_ITEMS = [
|
||
("overview", "Overview", overview),
|
||
("content", "Content", content_tab),
|
||
("followers", "Followers", followers_tab),
|
||
("visitors", "Visitors", visitors_tab),
|
||
("competitors", "Competitors", competitors_tab),
|
||
("growth", "Growth", growth_tab),
|
||
("reports", "Reports", reports_tab),
|
||
]
|
||
TAB_CONTENT_MAP = {key: content for key, _, content in NAV_ITEMS}
|
||
|
||
TAB_HERO = {
|
||
"overview": ("Overview", "Follower growth, content performance, and visitor traffic for the TXproducts LinkedIn Page."),
|
||
"content": ("Content", "Every post's impressions, clicks, engagement rate, and CTR — sortable and searchable."),
|
||
"followers": ("Followers", "Daily follower gains and losses, with the exact running total for every active day."),
|
||
"visitors": ("Visitors", "Page views and unique visitors to the TXproducts LinkedIn Page, broken down by device and section."),
|
||
"competitors": ("Competitors", "Benchmarking TXproducts against tracked competitor pages on LinkedIn."),
|
||
"growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."),
|
||
"reports": ("Reports", "Monthly written progress reports for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory."),
|
||
}
|
||
|
||
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_subtitle_children(active):
|
||
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)]
|
||
|
||
|
||
def _hero_actions(active):
|
||
"""Follower Directory / Follower Study links shown in the hero banner — only on the
|
||
Followers tab, not in the sidebar. Both are standalone HTML files regenerated from
|
||
manual_followers_list_2026-07-22.xlsx and copied into assets/, so Dash serves them
|
||
as-is at /assets/... ; re-copy them here after every follower data update."""
|
||
if active != "followers":
|
||
return []
|
||
link_style = {
|
||
"color": "white", "fontSize": "0.8rem", "fontWeight": "600",
|
||
"textDecoration": "none", "padding": "7px 14px",
|
||
"backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px",
|
||
"border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap",
|
||
}
|
||
return [
|
||
html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank",
|
||
rel="noopener noreferrer", style=link_style),
|
||
html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
|
||
rel="noopener noreferrer", style={**link_style, "marginLeft": "8px"}),
|
||
]
|
||
|
||
_SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR)
|
||
|
||
# Sidebar — vertical logo, LinkedIn+Analytics brand mark, description, vertical nav, footer
|
||
sidebar = html.Div([
|
||
html.Img(src="/assets/txp_logo_vertical.png", style={"maxWidth": "200px", "width": "100%", "display": "block", "margin": "0 auto 12px"}),
|
||
|
||
html.Div([
|
||
html.Img(src="/assets/linkedin_logo.svg", style={"height": "30px", "marginBottom": "4px"}),
|
||
html.Span("Analytics", style={"fontSize": "1.5rem", "fontWeight": "800", "color": BLUE}),
|
||
], style={"display": "flex", "flexDirection": "column", "alignItems": "center", "margin": "4px 0 10px"}),
|
||
html.Hr(style={"borderColor": "#e0e7ef", "margin": "6px 0"}),
|
||
|
||
html.Div([
|
||
html.Div("Data Last Updated", style={
|
||
"fontSize": "0.68rem", "textTransform": "uppercase", "letterSpacing": "0.05em",
|
||
"textAlign": "center", "color": "rgba(255,255,255,0.85)",
|
||
}),
|
||
html.Div(D["last_updated"].strftime("%b %d, %Y"), id="sidebar-last-updated", style={
|
||
"fontSize": "1.05rem", "fontWeight": "800", "textAlign": "center", "color": "white",
|
||
}),
|
||
], style={
|
||
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
|
||
"borderRadius": "8px", "padding": "8px 10px", "marginBottom": "12px",
|
||
}),
|
||
|
||
html.Div([
|
||
html.Div("Growth", style={
|
||
"fontSize": "0.72rem", "fontWeight": "800", "textTransform": "uppercase",
|
||
"letterSpacing": "0.05em", "textAlign": "center", "color": "#1b3a5c", "marginBottom": "2px",
|
||
}),
|
||
html.Div("Last Week vs. Prior Week", style={
|
||
"fontSize": "0.68rem", "textTransform": "uppercase", "letterSpacing": "0.05em",
|
||
"textAlign": "center", "color": "#5a6b7d",
|
||
}),
|
||
html.Div([
|
||
html.Span(_sidebar_last_week_text(_SIDEBAR_GROWTH_HISTORY), id="sidebar-growth-lastweek",
|
||
style={"fontSize": "1.3rem", "fontWeight": "800", "color": "#1b3a5c"}),
|
||
html.Span(_sidebar_delta_text(_SIDEBAR_GROWTH_HISTORY), id="sidebar-growth-delta",
|
||
style=_sidebar_delta_style(_SIDEBAR_GROWTH_HISTORY)),
|
||
], style={"display": "flex", "alignItems": "baseline", "justifyContent": "center", "gap": "8px"}),
|
||
html.Div(_sidebar_note_text(_SIDEBAR_GROWTH_HISTORY), id="sidebar-growth-note", style={
|
||
"fontSize": "0.68rem", "color": "#5a6b7d", "textAlign": "center",
|
||
}),
|
||
], id="growth-indicator-btn", n_clicks=0, title="View full growth history", style={
|
||
"backgroundColor": "#e8f0fe",
|
||
"borderRadius": "8px", "padding": "8px 10px", "marginBottom": "12px", "cursor": "pointer",
|
||
}),
|
||
|
||
html.Div(
|
||
[html.Button(label, id=f"nav-{key}", n_clicks=0,
|
||
className="sidebar-nav-btn active" if key == "overview" else "sidebar-nav-btn")
|
||
for key, label, _ in NAV_ITEMS],
|
||
style={"display": "flex", "flexDirection": "column", "gap": "2px"},
|
||
),
|
||
html.Hr(style={"borderColor": "#e0e7ef", "margin": "6px 0"}),
|
||
|
||
html.Div([
|
||
html.Div("Upload New Data", style={
|
||
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
|
||
"color": "white", "fontWeight": "700", "fontSize": "0.78rem", "textAlign": "center",
|
||
"padding": "8px 10px", "borderRadius": "8px", "marginBottom": "6px",
|
||
}),
|
||
dcc.Upload(
|
||
id="data-upload",
|
||
children=html.Div("Drag files or a folder here, or click to browse"),
|
||
multiple=True,
|
||
style={
|
||
"width": "100%", "padding": "10px 6px", "borderWidth": "1px", "borderStyle": "dashed",
|
||
"borderColor": "#c7d2e0", "borderRadius": "6px", "textAlign": "center",
|
||
"fontSize": "0.72rem", "color": "#6b7a8d", "cursor": "pointer", "backgroundColor": "white",
|
||
},
|
||
),
|
||
html.Div(id="upload-status", style={"fontSize": "0.72rem", "marginTop": "6px", "textAlign": "center"}),
|
||
], style={"marginBottom": "4px"}),
|
||
|
||
html.Div([
|
||
html.Button("Generate PDF Report", id="generate-report-btn", n_clicks=0,
|
||
style={"width": "100%", "padding": "8px",
|
||
"background": "linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%)",
|
||
"color": "white", "border": "none", "borderRadius": "8px",
|
||
"fontSize": "0.78rem", "fontWeight": "700", "textAlign": "center",
|
||
"cursor": "pointer", "marginBottom": "6px"}),
|
||
dcc.Dropdown(
|
||
id="report-timeframe-select",
|
||
options=report.report_month_options(D["fts"]), value="all", clearable=False, searchable=False,
|
||
className="compact-dropdown",
|
||
style={"fontSize": "0.8rem"},
|
||
),
|
||
dcc.Download(id="report-download"),
|
||
], style={"marginBottom": "12px"}),
|
||
html.Hr(style={"borderColor": "#e0e7ef"}),
|
||
|
||
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"})
|
||
|
||
app.layout = html.Div([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")],
|
||
[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 = TAB_HERO[active][0]
|
||
return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), _hero_actions(active)]
|
||
|
||
|
||
FILE_TYPE_KEYWORDS = ["followers", "visitors", "competitor", "content"]
|
||
|
||
|
||
@app.callback(
|
||
Output("upload-status", "children"),
|
||
Output("sidebar-last-updated", "children", allow_duplicate=True),
|
||
Output("sidebar-growth-lastweek", "children", allow_duplicate=True),
|
||
Output("sidebar-growth-delta", "children", allow_duplicate=True),
|
||
Output("sidebar-growth-delta", "style", allow_duplicate=True),
|
||
Output("sidebar-growth-note", "children", allow_duplicate=True),
|
||
Output("report-timeframe-select", "options", allow_duplicate=True),
|
||
[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("data-upload", "contents"),
|
||
State("data-upload", "filename"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def _handle_upload(contents_list, filename_list):
|
||
"""Saves every uploaded file whose name matches a recognized LinkedIn export keyword
|
||
into UPLOAD_DIR (under its original filename — _find_latest_file/_find_all_files in
|
||
load() already do the keyword-based lookup, so no renaming is needed here), then
|
||
fully rebuilds the dashboard in place via refresh_data(). Files that don't match a
|
||
known keyword are reported back to the uploader as skipped rather than silently
|
||
ignored. Forces navigation to the Growth tab afterward so the uploader immediately
|
||
sees the new snapshot without needing to reload the page."""
|
||
no_update_rest = [no_update] * (6 + len(NAV_ITEMS) + 4)
|
||
if not contents_list:
|
||
return [no_update] + no_update_rest
|
||
|
||
saved, skipped = [], []
|
||
for content, filename in zip(contents_list, filename_list):
|
||
matched = any(kw in filename.lower() for kw in FILE_TYPE_KEYWORDS)
|
||
if not matched or not filename.lower().endswith((".xls", ".xlsx")):
|
||
skipped.append(filename)
|
||
continue
|
||
_header, b64data = content.split(",", 1)
|
||
with open(os.path.join(UPLOAD_DIR, filename), "wb") as f:
|
||
f.write(base64.b64decode(b64data))
|
||
saved.append(filename)
|
||
|
||
if not saved:
|
||
status = html.Div(
|
||
f"No recognized LinkedIn export files found among {len(skipped)} uploaded "
|
||
f"({', '.join(skipped)}). Expected filenames containing "
|
||
f"'followers', 'visitors', 'competitor', or 'content'.",
|
||
style={"color": "#d43f3a"})
|
||
return [status] + no_update_rest
|
||
|
||
refresh_data()
|
||
|
||
status_children = [html.Div(f"Saved {len(saved)} file(s): {', '.join(saved)}", style={"color": GREEN})]
|
||
if skipped:
|
||
status_children.append(html.Div(
|
||
f"Skipped {len(skipped)} unrecognized file(s): {', '.join(skipped)}",
|
||
style={"color": "#d43f3a", "marginTop": "4px"}))
|
||
|
||
history = growth.load_history(UPLOAD_DIR)
|
||
nav_classes = ["sidebar-nav-btn active" if key == "growth" else "sidebar-nav-btn" for key, _, _ in NAV_ITEMS]
|
||
hero_title = TAB_HERO["growth"][0]
|
||
hero_subtitle = _hero_subtitle_children("growth")
|
||
|
||
return [
|
||
status_children,
|
||
D["last_updated"].strftime("%b %d, %Y"),
|
||
_sidebar_last_week_text(history),
|
||
_sidebar_delta_text(history),
|
||
_sidebar_delta_style(history),
|
||
_sidebar_note_text(history),
|
||
report.report_month_options(D["fts"]),
|
||
*nav_classes,
|
||
TAB_CONTENT_MAP["growth"],
|
||
hero_title,
|
||
hero_subtitle,
|
||
_hero_actions("growth"),
|
||
]
|
||
|
||
|
||
@app.callback(
|
||
Output("fb-expanded", "data", allow_duplicate=True),
|
||
Input("fb-expand-btn", "n_clicks"),
|
||
State("fb-expanded", "data"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def _toggle_followers_expand(_, expanded):
|
||
return not expanded
|
||
|
||
|
||
@app.callback(
|
||
Output("followers-breakdown-area", "children"),
|
||
Output("fb-expand-btn", "children"),
|
||
Output("fb-expand-btn", "style"),
|
||
Output("fb-month-label", "children"),
|
||
Input("fb-select", "value"),
|
||
Input("fb-period-select", "value"),
|
||
Input("fb-expanded", "data"),
|
||
Input("followers-selected-month", "data"),
|
||
)
|
||
def _render_followers_breakdown(active, period, expanded, selected_month):
|
||
btn_style_base = {"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
|
||
"background": "none", "color": "white", "textDecoration": "underline",
|
||
"cursor": "pointer", "padding": "0"}
|
||
|
||
if period != "total":
|
||
year, month = (int(p) for p in period.split("-"))
|
||
btn_style = {**btn_style_base, "display": "none"}
|
||
|
||
if (year, month) in set(_followers_count_only_months()):
|
||
net, sign, detail = _monthly_count_summary(year, month)
|
||
month_label = f"{_MONTH_NAMES[month - 1]} {year} — no category breakdown available"
|
||
note = html.Div([
|
||
html.Div(f"Net {sign}{net} follower{'s' if abs(net) != 1 else ''} in "
|
||
f"{_MONTH_NAMES[month - 1]} {year}: {detail}.",
|
||
style={"marginBottom": "8px", "fontWeight": "700", "color": "#1a2533"}),
|
||
html.Div("Category breakdown (location, industry, etc.) isn't available for these — "
|
||
"LinkedIn's demographic export wasn't captured yet at this point, so there's "
|
||
"no earlier snapshot to know which category they belong to.",
|
||
style={"color": "#6b7a8d", "fontSize": "0.8rem"}),
|
||
], style={"padding": "20px 4px"})
|
||
return note, "", btn_style, month_label
|
||
|
||
label, fig, changed_n, overflow = _breakdown_delta_figure(active, year, month)
|
||
month_label = f"{_MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
|
||
if overflow > 0:
|
||
month_label += f" · showing top {_DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
|
||
return [G(fig, style={"height": "240px"}), delta_legend()], "", btn_style, month_label
|
||
|
||
year = selected_month.get("year") if selected_month else None
|
||
month = selected_month.get("month") if selected_month else None
|
||
label, fig, total_n = _breakdown_figure(active, expanded, year, month)
|
||
needs_toggle = total_n > 10
|
||
btn_style = {**btn_style_base, "display": "inline-block" if needs_toggle else "none"}
|
||
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
|
||
month_label = f"as of {_MONTH_NAMES[month - 1]} {year}" if year and month else ""
|
||
return G(fig, style={"height": "240px"}), btn_label, btn_style, month_label
|
||
|
||
|
||
@app.callback(
|
||
Output("vb-expanded", "data", allow_duplicate=True),
|
||
Input("vb-expand-btn", "n_clicks"),
|
||
State("vb-expanded", "data"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def _toggle_visitors_expand(_, expanded):
|
||
return not expanded
|
||
|
||
|
||
@app.callback(
|
||
Output("visitor-breakdown-area", "children"),
|
||
Output("vb-expand-btn", "children"),
|
||
Output("vb-expand-btn", "style"),
|
||
Output("vb-month-label", "children"),
|
||
Input("vb-select", "value"),
|
||
Input("vb-period-select", "value"),
|
||
Input("vb-expanded", "data"),
|
||
)
|
||
def _render_visitor_breakdown(active, period, expanded):
|
||
btn_style_base = {"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
|
||
"background": "none", "color": "white", "textDecoration": "underline",
|
||
"cursor": "pointer", "padding": "0"}
|
||
|
||
if period != "total":
|
||
year, month = (int(p) for p in period.split("-"))
|
||
label, fig, total_n = _visitor_breakdown_figure(active, expanded, year, month)
|
||
month_label = f"as of {_MONTH_NAMES[month - 1]} {year}"
|
||
else:
|
||
label, fig, total_n = _visitor_breakdown_figure(active, expanded)
|
||
month_label = ""
|
||
|
||
needs_toggle = total_n > 10
|
||
btn_style = {**btn_style_base, "display": "inline-block" if needs_toggle else "none"}
|
||
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
|
||
return G(fig, style={"height": "240px"}), btn_label, btn_style, month_label
|
||
|
||
|
||
@app.callback(
|
||
Output("frr-area", "children"),
|
||
Input("frr-month-select", "value"),
|
||
)
|
||
def _render_follow_rate(period):
|
||
year, month = (int(p) for p in period.split("-"))
|
||
fig = follow_rate_figure(D["frr"], year, month)
|
||
return [G(fig, style={"height": "210px"}), follow_rate_legend()]
|
||
|
||
|
||
def _register_month_click_callback(id_prefix):
|
||
extra_outputs = [Output("followers-selected-month", "data")] if id_prefix == "followers" else []
|
||
|
||
@app.callback(
|
||
[Output(f"{id_prefix}-month-btn-{m}", "children") for m in range(1, 13)]
|
||
+ [Output(f"{id_prefix}-month-btn-{m}", "style") for m in range(1, 13)]
|
||
+ [Output(f"{id_prefix}-posts-row", "children"), Output(f"{id_prefix}-daily-followers-graph", "figure")]
|
||
+ extra_outputs,
|
||
[Input(f"{id_prefix}-year-select", "value")]
|
||
+ [Input(f"{id_prefix}-month-btn-{m}", "n_clicks") for m in range(1, 13)],
|
||
)
|
||
def _update_month_selection(year, *_):
|
||
triggered = ctx.triggered_id
|
||
if triggered and triggered.startswith(f"{id_prefix}-month-btn-"):
|
||
month = int(triggered.rsplit("-", 1)[-1])
|
||
else:
|
||
month = _default_active_month(D["fts"], D["posts"], year)
|
||
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(D["fts"], D["posts"], year)
|
||
children = [_month_box_children(monthly_net, monthly_posts, months_with_data, m) for m in range(1, 13)]
|
||
styles = [_month_box_style(monthly_net, months_with_data, max_gain, m, selected=(m == month))
|
||
for m in range(1, 13)]
|
||
result = children + styles + [_month_day_tiles(D["fts"], D["posts"], year, month),
|
||
daily_followers_figure(D["fts"], D["posts"], year, month)]
|
||
if id_prefix == "followers":
|
||
result.append({"year": year, "month": month})
|
||
return result
|
||
|
||
|
||
for _prefix in ("overview", "followers"):
|
||
_register_month_click_callback(_prefix)
|
||
|
||
|
||
@app.callback(
|
||
Output("overview-impressions-graph", "figure"),
|
||
Output("overview-engagement-graph", "figure"),
|
||
Output("overview-ctr-graph", "figure"),
|
||
Output("overview-pageviews-graph", "figure"),
|
||
Input("overview-year-select", "value"),
|
||
[Input(f"overview-month-btn-{m}", "n_clicks") for m in range(1, 13)],
|
||
)
|
||
def _sync_overview_metric_graphs(year, *_):
|
||
triggered = ctx.triggered_id
|
||
if triggered and triggered.startswith("overview-month-btn-"):
|
||
month = int(triggered.rsplit("-", 1)[-1])
|
||
else:
|
||
month = _default_active_month(D["fts"], D["posts"], year)
|
||
cts = _filtered_by_month(D["cts"], year, month)
|
||
vts = _filtered_by_month(D["vts"], year, month)
|
||
return impressions_figure(cts), engagement_figure(cts), ctr_figure(cts), page_views_figure(vts)
|
||
|
||
|
||
@app.callback(
|
||
Output("posts-table", "data"),
|
||
Output("posts-table", "tooltip_data"),
|
||
Input("content-month-select", "value"),
|
||
)
|
||
def _filter_posts_table(selected_month):
|
||
records = _posts_table_records(selected_month)
|
||
tooltip_data = [{c: {"value": str(row[c]), "type": "markdown"} for c in post_table_cols} for row in records]
|
||
return records, tooltip_data
|
||
|
||
|
||
@app.callback(
|
||
Output("content-impressions-fig", "figure"),
|
||
Output("content-engagement-fig", "figure"),
|
||
Output("content-ctr-fig", "figure"),
|
||
Output("content-scatter-fig", "figure"),
|
||
Output("content-reactions-fig", "figure"),
|
||
Output("content-clicks-fig", "figure"),
|
||
Input("content-month-select", "value"),
|
||
)
|
||
def _sync_content_charts(selected_month):
|
||
posts_f = _content_posts_filtered(selected_month)
|
||
cts_f = _content_cts_filtered(selected_month)
|
||
return (
|
||
_finalize_fig(impressions_by_format_figure(posts_f), margin=CONTENT_BAR_MARGIN),
|
||
_finalize_fig(engagement_by_format_figure(posts_f), margin=CONTENT_BAR_MARGIN),
|
||
_finalize_fig(ctr_by_format_figure(posts_f), margin=CONTENT_BAR_MARGIN),
|
||
_finalize_fig(impressions_vs_engagement_figure(posts_f), legend=CONTENT_SCATTER_LEGEND),
|
||
_finalize_fig(reactions_figure(cts_f)),
|
||
_finalize_fig(clicks_figure(cts_f)),
|
||
)
|
||
|
||
|
||
def _build_report_ctx():
|
||
"""Shared by the sidebar's generic PDF control and the Reports tab, so both build
|
||
the exact same ctx_data dict for report.build_report_pdf/compute_period_stats."""
|
||
return {
|
||
"D": D, "posts": posts, "comp_ordered": comp_ordered,
|
||
"current_total_followers": current_total_followers,
|
||
"daily_followers_figure": daily_followers_figure,
|
||
"std_fn": _std, "TMPL": TMPL, "FORMAT_COLORS": FORMAT_COLORS,
|
||
"growth_snapshot": GROWTH_SNAPSHOT, "directory_data": DIRECTORY_DATA,
|
||
}
|
||
|
||
|
||
@app.callback(
|
||
Output("report-download", "data"),
|
||
Input("generate-report-btn", "n_clicks"),
|
||
State("report-timeframe-select", "value"),
|
||
prevent_initial_call=True,
|
||
)
|
||
def _generate_report(_n_clicks, timeframe):
|
||
pdf_bytes = report.build_report_pdf(_build_report_ctx(), timeframe)
|
||
label = next((o["label"] for o in report.report_month_options(D["fts"]) if o["value"] == timeframe), timeframe)
|
||
safe_label = label.replace(" ", "_")
|
||
filename = f"TXproducts_LinkedIn_Report_{safe_label}.pdf"
|
||
return dcc.send_bytes(lambda buf: buf.write(pdf_bytes), filename=filename)
|
||
|
||
|
||
def _narrative_section(title, items):
|
||
if not items:
|
||
return html.Div()
|
||
return html.Div([
|
||
html.H5(title, style={"marginTop": "18px", "marginBottom": "8px", "color": "#1b3a5c"}),
|
||
html.Ul([html.Li(item, style={"marginBottom": "4px"}) for item in items],
|
||
style={"fontSize": "0.85rem", "paddingLeft": "1.1rem"}),
|
||
])
|
||
|
||
|
||
def _composition_table(counter, header_left, top_n=None):
|
||
"""No truncation by default, sector/seniority/function are a small, fixed taxonomy
|
||
(see project memory's classification categories), and country is uncapped by
|
||
composition_stats() for the same reason, so showing everything keeps the table's
|
||
own sum matching the cohort total instead of silently dropping tail categories."""
|
||
items = counter.most_common(top_n) if hasattr(counter, "most_common") else list(counter)[:top_n]
|
||
return dbc.Table([
|
||
html.Thead(html.Tr([html.Th(header_left), html.Th("Count")])),
|
||
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in items]),
|
||
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
|
||
|
||
|
||
def _reports_posts_table(posts_f):
|
||
"""Every post in the period, listed chronologically, not just a top-N by engagement.
|
||
A monthly campaign report should show the full picture of what was published, since
|
||
the point is a complete record, not a highlights reel."""
|
||
if not len(posts_f):
|
||
return html.Div("No posts published in this period.", className="text-muted")
|
||
all_posts = posts_f.sort_values("Created date")
|
||
return dbc.Table([
|
||
html.Thead(html.Tr([html.Th(c) for c in
|
||
["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]])),
|
||
html.Tbody([
|
||
html.Tr([
|
||
html.Td(str(r["Short title"])[:60]),
|
||
html.Td(r["Created date"].strftime("%d/%m/%Y")),
|
||
html.Td(r["Content Type"]),
|
||
html.Td(f"{int(r['Impressions']):,}"),
|
||
html.Td(f"{r['Eng %']:.1f}%"),
|
||
html.Td(f"{r['CTR %']:.1f}%"),
|
||
]) for _, r in all_posts.iterrows()
|
||
]),
|
||
], bordered=True, size="sm", style={"fontSize": "0.8rem"})
|
||
|
||
|
||
@app.callback(
|
||
Output("reports-content-area", "children"),
|
||
Input("reports-month-select", "value"),
|
||
)
|
||
def _render_reports_tab(month_value):
|
||
if not month_value:
|
||
return dbc.Alert("No campaign months have been written up yet.", color="info")
|
||
|
||
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"]
|
||
|
||
kpi_row = html.Div([
|
||
html.Div(kpi("Total Followers", f"{current_total_followers:,}", "as of latest export"),
|
||
style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("New Followers", f"{stats['new_followers']:,}", stats["period_label"]),
|
||
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("Total Page Views", f"{stats['total_page_views']:,}"), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Best Post Eng.", f"{stats['best_eng']:.1f}%"), style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi("Avg. Eng. Rate", f"{stats['avg_eng']:.2f}%" if stats["avg_eng"] else "—"),
|
||
style={"flex": "1", "minWidth": "0"}),
|
||
html.Div(kpi(
|
||
"Monthly Growth Rate",
|
||
f"{stats['monthly_growth_rate']:+.1f}%" if stats["monthly_growth_rate"] is not None else "—",
|
||
(f"+{stats['new_followers']:,} vs {stats['start_of_month_followers']:,} at start "
|
||
f"of {stats['period_label']}") if stats["start_of_month_followers"] 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": "1.25rem", "flexWrap": "wrap"})
|
||
|
||
chart_card = dbc.Card([
|
||
dbc.CardHeader("Follower Growth"),
|
||
dbc.CardBody(G(daily_followers_figure(fts_f, posts_f), style={"height": "280px"}) if not fts_f.empty
|
||
else html.Div("No follower data for this period.", className="text-muted")),
|
||
], className="mb-3 shadow-sm")
|
||
|
||
posts_card = dbc.Card([
|
||
dbc.CardHeader(f"All Posts, {stats['period_label']}"),
|
||
dbc.CardBody(_reports_posts_table(posts_f)),
|
||
], className="mb-3 shadow-sm")
|
||
|
||
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value)
|
||
all_time = follower_directory_data.composition_stats(DIRECTORY_DATA)
|
||
if cohort["total"]:
|
||
composition_body = [
|
||
html.P([
|
||
f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}. "
|
||
f"Of those, ",
|
||
html.Strong(f"{cohort['total']} have been identified and profiled"),
|
||
f" in the Follower Directory so far (a separate, hand-maintained audit of who our "
|
||
f"followers are, not a second follower count), out of {all_time['total']} audited "
|
||
f"across the whole page to date.",
|
||
], style={"fontSize": "0.85rem"}),
|
||
html.Div([
|
||
html.Div(_composition_table(cohort["sector"], "Sector (this month)"),
|
||
style={"flex": "1", "minWidth": "220px"}),
|
||
html.Div(_composition_table(cohort["seniority"], "Seniority (this month)"),
|
||
style={"flex": "1", "minWidth": "220px"}),
|
||
html.Div(_composition_table(cohort["country"], "Country (this month)"),
|
||
style={"flex": "1", "minWidth": "220px"}),
|
||
], style={"display": "flex", "gap": "1rem", "flexWrap": "wrap"}),
|
||
]
|
||
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_card = dbc.Card([
|
||
dbc.CardHeader("Audience Composition (Follower Directory Coverage)"),
|
||
dbc.CardBody(composition_body + [
|
||
html.P("The Follower Directory is a hand-maintained audit that lags real LinkedIn follows "
|
||
"and gets corrected after the fact as more profiles are screenshotted and "
|
||
"cross-referenced, so this is a coverage figure that can still grow in later "
|
||
"updates, not a final count.", className="text-muted",
|
||
style={"fontSize": "0.75rem", "fontStyle": "italic", "marginTop": "10px"}),
|
||
]),
|
||
], className="mb-3 shadow-sm")
|
||
|
||
if notes:
|
||
narrative_card = dbc.Card([
|
||
dbc.CardHeader("Monthly Notes"),
|
||
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")
|
||
else:
|
||
narrative_card = dbc.Alert("Notes for this month have not been written yet.", color="warning")
|
||
|
||
return html.Div([kpi_row, chart_card, posts_card, composition_card, narrative_card])
|
||
|
||
|
||
@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)
|
||
pdf_bytes = report.build_report_pdf(ctx_data, month_value)
|
||
label = next((o["label"] for o in _REPORTS_MONTH_OPTIONS if o["value"] == month_value), month_value)
|
||
filename = f"TXproducts_LinkedIn_Report_{label.replace(' ', '_')}.pdf"
|
||
return dcc.send_bytes(lambda buf: buf.write(pdf_bytes), filename=filename)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=False, port=8051)
|