commit 348fe65b888284ef2b31ed66945ceefda81e6b93
Author: beantownbabe <156287695+beantownbabe@users.noreply.github.com>
Date: Tue Jul 21 15:14:28 2026 +0200
Initial commit: TXproducts LinkedIn Analytics Dashboard
Dash app with Overview, Content, Followers, Visitors, Competitors, and
Growth tabs. Includes weekly-pace tracking, PDF report generation, data
upload with live refresh, and a static HTML export builder for sharing
via NextCloud.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f255fde
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+# Real LinkedIn export data — not shared code, uploaded fresh each week via the dashboard
+*.xls
+*.xlsx
+growth_history.json
+
+# Generated output — regenerate locally with build_static_export.py, not shared code
+TXproducts_Dashboard_Snapshot.html
+
+# Python
+__pycache__/
+*.pyc
+
+# OS
+.DS_Store
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..9d47eb7
--- /dev/null
+++ b/app.py
@@ -0,0 +1,1898 @@
+"""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="Post published — %{x|%b %d, %Y}
%{customdata}",
+ ))
+ 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}",
+ ))
+ # 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}
%{y:,} followers")
+ 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}
%{y:.2f}%"))
+ 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}
%{y:.2f}%"))
+ 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)
diff --git a/assets/custom.css b/assets/custom.css
new file mode 100644
index 0000000..e4e8863
--- /dev/null
+++ b/assets/custom.css
@@ -0,0 +1,113 @@
+/* TXproducts design language, matched to txdata_project/portal.py */
+
+body {
+ background: #f7f9fc;
+ font-family: -apple-system, "Source Sans Pro", "Segoe UI", Helvetica, Arial, sans-serif;
+}
+
+/* Section cards */
+.card {
+ border: 1px solid #e0e7ef !important;
+ border-radius: 8px !important;
+ box-shadow: 0 1px 3px rgba(27, 58, 92, 0.06) !important;
+}
+
+.card-header {
+ background: linear-gradient(90deg, #1b3a5c 0%, #2d6a9f 100%) !important;
+ color: white !important;
+ border-radius: 7px 7px 0 0 !important;
+ border: none !important;
+ font-size: 0.85rem;
+ letter-spacing: 0.2px;
+ padding: 0.55rem 1rem !important;
+}
+
+.card-body {
+ background: #ffffff;
+ border-radius: 0 0 7px 7px;
+}
+
+hr {
+ border-color: #e0e7ef !important;
+}
+
+/* Tabs */
+.nav-tabs {
+ border-bottom: 1px solid #e0e7ef !important;
+}
+.nav-tabs .nav-link {
+ color: #6b7a8d !important;
+ font-weight: 500;
+ border: none !important;
+ border-bottom: 2px solid transparent !important;
+}
+.nav-tabs .nav-link.active {
+ color: #1b3a5c !important;
+ font-weight: 700;
+ border-bottom: 2px solid #2d6a9f !important;
+ background: transparent !important;
+}
+
+/* DataTable polish */
+.dash-table-container {
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+/* Compact year picker on the follower calendar */
+.compact-dropdown .Select-control {
+ height: 28px !important;
+ min-height: 28px !important;
+}
+.compact-dropdown .Select-value, .compact-dropdown .Select-placeholder, .compact-dropdown .Select-input {
+ line-height: 26px !important;
+ padding: 0 8px !important;
+}
+.compact-dropdown .Select-input {
+ height: 26px !important;
+}
+.compact-dropdown .Select-arrow-zone {
+ height: 26px !important;
+}
+
+/* Growth tab history log accordion — match the dashboard's font instead of Bootstrap/FLATLY's default */
+.accordion-button {
+ font-family: inherit;
+ font-size: 0.82rem;
+ color: #1a2533;
+}
+.accordion-button:not(.collapsed) {
+ color: #1b3a5c;
+ background-color: #e8f0fe;
+ box-shadow: none;
+}
+.accordion-button:focus {
+ box-shadow: none;
+ border-color: #e0e7ef;
+}
+
+/* Sidebar vertical nav */
+.sidebar-nav-btn {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ border-left: 3px solid transparent;
+ color: #6b7a8d;
+ font-weight: 500;
+ font-size: 0.92rem;
+ padding: 8px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+.sidebar-nav-btn:hover {
+ background: #f7f9fc;
+ color: #1b3a5c;
+}
+.sidebar-nav-btn.active {
+ background: #e8f0fe;
+ color: #1b3a5c;
+ font-weight: 700;
+ border-left: 3px solid #2d6a9f;
+}
diff --git a/assets/linkedin_logo.svg b/assets/linkedin_logo.svg
new file mode 100644
index 0000000..d1ebedc
--- /dev/null
+++ b/assets/linkedin_logo.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/assets/txp_logo_horizontal.png b/assets/txp_logo_horizontal.png
new file mode 100644
index 0000000..b09f894
Binary files /dev/null and b/assets/txp_logo_horizontal.png differ
diff --git a/assets/txp_logo_vertical.png b/assets/txp_logo_vertical.png
new file mode 100644
index 0000000..bcc9cce
Binary files /dev/null and b/assets/txp_logo_vertical.png differ
diff --git a/build_static_export.py b/build_static_export.py
new file mode 100644
index 0000000..579c3e7
--- /dev/null
+++ b/build_static_export.py
@@ -0,0 +1,569 @@
+"""Builds a single self-contained HTML file snapshot of the live Dash dashboard, for
+sharing with people who don't have the app running (e.g. via NextCloud/email).
+
+Reuses app.py's data loading and figure-builder functions directly. Converts the Dash
+component trees (dbc.Card/Row/Col are just Bootstrap-class wrappers) into plain HTML,
+so the same Bootstrap CSS renders them identically. Charts render via Plotly.js from
+embedded JSON. A handful of interactive elements (calendar month click, breakdown
+dropdowns, content month filter) have every possible state precomputed and swapped
+client-side with vanilla JS — everything else is a static snapshot of today's data.
+"""
+import base64
+import html as html_escape_mod
+import json
+import mimetypes
+import os
+import re
+
+import app as A
+from dash import html, dcc, dash_table
+import dash_bootstrap_components as dbc
+
+CHARTS = {} # chart div id -> plotly figure json string
+CHART_COUNTER = [0]
+ASSETS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
+_ASSET_CACHE = {}
+
+
+def esc(s):
+ return html_escape_mod.escape(str(s), quote=True)
+
+
+def inline_asset(src):
+ """Rewrite /assets/foo.png into a base64 data: URI so the exported file is fully
+ self-contained (no separate image files needed alongside it)."""
+ if not src or not src.startswith("/assets/"):
+ return src
+ if src in _ASSET_CACHE:
+ return _ASSET_CACHE[src]
+ path = os.path.join(ASSETS_DIR, os.path.basename(src))
+ if not os.path.isfile(path):
+ return src
+ mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
+ with open(path, "rb") as f:
+ b64 = base64.b64encode(f.read()).decode("ascii")
+ uri = f"data:{mime};base64,{b64}"
+ _ASSET_CACHE[src] = uri
+ return uri
+
+
+def style_dict_to_css(style):
+ if not style:
+ return ""
+ parts = []
+ for k, v in style.items():
+ # camelCase -> kebab-case
+ css_key = re.sub(r"([A-Z])", r"-\1", k).lower()
+ parts.append(f"{css_key}:{v}")
+ return ";".join(parts)
+
+
+def conditional_row_bg(conditional, row, idx):
+ """Replays a DataTable's style_data_conditional row_index/filter_query rules for one
+ row — later rules override earlier ones on a match, same as Dash's own precedence — so
+ the best-engagement/best-CTR highlight still shows up here, not just in the live app.
+ Only supports the simple "{Col} = value" equality form filter_query uses in this
+ codebase, not the full Dash query mini-language. Caller applies the result per-
,
+ not on the | — Bootstrap 5 paints table backgrounds via a CSS variable applied
+ directly to each cell, which sits on top of and hides a tr-level background-color."""
+ bg = None
+ for rule in conditional:
+ cond = rule.get("if", {})
+ matched = False
+ if "row_index" in cond:
+ ri = cond["row_index"]
+ if ri == "odd" and idx % 2 == 1:
+ matched = True
+ elif ri == "even" and idx % 2 == 0:
+ matched = True
+ elif isinstance(ri, int) and idx == ri:
+ matched = True
+ elif "filter_query" in cond:
+ m = re.match(r"\{([^}]+)\}\s*=\s*(.+)", cond["filter_query"])
+ if m:
+ col, val = m.group(1), m.group(2)
+ cell = row.get(col)
+ try:
+ matched = float(cell) == float(val)
+ except (TypeError, ValueError):
+ matched = str(cell) == val
+ if matched and "backgroundColor" in rule:
+ bg = rule["backgroundColor"]
+ return bg
+
+
+def next_chart_id():
+ CHART_COUNTER[0] += 1
+ return f"chart-{CHART_COUNTER[0]}"
+
+
+def register_chart(fig, style=None, comp_id=None):
+ cid = comp_id or next_chart_id()
+ CHARTS[cid] = json.loads(fig.to_json())
+ height = "320px"
+ if style and "height" in style:
+ height = style["height"]
+ return f''
+
+
+def render(comp):
+ """Recursively convert a Dash component (or list/str) into an HTML string."""
+ if comp is None:
+ return ""
+ if isinstance(comp, (str, int, float)):
+ return esc(comp)
+ if isinstance(comp, (list, tuple)):
+ return "".join(render(c) for c in comp)
+
+ cls = type(comp).__name__
+ module = type(comp).__module__
+
+ # --- Plotly graphs ---
+ if isinstance(comp, dcc.Graph):
+ return register_chart(comp.figure, getattr(comp, "style", None), getattr(comp, "id", None))
+
+ # --- Dropdowns: render as