- Hero banner now shows Follower Directory / Follower Study links on the Followers tab only (not the sidebar), opening the standalone reports served from assets/. - Followers by Country: _COUNTRY_CENTROIDS expanded from 14 hardcoded entries to a near-complete world table, so a country missing from the old dict no longer vanishes from the map silently; added a console warning for any country still missing a centroid, and a "N countries represented" caption computed from the same rollup the map uses so the two can never disagree. - Added visitor_history.py: append-only observation log for the Visitors export's category breakdowns (Location/Seniority/Company size), since LinkedIn only shows a category if it clears a per-export minimum-visits threshold and otherwise silently drops it. - Synced other accumulated app.py/build_static_export.py work not yet committed: calendar month drill-down tiles, follower-breakdown expand toggle, CTR explainer, manual post title overrides, and matching static- export precompute/JS for the calendar and content tabs. - .gitignore: exclude the newer generated snapshot outputs (follower study/ directory HTML copies, PDF report, visitor_category_history.json) using the same rationale as the existing growth_history.json/snapshot rules — regenerable from code + data, not itself code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2519 lines
134 KiB
Python
2519 lines
134 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
|
||
|
||
# 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")
|
||
|
||
# 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",
|
||
}
|
||
|
||
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_month_options(D["frr"])[0]["value"] if _frr_month_options(D["frr"]) else None,
|
||
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_month_options(D["frr"])[0]["value"].split("-")]
|
||
if _frr_month_options(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,
|
||
}
|
||
|
||
|
||
|
||
# ── 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 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")
|
||
|
||
|
||
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),
|
||
]
|
||
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."),
|
||
}
|
||
|
||
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)),
|
||
)
|
||
|
||
|
||
@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):
|
||
ctx_data = {
|
||
"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,
|
||
}
|
||
pdf_bytes = report.build_report_pdf(ctx_data, 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)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(debug=False, port=8051)
|