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 {opts}') + return f'' + + # --- Stores/Download: no static equivalent needed (Download requires a live backend) --- + if isinstance(comp, (dcc.Store, dcc.Download)): + return "" + + # --- DataTable: render a plain HTML table from data/columns --- + if isinstance(comp, dash_table.DataTable): + cols = comp.columns + rows = comp.data + conditional = getattr(comp, "style_data_conditional", None) or [] + + thead = "".join(f"{esc(c['name'])}" for c in cols) + trs = [] + for i, r in enumerate(rows): + bg = conditional_row_bg(conditional, r, i) + td_style = f' style="background-color:{bg}"' if bg else "" + tds = "".join(f"{esc(r.get(c['id'], ''))}" for c in cols) + trs.append(f"{tds}") + cid = getattr(comp, "id", "") or next_chart_id() + return (f'
' + f"{thead}{''.join(trs)}
") + + # --- dbc components: map to their underlying Bootstrap markup --- + if module.startswith("dash_bootstrap_components"): + children_html = render(getattr(comp, "children", None)) + extra_class = getattr(comp, "className", "") or "" + style = style_dict_to_css(getattr(comp, "style", None)) + cid = getattr(comp, "id", "") + id_attr = f' id="{esc(cid)}"' if cid else "" + + if cls == "Container": + base = "container-fluid" if getattr(comp, "fluid", False) else "container" + return f'
{children_html}
' + if cls == "Row": + return f'
{children_html}
' + if cls == "Col": + md = getattr(comp, "md", None) + colcls = f"col-md-{md}" if md else "col" + return f'
{children_html}
' + if cls == "Card": + return f'
{children_html}
' + if cls == "CardHeader": + return f'
{children_html}
' + if cls == "CardBody": + return f'
{children_html}
' + if cls == "Alert": + color = getattr(comp, "color", "info") + return f'
{children_html}
' + if cls == "Popover": + target = getattr(comp, "target", "") + body_html = render(getattr(comp, "children", None)) + return f'' + if cls == "PopoverBody": + style = style_dict_to_css(getattr(comp, "style", None)) + return f'
{children_html}
' + if cls == "Accordion": + return f'
{children_html}
' + if cls == "AccordionItem": + title = getattr(comp, "title", "") or "" + return (f'
' + f'{esc(title)}' + f'
{children_html}
' + f'
') + # Fallback: just render children + return children_html + + # --- Plain html.* components --- + if module.startswith("dash.html") or module.startswith("dash_html_components"): + children_html = render(getattr(comp, "children", None)) + extra_class = getattr(comp, "className", "") or "" + style = style_dict_to_css(getattr(comp, "style", None)) + cid = getattr(comp, "id", "") + id_attr = f' id="{esc(cid)}"' if cid else "" + title = getattr(comp, "title", None) + title_attr = f' title="{esc(title)}"' if title else "" + class_attr = f' class="{esc(extra_class)}"' if extra_class else "" + + tag_map = { + "Div": "div", "Span": "span", "Strong": "strong", "B": "b", "P": "p", + "H3": "h3", "Hr": "hr", "Button": "button", + "Table": "table", "Thead": "thead", "Tbody": "tbody", "Tr": "tr", + "Th": "th", "Td": "td", + } + tag = tag_map.get(cls, "div") + + if cls == "Img": + src = inline_asset(getattr(comp, "src", "")) + return f'' + if cls == "Hr": + return f'
' + if cls == "Button": + if cid == "generate-report-btn": + disabled_style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()}, + "opacity": "0.5", "cursor": "not-allowed"}) + return (f'') + onclick = f' onclick="window.__onClick && window.__onClick(this)"' + return f'' + if cid.startswith("info-"): + onclick = f' onclick="window.__onClick && window.__onClick(this)"' + return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}{onclick}>{children_html}' + return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}>{children_html}' + + # Unknown component type — best-effort render children + return render(getattr(comp, "children", None)) + + +def fig_json(fig): + return json.loads(fig.to_json()) + + +def precompute_calendar_states(): + """Every month's tile-row + daily graph (+ Overview's 4 metric charts), for both + calendar instances — this is what the month-box click swaps between.""" + states = {} + year = int(A.D["fts"]["Date"].dt.year.max()) + for prefix in ("overview", "followers"): + states[prefix] = {} + for m in range(1, 13): + entry = { + "postsRow": render(A._month_day_tiles(A.D["fts"], A.D["posts"], year, m)), + "dailyFig": fig_json(A.daily_followers_figure(A.D["fts"], A.D["posts"], year, m)), + } + if prefix == "overview": + cts_f = A._filtered_by_month(A.D["cts"], year, m) + vts_f = A._filtered_by_month(A.D["vts"], year, m) + entry["metrics"] = { + "overview-impressions-graph": fig_json(A.impressions_figure(cts_f)), + "overview-engagement-graph": fig_json(A.engagement_figure(cts_f)), + "overview-ctr-graph": fig_json(A.ctr_figure(cts_f)), + "overview-pageviews-graph": fig_json(A.page_views_figure(vts_f)), + } + states[prefix][str(m)] = entry + default_month = A._default_active_month(A.D["fts"], A.D["posts"], year) + return states, year, default_month + + +def precompute_fb_states(): + """Followers-By dropdown x Show-all-toggle states.""" + states = {} + for key, _label in A.FOLLOWER_BREAKDOWNS: + for expanded in (False, True): + _lbl, fig, total_n = A._breakdown_figure(key, expanded) + fig = A._std(fig) + needs_toggle = total_n > 10 + btn_label = "Show top 10" if expanded else f"Show all {total_n}" + states[f"{key}|{str(expanded).lower()}"] = { + "fig": fig_json(fig), + "btnLabel": btn_label, + "btnDisplay": "inline-block" if needs_toggle else "none", + } + return states + + +def precompute_vb_states(): + """Visitors-By dropdown states.""" + states = {} + for key, _label in A.VISITOR_BREAKDOWNS: + _lbl, fig = A._visitor_breakdown_figure(key) + states[key] = {"fig": fig_json(A._std(fig))} + return states + + +def precompute_content_states(): + """Content tab's month-filter dropdown states — 6 charts + the posts table.""" + states = {} + for opt in A._content_month_options(): + value = opt["value"] + posts_f = A._content_posts_filtered(value) + cts_f = A._content_cts_filtered(value) + records = A._posts_table_records(value) + # Same fixed rule set for every month, matching the live app: its month-filter + # callback only swaps `data`, never `style_data_conditional`, so the highlight + # always marks the all-time best post/CTR, not a per-month recompute. + row_htmls = [] + for i, r in enumerate(records): + bg = conditional_row_bg(A.post_highlight_styles, r, i) + td_style = f' style="background-color:{bg}"' if bg else "" + tds = "".join(f"{esc(r.get(c, ''))}" for c in A.post_table_cols) + row_htmls.append(f"{tds}") + rows_html = "".join(row_htmls) + states[value] = { + "content-impressions-fig": fig_json(A._finalize_fig( + A.impressions_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)), + "content-engagement-fig": fig_json(A._finalize_fig( + A.engagement_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)), + "content-ctr-fig": fig_json(A._finalize_fig( + A.ctr_by_format_figure(posts_f), margin=A.CONTENT_BAR_MARGIN)), + "content-scatter-fig": fig_json(A._finalize_fig( + A.impressions_vs_engagement_figure(posts_f), legend=A.CONTENT_SCATTER_LEGEND)), + "content-reactions-fig": fig_json(A._finalize_fig(A.reactions_figure(cts_f))), + "content-clicks-fig": fig_json(A._finalize_fig(A.clicks_figure(cts_f))), + "tableRows": rows_html, + } + return states + + +JS_TEMPLATE = r""" +const CHARTS = %(charts)s; +const TAB_HERO = %(tab_hero)s; +const CAL_STATES = %(cal_states)s; +const CAL_YEAR = %(cal_year)s; +const CAL_DEFAULT_MONTH = %(cal_default_month)s; +const FB_STATES = %(fb_states)s; +const VB_STATES = %(vb_states)s; +const CONTENT_STATES = %(content_states)s; + +const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH}; +let fbActive = 'company_size', fbExpanded = false; +let vbActive = 'seniority'; + +function renderChart(divId, figSpec) { + if (!figSpec || !document.getElementById(divId)) return; + Plotly.react(divId, figSpec.data, figSpec.layout, {displayModeBar: false, responsive: true}); +} + +function renderChartWithin(containerId, figSpec) { + var container = document.getElementById(containerId); + if (!container || !figSpec) return; + var chartDiv = container.querySelector('.js-plotly-chart'); + if (!chartDiv) return; + Plotly.react(chartDiv.id, figSpec.data, figSpec.layout, {displayModeBar: false, responsive: true}); +} + +document.addEventListener('DOMContentLoaded', function () { + Object.keys(CHARTS).forEach(function (id) { + var spec = CHARTS[id]; + Plotly.newPlot(id, spec.data, spec.layout, {displayModeBar: false, responsive: true}); + }); +}); + +function switchTab(key) { + document.querySelectorAll('.app-tab').forEach(function (el) { el.style.display = 'none'; }); + var el = document.getElementById('tab-' + key); + if (el) el.style.display = 'block'; + document.querySelectorAll('[id^="nav-"]').forEach(function (btn) { + btn.classList.toggle('active', btn.id === 'nav-' + key); + }); + var hero = TAB_HERO[key]; + if (hero) { + document.getElementById('hero-title').textContent = hero[0]; + document.getElementById('hero-subtitle').textContent = hero[1]; + } + window.dispatchEvent(new Event('resize')); +} + +function selectCalendarMonth(prefix, month) { + calMonth[prefix] = month; + var state = CAL_STATES[prefix][String(month)]; + if (!state) return; + for (var m = 1; m <= 12; m++) { + var btn = document.getElementById(prefix + '-month-btn-' + m); + if (btn) btn.style.border = (m === month) ? '2px solid #1b3a5c' : '1px solid #e8e8e8'; + } + var row = document.getElementById(prefix + '-posts-row'); + if (row) row.innerHTML = state.postsRow; + renderChart(prefix + '-daily-followers-graph', state.dailyFig); + if (state.metrics) { + Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); }); + } +} + +function renderFollowersBreakdown() { + var s = FB_STATES[fbActive + '|' + fbExpanded]; + if (!s) return; + renderChartWithin('followers-breakdown-area', s.fig); + var btn = document.getElementById('fb-expand-btn'); + if (btn) { + btn.textContent = s.btnLabel; + btn.style.display = s.btnDisplay; + } +} + +function renderVisitorBreakdown() { + var s = VB_STATES[vbActive]; + if (!s) return; + renderChartWithin('visitor-breakdown-area', s.fig); +} + +function renderContentState(value) { + var s = CONTENT_STATES[value]; + if (!s) return; + ['content-impressions-fig', 'content-engagement-fig', 'content-ctr-fig', + 'content-scatter-fig', 'content-reactions-fig', 'content-clicks-fig'].forEach(function (id) { + renderChart(id, s[id]); + }); + var tbody = document.querySelector('#posts-table tbody'); + if (tbody) tbody.innerHTML = s.tableRows; +} + +function closeAllPopovers(except) { + document.querySelectorAll('.static-popover').forEach(function (p) { + if (p !== except) p.style.display = 'none'; + }); +} + +window.__onClick = function (el) { + var id = el.id || ''; + var m; + if (id.indexOf('info-') === 0) { + var pop = document.querySelector('.static-popover[data-target="' + id + '"]'); + if (!pop) return; + var willOpen = pop.style.display === 'none' || !pop.style.display; + closeAllPopovers(); + if (willOpen) { + var rect = el.getBoundingClientRect(); + pop.style.position = 'fixed'; + pop.style.top = (rect.bottom + 6) + 'px'; + pop.style.left = Math.min(rect.left, window.innerWidth - 380) + 'px'; + pop.style.display = 'block'; + } + return; + } + if ((m = id.match(/^(overview|followers)-month-btn-(\d+)$/))) { + selectCalendarMonth(m[1], parseInt(m[2], 10)); + return; + } + if (id === 'fb-expand-btn') { + fbExpanded = !fbExpanded; + renderFollowersBreakdown(); + return; + } +}; + +window.__onSelect = function (el) { + if (el.id === 'fb-select') { fbActive = el.value; fbExpanded = false; renderFollowersBreakdown(); return; } + if (el.id === 'vb-select') { vbActive = el.value; renderVisitorBreakdown(); return; } + if (el.id === 'content-month-select') { renderContentState(el.value); return; } +}; + +document.addEventListener('click', function (ev) { + if (ev.target.id && ev.target.id.indexOf('info-') === 0) return; + if (ev.target.closest && ev.target.closest('.static-popover')) return; + closeAllPopovers(); +}); +""" + + +def build(): + tabs_html = {} + for key, _label, comp in A.NAV_ITEMS: + tabs_html[key] = render(comp) + + sidebar_html = render(A.sidebar) + hero_html = render(A.hero) + + cal_states, cal_year, cal_default_month = precompute_calendar_states() + fb_states = precompute_fb_states() + vb_states = precompute_vb_states() + content_states = precompute_content_states() + + with open(os.path.join(ASSETS_DIR, "custom.css")) as f: + custom_css = f.read() + + tab_divs = "\n".join( + f'
' + f'{tabs_html[key]}
' + for key, _label, _c in A.NAV_ITEMS + ) + + js = JS_TEMPLATE % { + "charts": json.dumps(CHARTS), + "tab_hero": json.dumps(A.TAB_HERO), + "cal_states": json.dumps(cal_states), + "cal_year": json.dumps(cal_year), + "cal_default_month": json.dumps(cal_default_month), + "fb_states": json.dumps(fb_states), + "vb_states": json.dumps(vb_states), + "content_states": json.dumps(content_states), + } + + html_doc = f""" + + + +TXproducts LinkedIn Analytics — Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')}) + + + + + + +
Static snapshot as of {A.D['last_updated'].strftime('%B %d, %Y')} — dropdowns, month clicks, + and breakdown selectors work, but this file does not update with new data. Open the live tool for current numbers.
+
+{sidebar_html} +
+{hero_html} +
+{tab_divs} +
+
+
+ + + +""" + out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "TXproducts_Dashboard_Snapshot.html") + with open(out_path, "w") as f: + f.write(html_doc) + print(f"Wrote {out_path} ({len(html_doc):,} bytes, {len(CHARTS)} charts)") + return out_path + + +if __name__ == "__main__": + build() diff --git a/growth.py b/growth.py new file mode 100644 index 0000000..c48ef70 --- /dev/null +++ b/growth.py @@ -0,0 +1,193 @@ +"""Tracks follower-growth history over time: an append-only-per-day log that powers the +sidebar Growth Statistics indicator, the Growth tab's historical table/chart, and the PDF +report's weekly-pace section. Persisted as JSON in UPLOAD_DIR so it survives restarts and +uploads on any team member's machine, not just the original author's. + +Pace is judged against a rolling personal best rather than a fixed percentage: the current +week's growth-to-date is compared against the best of the last ROLLING_BEST_WEEKS COMPLETED +weeks (never against earlier days within the current week itself). Weeks are real Monday- +Sunday calendar weeks, not a daily trailing window and not a 7-day block anchored to +whichever day-of-week the data happens to start on — an earlier version compared each day's +trailing-7-day rate against the best trailing rate from the past N weeks, which could +include days from later in the SAME displayed week, so a week could get flagged "Behind" due +to its own mid-week peak rather than genuinely falling short of prior weeks. This rises +automatically as performance improves, but a one-off spike ages out of the window after +ROLLING_BEST_WEEKS instead of setting an unreachable target forever. + +Zero import-time dependency on app.py, for the same reason report.py has none: app.py runs +as "__main__" (`python3 app.py`), so `import app` here would re-execute it as a second +module copy that's still mid-import when these functions get called. +""" +import datetime +import json +import os + +import pandas as pd + +ROLLING_BEST_WEEKS = 4 + + +def _history_path(upload_dir): + return os.path.join(upload_dir, "growth_history.json") + + +def load_history(upload_dir): + path = _history_path(upload_dir) + if not os.path.exists(path): + return [] + with open(path) as f: + return json.load(f) + + +def _save_history(upload_dir, history): + with open(_history_path(upload_dir), "w") as f: + json.dump(history, f, indent=2) + + +def _followers_as_of(fts_df, on_or_before): + """Cumulative Followers value from the most recent row on or before `on_or_before`; + falls back to the earliest available row if the whole series starts later.""" + prior = fts_df[fts_df["Date"] <= on_or_before] + if prior.empty: + return float(fts_df["Cumulative Followers"].iloc[0]) + return float(prior["Cumulative Followers"].iloc[-1]) + + +def _week_start_for(date): + """Monday of the standard calendar week `date` falls into — real Monday-Sunday weeks, + 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).""" + return date - pd.Timedelta(days=date.weekday()) + + +def compute_snapshot(fts_df, prior_history, week_boundary_date=None): + """Computes (but does not persist) a growth snapshot as of the last date in fts_df, + given the history entries recorded so far (used for since_last_pct and the rolling-best + pace target). Taking the prior history as a plain list — rather than reading it from + disk — lets backfill_history() replay this against progressively larger slices of + fts_df without hitting disk each time. + + `week_boundary_date` only affects which past weeks are eligible for the rolling-best + lookback below — it does NOT move week-to-date's own week, which always stays the + actual last date in fts_df (so a week with zero new data yet never gets falsely reset + to a hypothetical 0%). Defaults to this entry's own week, which reproduces the original + behavior of excluding the current week from its own target pool. backfill_history() + passes today's real date for the latest entry only, so a week that's actually finished + in real life (e.g. LinkedIn's export lagging a day behind on Mondays) immediately + becomes eligible as a rolling-best candidate, instead of waiting for next week's data + to start arriving before it's ever counted.""" + fts_df = fts_df.sort_values("Date") + today = fts_df["Date"].iloc[-1] + total_followers = float(fts_df["Cumulative Followers"].iloc[-1]) + + # Trailing 7-day rate: a smoother, purely informational "how's it going right now" + # figure. Not used for the pace judgment (see week_to_date_pct below) since a trailing + # window updates every day and doesn't respect week boundaries. + week_ago = today - pd.Timedelta(days=7) + followers_week_ago = _followers_as_of(fts_df, week_ago) + weekly_pct = ((total_followers - followers_week_ago) / followers_week_ago * 100 + if followers_week_ago else None) + weekly_pct = round(weekly_pct, 2) if weekly_pct is not None else None + + if prior_history: + prev_total = prior_history[-1]["total_followers"] + since_last_pct = (total_followers - prev_total) / prev_total * 100 if prev_total else None + else: + since_last_pct = None + + # Discrete week-over-week pace: growth since the start of THIS week (always the real + # last-data week — never moved by week_boundary_date), compared against the best of the + # last ROLLING_BEST_WEEKS COMPLETED weeks. + current_week_start = _week_start_for(today) + week_start_baseline = _followers_as_of(fts_df, current_week_start - pd.Timedelta(days=1)) + week_to_date_pct = ((total_followers - week_start_baseline) / week_start_baseline * 100 + if week_start_baseline else 0.0) + week_to_date_pct = round(week_to_date_pct, 2) + + # Which past weeks are eligible for the rolling-best pool: strictly before the lookback + # boundary, which defaults to this entry's own week (excluding it from its own target, + # since an ONGOING week can't fairly be judged against its own still-accumulating + # total) but can be pushed forward to the real current date for the live/latest entry — + # at that point this week is no longer "ongoing" in real life even if fresh data for it + # hasn't arrived, so it's fair game as a candidate for whatever comes next. + lookback_boundary_week_start = (_week_start_for(week_boundary_date) + if week_boundary_date is not None else current_week_start) + lookback_start = lookback_boundary_week_start - pd.Timedelta(weeks=ROLLING_BEST_WEEKS) + last_entry_per_week = {} + for h in prior_history: + h_week_start = h.get("week_start") + if h_week_start is None or not (lookback_start <= pd.Timestamp(h_week_start) < lookback_boundary_week_start): + continue + existing = last_entry_per_week.get(h_week_start) + if existing is None or h["date"] > existing["date"]: + last_entry_per_week[h_week_start] = h + completed_week_rates = [h["week_to_date_pct"] for h in last_entry_per_week.values()] + rolling_best_weekly_pct = round(max(completed_week_rates), 2) if completed_week_rates else None + on_track = (None if rolling_best_weekly_pct is None + else week_to_date_pct >= rolling_best_weekly_pct) + + return { + "date": today.strftime("%Y-%m-%d"), + "total_followers": int(total_followers), + "weekly_pct": weekly_pct, + "since_last_pct": round(since_last_pct, 2) if since_last_pct is not None else None, + "week_start": current_week_start.strftime("%Y-%m-%d"), + "week_to_date_pct": week_to_date_pct, + "rolling_best_weekly_pct": rolling_best_weekly_pct, + "rolling_best_weeks": ROLLING_BEST_WEEKS, + "on_track": on_track, + } + + +def last_two_completed_weeks(history): + """The most recent COMPLETED week's final entry, and the one before it — both full + Monday-Sunday weeks. Used for an apples-to-apples week-over-week comparison, as opposed + to comparing a full week against the current week, which is always partial until the + next Monday's upload. + + "Completed" is judged against the real calendar (today's actual date), not the last date + present in the export — LinkedIn's export routinely lags a day or two behind (e.g. it may + stop at Saturday), which previously made an already-finished week look "still in + progress" and made the sidebar fall back to showing the week before it.""" + if not history: + return None, None + real_today = pd.Timestamp(datetime.date.today()) + current_week_start = (real_today - pd.Timedelta(days=real_today.weekday())).strftime("%Y-%m-%d") + last_entry_per_week = {} + for h in history: + if h["week_start"] >= current_week_start: + continue + existing = last_entry_per_week.get(h["week_start"]) + if existing is None or h["date"] > existing["date"]: + last_entry_per_week[h["week_start"]] = h + completed_weeks = sorted(last_entry_per_week.values(), key=lambda h: h["week_start"], reverse=True) + last_week = completed_weeks[0] if len(completed_weeks) >= 1 else None + prior_week = completed_weeks[1] if len(completed_weeks) >= 2 else None + return last_week, prior_week + + +def backfill_history(fts_df, upload_dir): + """Ensures the growth-history log has one entry per day already present in fts_df — + backfills any days from before this feature existed (each computed using only the data + that would have been available up to that day, so backfilled entries are historically + accurate, not hindsight-adjusted), while always recomputing the MOST RECENT day fresh so + a same-day re-upload/correction updates that day's entry instead of reusing a stale one. + Returns the full merged history list, sorted chronologically.""" + existing = {e["date"]: e for e in load_history(upload_dir)} + fts_sorted = fts_df.sort_values("Date") + dates = list(fts_sorted["Date"]) + real_today = pd.Timestamp(datetime.date.today()) + history = [] + for i, d in enumerate(dates): + date_str = d.strftime("%Y-%m-%d") + is_latest = (i == len(dates) - 1) + if date_str in existing and not is_latest: + history.append(existing[date_str]) + continue + subset = fts_sorted[fts_sorted["Date"] <= d] + # Only the latest entry gets the real-calendar boundary — backfilled days stay + # reproducible, using just their own date as before. + boundary = max(d, real_today) if is_latest else None + history.append(compute_snapshot(subset, history, week_boundary_date=boundary)) + _save_history(upload_dir, history) + return history diff --git a/report.py b/report.py new file mode 100644 index 0000000..c0ba968 --- /dev/null +++ b/report.py @@ -0,0 +1,251 @@ +"""Builds a leadership-facing PDF summary of LinkedIn performance for a chosen +timeframe. Charts are rendered to PNG via kaleido and embedded as images (reportlab +has no native Plotly support). + +Deliberately has zero import-time dependency on app.py — app.py is run directly +(`python3 app.py`), so it's registered in sys.modules as "__main__", not "app". If this +module did `import app`, Python wouldn't recognize that as the same module and would +re-execute app.py from scratch as a second copy, which is still mid-import when this +module's functions get called — a circular-import AttributeError. Passing everything +in as arguments avoids that entirely. +""" +import io + +import pandas as pd +import plotly.express as px +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle, Paragraph, + Spacer, Image, HRFlowable) + +NAVY = colors.HexColor("#1b3a5c") +LIGHT_GRAY = colors.HexColor("#f0f4f8") +TEXT_GRAY = colors.HexColor("#6b7a8d") + + +def report_month_options(fts_df): + """'All Time', then one entry per year, then one entry per (year, month) — lets the + report be scoped to as broad or as narrow a window as the export data supports.""" + import calendar as calmod + dates = fts_df["Date"] + years = sorted({d.year for d in dates}) + months = sorted({(d.year, d.month) for d in dates}) + options = [{"label": "All Time", "value": "all"}] + options += [{"label": str(y), "value": f"year-{y}"} for y in years] + options += [{"label": f"{calmod.month_name[m]} {y}", "value": f"{y}-{m:02d}"} for y, m in months] + return options + + +def _filtered(df, date_col, value): + if not value or value == "all": + return df + if value.startswith("year-"): + year = int(value.split("-", 1)[1]) + return df[df[date_col].dt.year == year] + year, month = (int(p) for p in value.split("-")) + return df[(df[date_col].dt.year == year) & (df[date_col].dt.month == month)] + + +def _period_label(value, fts_df): + if not value or value == "all": + return "All Time" + return next((o["label"] for o in report_month_options(fts_df) if o["value"] == value), value) + + +def _fig_to_image(fig, width_cm=17, height_px=300, width_px=900): + buf = io.BytesIO() + fig.write_image(buf, format="png", width=width_px, height=height_px, scale=2) + buf.seek(0) + aspect = height_px / width_px + return Image(buf, width=width_cm * cm, height=width_cm * cm * aspect) + + +def build_report_pdf(ctx, timeframe_value="all"): + """ctx is a dict bundling everything the report needs from the live app: + D, posts, comp_ordered, current_total_followers, daily_followers_figure (the + figure-builder function), std_fn (the _std styling helper), TMPL, FORMAT_COLORS.""" + D = ctx["D"] + posts = ctx["posts"] + comp_ordered = ctx["comp_ordered"] + current_total_followers = ctx["current_total_followers"] + daily_followers_figure = ctx["daily_followers_figure"] + std_fn = ctx["std_fn"] + tmpl = ctx["TMPL"] + format_colors = ctx["FORMAT_COLORS"] + growth_snapshot = ctx["growth_snapshot"] + + fts_f = _filtered(D["fts"], "Date", timeframe_value) + posts_f = _filtered(posts, "Created date", timeframe_value) + cts_f = _filtered(D["cts"], "Date", timeframe_value) + vts_f = _filtered(D["vts"], "Date", timeframe_value) + + new_followers = int(fts_f["Total followers"].sum()) if not fts_f.empty else 0 + total_impressions = int(cts_f["Impressions (total)"].sum()) if not cts_f.empty else 0 + total_page_views = int(vts_f["Total page views (total)"].sum()) if not vts_f.empty else 0 + posts_published = len(posts_f) + best_eng = float(posts_f["Eng %"].max()) if len(posts_f) else 0.0 + avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float) + avg_eng = float(avg_eng_series.mean()) if not avg_eng_series.dropna().empty else 0.0 + + styles = getSampleStyleSheet() + title_style = ParagraphStyle("TXTitle", parent=styles["Title"], textColor=NAVY, fontSize=20, spaceAfter=2) + subtitle_style = ParagraphStyle("TXSubtitle", parent=styles["Normal"], textColor=TEXT_GRAY, fontSize=10, spaceAfter=16) + h2_style = ParagraphStyle("TXH2", parent=styles["Heading2"], textColor=NAVY, fontSize=13, spaceBefore=16, spaceAfter=8) + body_style = ParagraphStyle("TXBody", parent=styles["Normal"], fontSize=9, textColor=colors.HexColor("#333333")) + note_style = ParagraphStyle("TXNote", parent=styles["Normal"], fontSize=8, textColor=TEXT_GRAY, spaceBefore=4) + + period_label = _period_label(timeframe_value, D["fts"]) + + story = [] + story.append(Paragraph("TXproducts, LinkedIn Performance Report", title_style)) + story.append(Paragraph( + f"Period: {period_label}  |  Generated {pd.Timestamp.now().strftime('%B %d, %Y')} " + f" |  Data as of {D['last_updated'].strftime('%B %d, %Y')}", + subtitle_style)) + story.append(HRFlowable(width="100%", color=NAVY, thickness=1.2)) + + # --- KPI summary table --- + story.append(Paragraph("Key Metrics", h2_style)) + kpi_data = [ + ["Total Followers\n(as of today)", "New Followers", "Posts Published", "Total Impressions", + "Total Page Views", "Best Post Eng.", "Avg. Eng. Rate"], + [f"{current_total_followers:,}", f"{new_followers:,}", f"{posts_published:,}", + f"{total_impressions:,}", f"{total_page_views:,}", f"{best_eng:.1f}%", + f"{avg_eng:.2f}%" if avg_eng else "—"], + ] + kpi_table = Table(kpi_data, colWidths=[2.6 * cm] * 7) + kpi_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY), + ("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY), + ("FONTSIZE", (0, 0), (-1, 0), 7), + ("FONTSIZE", (0, 1), (-1, 1), 12), + ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"), + ("TEXTCOLOR", (0, 1), (-1, 1), NAVY), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ])) + story.append(kpi_table) + + # --- Weekly growth pace --- + story.append(Paragraph("Weekly Growth Pace", h2_style)) + on_track = growth_snapshot["on_track"] + if on_track is None: + status_color, status_label = TEXT_GRAY, "Building History" + elif on_track: + status_color, status_label = colors.HexColor("#52C41A"), "On Track" + else: + status_color, status_label = colors.HexColor("#d43f3a"), "Behind" + weekly_pct = growth_snapshot["week_to_date_pct"] + target_pct = growth_snapshot["rolling_best_weekly_pct"] + objective_data = [ + ["This Week", f"Target (best of {growth_snapshot['rolling_best_weeks']} wks)", "Pace Status"], + [f"{weekly_pct:+.2f}%" if weekly_pct is not None else "—", + f"{target_pct:+.2f}%" if target_pct is not None else "—", + status_label], + ] + objective_table = Table(objective_data, colWidths=[5 * cm, 6 * cm, 4 * cm]) + objective_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY), + ("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY), + ("FONTSIZE", (0, 0), (-1, 0), 8), + ("FONTSIZE", (0, 1), (-1, 1), 11), + ("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"), + ("TEXTCOLOR", (0, 1), (-1, 1), NAVY), + ("TEXTCOLOR", (2, 1), (2, 1), status_color), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ])) + story.append(objective_table) + story.append(Paragraph( + f"Target is a rolling personal best, the highest trailing-7-day growth rate seen in " + f"the last {growth_snapshot['rolling_best_weeks']} weeks, not a fixed percentage. It " + f"rises automatically as performance improves, but a one-off spike ages out of the " + f"window after {growth_snapshot['rolling_best_weeks']} weeks rather than setting an " + f"unreachable target permanently.", + note_style)) + + # --- Follower growth chart --- + if not fts_f.empty: + story.append(Paragraph("Follower Growth", h2_style)) + fig = daily_followers_figure(fts_f, posts_f) + fig.update_layout(showlegend=True, font=dict(size=14)) + story.append(_fig_to_image(fig)) + + # --- Top posts table --- + story.append(Paragraph("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), h2_style)) + if len(posts_f): + top_posts = posts_f.sort_values("Eng %", ascending=False).head(5) + rows = [["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]] + for _, r in top_posts.iterrows(): + rows.append([ + Paragraph(str(r["Short title"])[:60], body_style), + r["Created date"].strftime("%d/%m/%Y"), + r["Content Type"], + f"{int(r['Impressions']):,}", + f"{r['Eng %']:.1f}%", + f"{r['CTR %']:.1f}%", + ]) + posts_table = Table(rows, colWidths=[7 * cm, 2.2 * cm, 2.2 * cm, 2.4 * cm, 1.8 * cm, 1.8 * cm]) + posts_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), NAVY), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ])) + story.append(posts_table) + else: + story.append(Paragraph("No posts published in this period.", body_style)) + + # --- Content format performance --- + if len(posts_f): + story.append(Paragraph("Impressions by Content Format", h2_style)) + fmt_imp = (posts_f.groupby("Content Type", as_index=False)["Impressions"].sum().sort_values("Impressions")) + fig2 = std_fn(px.bar( + fmt_imp, x="Impressions", y="Content Type", orientation="h", + template=tmpl, color="Content Type", color_discrete_map=format_colors)) + fig2.update_layout(showlegend=False, font=dict(size=14)) + story.append(_fig_to_image(fig2, height_px=220)) + + # --- Competitor snapshot --- + story.append(Paragraph("Competitor Snapshot", h2_style)) + story.append(Paragraph( + "Cumulative export snapshot, not specific to the selected period.", note_style)) + comp_rows = [["Page", "New Followers", "Posts", "Comments", "Comments/day", "Reactions"]] + for _, r in comp_ordered.iterrows(): + label = r["Page"] + (" (You)" if r["Page"] == "TXproducts" else "") + comp_rows.append([label, r["New Followers"], r["Posts"], r["Comments"], r["Comments per day"], r["Reactions"]]) + comp_table = Table(comp_rows, colWidths=[5 * cm, 2.6 * cm, 2 * cm, 2.2 * cm, 2.6 * cm, 2.2 * cm]) + comp_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), NAVY), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ])) + story.append(comp_table) + + story.append(Spacer(1, 16)) + story.append(Paragraph("TXproducts · Internal Report · Generated automatically from LinkedIn Page Analytics exports.", note_style)) + + buf = io.BytesIO() + doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=1.5 * cm, bottomMargin=1.5 * cm, + leftMargin=1.5 * cm, rightMargin=1.5 * cm) + doc.build(story) + buf.seek(0) + return buf.getvalue() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b222c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +dash>=2.0 +dash-bootstrap-components>=1.0 +plotly>=5.0 +pandas>=1.3 +openpyxl>=3.0 +xlrd>=2.0