Files
LinkedIn-Analytics-Dashboard/app.py
T
beantownbabe 348fe65b88 Initial commit: TXproducts LinkedIn Analytics Dashboard
Dash app with Overview, Content, Followers, Visitors, Competitors, and
Growth tabs. Includes weekly-pace tracking, PDF report generation, data
upload with live refresh, and a static HTML export builder for sharing
via NextCloud.
2026-07-21 15:14:28 +02:00

1899 lines
97 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""TXproducts LinkedIn Analytics Dashboard"""
import warnings; warnings.filterwarnings("ignore")
import 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
# 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",
}
# ── 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"
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",
"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 _find_latest_file(keyword, extensions=(".xls", ".xlsx")):
"""Most recently modified file across DATA_DIR and UPLOAD_DIR whose name contains
`keyword` (case-insensitive) — 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 keyword.lower() in fname.lower() and fname.lower().endswith(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 keyword.lower() in fname.lower() and fname.lower().endswith(extensions):
candidates.append(os.path.join(folder, fname))
return sorted(set(candidates), key=os.path.getmtime)
# ── Load data ─────────────────────────────────────────────────────────────────
def load():
d = {}
file_mtimes = []
# Followers
fp = _find_latest_file("followers")
file_mtimes.append(os.path.getmtime(fp))
fts = pd.read_excel(fp, sheet_name="New followers")
fts["Date"] = pd.to_datetime(fts["Date"])
fts = fts.sort_values("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
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")
# Visitors
vp = _find_latest_file("visitors")
file_mtimes.append(os.path.getmtime(vp))
vts = pd.read_excel(vp, sheet_name="Visitor metrics")
vts["Date"] = pd.to_datetime(vts["Date"])
d["vts"] = vts
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")
# 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
# 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')}"
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 _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_size=20,
style_table={"overflowX": "auto"},
style_header={"backgroundColor": "#f0f4f8", "color": "#333", "fontWeight": "bold", "border": "1px solid #ccc",
"fontSize": "11px", "padding": "3px 6px", "height": "22px", "lineHeight": "16px"},
style_cell={"backgroundColor": "#ffffff", "color": "#333", "border": "1px solid #e0e0e0",
"fontSize": "11px", "padding": "3px 6px", "height": "22px", "lineHeight": "16px",
"textAlign": "left",
"maxWidth": "300px", "overflow": "hidden", "textOverflow": "ellipsis"},
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()]
def _breakdown_figure(key, expanded):
label, df, col, color = FOLLOWER_BREAKDOWN_MAP[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
# Approximate geographic centroid for each country that currently appears in the export —
# used to place one marker per country rather than one per city.
_COUNTRY_CENTROIDS = {
"Germany": (51.1657, 10.4515),
"United Kingdom": (55.3781, -3.4360),
"Austria": (47.5162, 14.5501),
"Netherlands": (52.1326, 5.2913),
"France": (46.6034, 1.8883),
"Sweden": (60.1282, 18.6435),
"Norway": (60.4720, 8.4689),
"Poland": (51.9194, 19.1451),
"Czechia": (49.8175, 15.4730),
"India": (20.5937, 78.9629),
"China": (35.8617, 104.1954),
"Canada": (56.1304, -106.3468),
"Tunisia": (33.8869, 9.5375),
}
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 = floc_df.copy()
by_country["Country"] = by_country["Location"].str.split(",").str[-1].str.strip()
by_country = by_country.groupby("Country", as_index=False)["Total followers"].sum()
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])
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=8 column at 240px tall) — fitbounds="locations" left large
# blank side margins here, since our data's true shape (Canada-to-China, ~5.8:1) didn't
# match the box's shape and Plotly pads rather than crops to preserve true geographic
# proportions. This is tuned to an estimated box width, not a measured one (no browser
# available to verify pixel dimensions here) — nudge lataxis_range 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=[-115, 133], lataxis_range=[3, 78])
return fig
_init_label, _init_fig, _init_total = _breakdown_figure("company_size", False)
FOLLOWERS_BY_EXPLAINER = html.Div([
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"}),
])
followers_tab = dbc.Container([
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-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"}),
dbc.CardBody(G(_init_fig, style={"height": "240px"}), id="followers-breakdown-area"),
dcc.Store(id="fb-expanded", data=False),
], className="mb-3 shadow-sm h-100"), md=4),
dbc.Col(section("Followers by Country",
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=8),
], 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(
"This correlates each day's total unique visitors against that day's net 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={"color": "#6b7a8d"},
),
])
_conv_rate_df = D["conv"].dropna(subset=["Conversion Rate"]).copy()
_conv_rate_df["Sign"] = _conv_rate_df["Conversion Rate"].apply(lambda v: "Positive" if v >= 0 else "Negative")
# key -> (label, dataframe sorted largest-first, category column)
# Same single light-blue accent as Followers By — color was never meaningful data here either.
VISITOR_BREAKDOWN_MAP = {
"seniority": ("Seniority", D["vsen"].sort_values("Total views", ascending=False), "Seniority"),
"company_size": ("Company Size", D["vcs"].sort_values("Total views", ascending=False), "Company size"),
"location": ("Location", D["vloc"].sort_values("Total views", ascending=False), "Location"),
}
VISITOR_BREAKDOWNS = [(key, label) for key, (label, *_rest) in VISITOR_BREAKDOWN_MAP.items()]
def _visitor_breakdown_figure(key):
label, df, col = VISITOR_BREAKDOWN_MAP[key]
fig = px.bar(df.sort_values("Total views"), x="Total views", y=col,
orientation="h", template=TMPL, color_discrete_sequence=[LIGHT_BLUE])
fig.update_layout(showlegend=False)
return label, fig
_vb_init_label, _vb_init_fig = _visitor_breakdown_figure("seniority")
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("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(px.bar(_conv_rate_df, x="Date", y="Conversion Rate", template=TMPL,
color="Sign", color_discrete_map={"Positive": GREEN, "Negative": "#d43f3a"},
labels={"Conversion Rate": "Conversion Rate (%)"},
).update_layout(showlegend=False), style={"height": "300px"})),
], 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-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"},
),
info_popover("visitor-terminology", VISITOR_TERMINOLOGY_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
dbc.CardBody(G(_vb_init_fig, style={"height": "240px"}), id="visitor-breakdown-area"),
], 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",
), 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": "wrap"})
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 _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"
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"})
# ── 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("Visits to your LinkedIn Page, 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."),
}
_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 = 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={
"background": "linear-gradient(90deg,#1b3a5c 0%,#2d6a9f 100%)",
"borderRadius": "8px", "padding": "14px 18px 13px", "marginTop": "4px", "marginBottom": "16px",
})
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")],
[Input(f"nav-{key}", "n_clicks") for key, _, _ in NAV_ITEMS]
+ [Input("growth-indicator-btn", "n_clicks")],
)
def _switch_tab(*_):
triggered = ctx.triggered_id
if triggered == "growth-indicator-btn":
active = "growth"
else:
active = triggered.replace("nav-", "") if triggered else "overview"
classes = ["sidebar-nav-btn active" if key == active else "sidebar-nav-btn" for key, _, _ in NAV_ITEMS]
hero_title, hero_subtitle = TAB_HERO[active]
return classes + [TAB_CONTENT_MAP[active], hero_title, hero_subtitle]
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),
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) + 3)
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, hero_subtitle = TAB_HERO["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,
]
@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"),
Input("fb-select", "value"),
Input("fb-expanded", "data"),
)
def _render_followers_breakdown(active, expanded):
label, fig, total_n = _breakdown_figure(active, expanded)
needs_toggle = total_n > 10
btn_style = {"display": "inline-block" if needs_toggle else "none",
"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
"background": "none", "color": "white", "textDecoration": "underline",
"cursor": "pointer", "padding": "0"}
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
return G(fig, style={"height": "240px"}), btn_label, btn_style
@app.callback(
Output("visitor-breakdown-area", "children"),
Input("vb-select", "value"),
)
def _render_visitor_breakdown(active):
label, fig = _visitor_breakdown_figure(active)
return G(fig, style={"height": "240px"})
def _register_month_click_callback(id_prefix):
@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")],
[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)]
return children + styles + [_month_day_tiles(D["fts"], D["posts"], year, month),
daily_followers_figure(D["fts"], D["posts"], year, month)]
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)