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.
1899 lines
97 KiB
Python
1899 lines
97 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
|
||
|
||
# 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)
|