diff --git a/.gitignore b/.gitignore
index 589baa9..e37d0d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,9 +2,14 @@
*.xls
*.xlsx
growth_history.json
+visitor_category_history.json
# Generated output — regenerate locally with build_static_export.py, not shared code
TXproducts_Dashboard_Snapshot.html
+TXproducts_Follower_Study_*.html
+LinkedIn_Dashboard_Daily_Report_*.pdf
+assets/follower_directory.html
+assets/follower_study.html
# Python
__pycache__/
diff --git a/app.py b/app.py
index 5ebdd83..71756bd 100644
--- a/app.py
+++ b/app.py
@@ -11,6 +11,7 @@ import plotly.graph_objects as go
import pandas as pd
import report
import growth
+import visitor_history
# Overridable via env var so the same code runs unmodified in Docker/internal deployments —
# defaults to this machine's path for local dev.
@@ -34,6 +35,7 @@ MANUAL_POST_TITLES = {
MANUAL_CONTENT_TYPES = {
"2026-07-09": "Carousel",
"2026-07-16": "Carousel",
+ "2026-07-21": "Poll",
}
# ── Palette — matched to txdata_project/portal.py's navy/blue design language ──
@@ -42,6 +44,7 @@ NAVY2 = "#2d6a9f" # secondary blue, used in gradients alongside BLUE
ORANGE = "#E8825A"
GREEN = "#52C41A"
AMBER = "#FFA726"
+LIGHT_BLUE = "#4a90d9"
TEAL = "#2d6a9f" # aligned to the secondary blue instead of a separate teal
TMPL = "plotly_white"
BG = "rgba(0,0,0,0)"
@@ -52,6 +55,7 @@ FORMAT_COLORS = {
"Article": "#52C41A",
"Text": "#FFA726",
"Carousel": "#9C27B0",
+ "Poll": "#00838F",
"Unknown": "#888888",
}
@@ -152,32 +156,83 @@ 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)
+ # Followers — "New followers" is a rolling ~30-day window in each export, so merge every
+ # historical file by date (newest file wins on an overlapping date) the same way Content
+ # already does. Otherwise a day that ages out of the newest file's window silently
+ # disappears from the cumulative total and any month it belonged to.
+ follower_files = _find_all_files("followers")
+ file_mtimes.extend(os.path.getmtime(fp) for fp in follower_files)
+ fts_frames = []
+ for fp in follower_files:
+ f = pd.read_excel(fp, sheet_name="New followers")
+ f["Date"] = pd.to_datetime(f["Date"])
+ f["_src"] = os.path.getmtime(fp)
+ fts_frames.append(f)
+ fts = (pd.concat(fts_frames)
+ .sort_values("_src")
+ .drop_duplicates(subset=["Date"], keep="last")
+ .drop(columns=["_src"])
+ .sort_values("Date")
+ .reset_index(drop=True))
+ # Cumulative math is anchored at BASELINE_DATE — drop any merged rows before it so the
+ # baseline stays the true day-zero rather than double-counting pre-baseline history.
+ fts = fts[fts["Date"] >= pd.Timestamp(BASELINE_DATE)].reset_index(drop=True)
# Exact follower count on each day = the one manual baseline anchor + every gain since.
fts["Cumulative Followers"] = BASELINE_FOLLOWERS + fts["Total followers"].cumsum()
d["fts"] = fts
+ # Breakdown sheets are point-in-time snapshots, not a time series — always the newest file
+ # for the "current" values, but also keep one snapshot per (year, month) below so the
+ # "Followers by" panel can show an accurate historical split instead of always today's.
+ fp = follower_files[-1]
d["floc"] = pd.read_excel(fp, sheet_name="Location").sort_values("Total followers", ascending=False)
d["fjob"] = pd.read_excel(fp, sheet_name="Job function").sort_values("Total followers")
d["fsen"] = pd.read_excel(fp, sheet_name="Seniority").sort_values("Total followers")
d["find"] = pd.read_excel(fp, sheet_name="Industry").sort_values("Total followers", ascending=False)
d["fcs"] = pd.read_excel(fp, sheet_name="Company size").sort_values("Total followers")
- # 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"])
+ # One breakdown snapshot per (year, month) — the latest export within that month wins,
+ # so e.g. selecting June shows June's actual demographic split, not today's.
+ breakdown_history = {}
+ for hfp in follower_files:
+ mtime = pd.Timestamp(os.path.getmtime(hfp), unit="s")
+ breakdown_history[(mtime.year, mtime.month)] = {
+ "floc": pd.read_excel(hfp, sheet_name="Location").sort_values("Total followers", ascending=False),
+ "fjob": pd.read_excel(hfp, sheet_name="Job function").sort_values("Total followers"),
+ "fsen": pd.read_excel(hfp, sheet_name="Seniority").sort_values("Total followers"),
+ "find": pd.read_excel(hfp, sheet_name="Industry").sort_values("Total followers", ascending=False),
+ "fcs": pd.read_excel(hfp, sheet_name="Company size").sort_values("Total followers"),
+ }
+ d["breakdown_history"] = breakdown_history
+
+ # Visitors — same rolling-window issue as Followers, same fix.
+ visitor_files = _find_all_files("visitors")
+ file_mtimes.extend(os.path.getmtime(vp) for vp in visitor_files)
+ vts_frames = []
+ for vp in visitor_files:
+ v = pd.read_excel(vp, sheet_name="Visitor metrics")
+ v["Date"] = pd.to_datetime(v["Date"])
+ v["_src"] = os.path.getmtime(vp)
+ vts_frames.append(v)
+ vts = (pd.concat(vts_frames)
+ .sort_values("_src")
+ .drop_duplicates(subset=["Date"], keep="last")
+ .drop(columns=["_src"])
+ .sort_values("Date")
+ .reset_index(drop=True))
d["vts"] = vts
+ vp = visitor_files[-1]
d["vloc"] = pd.read_excel(vp, sheet_name="Location").sort_values("Total views", ascending=False)
d["vsen"] = pd.read_excel(vp, sheet_name="Seniority").sort_values("Total views")
d["vind"] = pd.read_excel(vp, sheet_name="Industry")
d["vcs"] = pd.read_excel(vp, sheet_name="Company size").sort_values("Total views")
+ # Persistent running total per category (Location/Seniority/Company size), accumulated
+ # across every weekly export ever uploaded — see visitor_history.py for why this exists:
+ # LinkedIn only shows a category if it clears a minimum-visits threshold that week, so a
+ # single-snapshot approach (like Followers' breakdown_history) would silently lose a
+ # location the moment one week's export didn't happen to include it.
+ d["visitor_category_history"] = visitor_history.backfill_history(UPLOAD_DIR, visitor_files)
+
# Visitor -> Follower conversion: correlates daily unique visitors against daily new followers.
# Not a true funnel — LinkedIn doesn't expose which specific visitor became a follower, only
# daily totals for each, so this is a same-day correlation, not individual-level attribution.
@@ -218,6 +273,23 @@ def load():
cts["Impressions (sponsored)"] > 0, 0)
d["cts"] = cts
+ # Follow Rate Relative to Reach: New Followers / Impressions * 100, per day. Replaces
+ # Conversion Rate (New Followers / Unique Visitors) for the cases where that metric silently
+ # drops a day — LinkedIn lets people follow a Page straight from a post in their feed, with
+ # zero page visit, so a real follower gain can land on a day with 0 tracked visitors. Impressions
+ # are almost never zero on a day with any reach at all, so those days no longer disappear.
+ frr = pd.merge(
+ conv[["Date", "New Followers", "Unique Visitors"]],
+ cts[["Date", "Impressions (organic)", "Impressions (sponsored)"]],
+ on="Date", how="inner",
+ )
+ frr["Impressions"] = frr["Impressions (organic)"] + frr["Impressions (sponsored)"]
+ frr["Follow Rate"] = frr.apply(
+ lambda r: (r["New Followers"] / r["Impressions"] * 100) if r["Impressions"] > 0 else None, axis=1
+ )
+ frr["No Visitor Gain"] = (frr["Unique Visitors"] == 0) & (frr["New Followers"] > 0)
+ d["frr"] = frr
+
# Merge posts: deduplicate by Post link, keep newest file's version
posts = (pd.concat(posts_frames)
.sort_values("_src")
@@ -259,6 +331,11 @@ def date_range_label(df, col="Date"):
return f"{lo.strftime('%b %d')} – {hi.strftime('%b %d')}"
+_MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"]
+_DELTA_MONTH_CAP = 20
+
+
def refresh_data():
global D, GROWTH_SNAPSHOT, current_total_followers, current_total_followers_as_of
global new_followers_30d, total_impressions, total_page_views, best_eng, num_posts
@@ -266,6 +343,9 @@ def refresh_data():
global overall_conversion_rate, overview, posts, post_table_cols, content_tab
global followers_tab, visitors_tab, comp, comp_ordered, competitors_tab, growth_tab
global TAB_CONTENT_MAP, _breakdown_figure, _visitor_breakdown_figure
+ global _breakdown_delta_figure, _followers_delta_months
+ global _followers_count_only_months, _monthly_count_summary
+ global _visitor_category_months
global _posts_table_records, _content_posts_filtered, _content_cts_filtered
global impressions_by_format_figure, engagement_by_format_figure, ctr_by_format_figure
global impressions_vs_engagement_figure, reactions_figure, clicks_figure
@@ -587,8 +667,16 @@ def refresh_data():
}
FOLLOWER_BREAKDOWNS = [(key, label) for key, (label, *_rest) in FOLLOWER_BREAKDOWN_MAP.items()]
- def _breakdown_figure(key, expanded):
+ _BREAKDOWN_SHEET_KEY = {"company_size": "fcs", "job_function": "fjob",
+ "industry": "find", "location": "floc", "seniority": "fsen"}
+
+ def _breakdown_figure(key, expanded, year=None, month=None):
label, df, col, color = FOLLOWER_BREAKDOWN_MAP[key]
+ if year is not None and month is not None:
+ hist = D["breakdown_history"]
+ available = sorted(k for k in hist if k <= (year, month))
+ if available:
+ df = hist[available[-1]][_BREAKDOWN_SHEET_KEY[key]]
total_n = len(df)
show_df = df if expanded else df.head(10)
fig = px.bar(show_df.sort_values("Total followers"), x="Total followers", y=col,
@@ -596,33 +684,196 @@ def refresh_data():
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.
+ # Months where a prior snapshot exists to diff against — the earliest snapshot in
+ # breakdown_history has nothing before it, so it can never be a delta option.
+ def _followers_delta_months():
+ return sorted(D["breakdown_history"].keys())[1:]
+
+ def _breakdown_delta_figure(key, year, month):
+ label, _df, col, _color = FOLLOWER_BREAKDOWN_MAP[key]
+ sheet_key = _BREAKDOWN_SHEET_KEY[key]
+ hist = D["breakdown_history"]
+ available = sorted(hist.keys())
+ idx = available.index((year, month))
+ prev_year, prev_month = available[idx - 1]
+ cur = hist[(year, month)][sheet_key][[col, "Total followers"]]
+ prev = hist[(prev_year, prev_month)][sheet_key][[col, "Total followers"]]
+ merged = cur.merge(prev, on=col, how="outer", suffixes=("_cur", "_prev")).fillna(0)
+ merged["Delta"] = merged["Total followers_cur"] - merged["Total followers_prev"]
+ merged = merged[merged["Delta"] != 0].copy()
+ merged["AbsDelta"] = merged["Delta"].abs()
+ changed_n = len(merged)
+ show_df = merged.sort_values("AbsDelta", ascending=False).head(_DELTA_MONTH_CAP)
+ overflow = changed_n - len(show_df)
+ show_df = show_df.sort_values("Delta")
+ fig = px.bar(show_df, x="Delta", y=col, orientation="h", template=TMPL,
+ labels={"Delta": "Change in Followers"})
+ fig.update_traces(marker_color=show_df["Delta"].apply(
+ lambda d: "#52C41A" if d > 0 else "#d43f3a"))
+ fig.update_layout(showlegend=False)
+ return label, fig, changed_n, overflow
+
+ # Months with real follower-count activity (from the daily fts time series) but no
+ # category breakdown to attribute it to — i.e. months before the first-ever demographic
+ # export existed to serve as a "prior" snapshot. Currently only June 2026: the campaign's
+ # baseline count starts Jun 16, but the first Followers export with a category breakdown
+ # wasn't taken until Jun 24, so the loss/gains between Jun 16-24 have no category history.
+ def _followers_count_only_months():
+ delta_months = set(_followers_delta_months())
+ fts_months = sorted(set(zip(D["fts"]["Date"].dt.year, D["fts"]["Date"].dt.month)))
+ earliest_breakdown = min(D["breakdown_history"].keys()) if D["breakdown_history"] else None
+ return [ym for ym in fts_months
+ if ym not in delta_months and (earliest_breakdown is None or ym <= earliest_breakdown)]
+
+ def _monthly_count_summary(year, month):
+ fts = D["fts"]
+ sub = fts[(fts["Date"].dt.year == year) & (fts["Date"].dt.month == month)]
+ net = int(sub["Total followers"].sum())
+ events = sub[sub["Total followers"] != 0]
+ gains = events[events["Total followers"] > 0]
+ losses = events[events["Total followers"] < 0]
+
+ def _fmt(rows):
+ return ", ".join(f"{d.strftime('%b %-d')}" for d in rows["Date"])
+
+ parts = []
+ if len(gains):
+ parts.append(f"gained {int(gains['Total followers'].sum())} ({_fmt(gains)})")
+ if len(losses):
+ parts.append(f"lost {int(-losses['Total followers'].sum())} ({_fmt(losses)})")
+ detail = "; ".join(parts) if parts else "no change"
+ sign = "+" if net >= 0 else ""
+ return net, sign, detail
+
+ # Approximate geographic centroid for every country — not just the ones that happen to
+ # appear in this week's export. This used to be a 14-entry dict covering only the countries
+ # present when the map was first built; any new country in a future export would silently
+ # vanish from the map (dropped by the dropna() below) with no visible sign anything was
+ # missing. Now covers essentially every UN member state plus commonly-seen LinkedIn
+ # location names, so a country is never invisible just because nobody thought to add it —
+ # see the fallback warning in followers_country_map_figure() for the rare remaining gap.
_COUNTRY_CENTROIDS = {
- "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),
+ "Afghanistan": (33.9391, 67.7100), "Albania": (41.1533, 20.1683),
+ "Algeria": (28.0339, 1.6596), "Andorra": (42.5462, 1.6016),
+ "Angola": (-11.2027, 17.8739), "Argentina": (-38.4161, -63.6167),
+ "Armenia": (40.0691, 45.0382), "Australia": (-25.2744, 133.7751),
+ "Austria": (47.5162, 14.5501), "Azerbaijan": (40.1431, 47.5769),
+ "Bahamas": (25.0343, -77.3963), "Bahrain": (26.0667, 50.5577),
+ "Bangladesh": (23.6850, 90.3563), "Belarus": (53.7098, 27.9534),
+ "Belgium": (50.5039, 4.4699), "Belize": (17.1899, -88.4976),
+ "Benin": (9.3077, 2.3158), "Bhutan": (27.5142, 90.4336),
+ "Bolivia": (-16.2902, -63.5887), "Bosnia and Herzegovina": (43.9159, 17.6791),
+ "Botswana": (-22.3285, 24.6849), "Brazil": (-14.2350, -51.9253),
+ "Brunei": (4.5353, 114.7277), "Bulgaria": (42.7339, 25.4858),
+ "Burkina Faso": (12.2383, -1.5616), "Burundi": (-3.3731, 29.9189),
+ "Cambodia": (12.5657, 104.9910), "Cameroon": (7.3697, 12.3547),
+ "Canada": (56.1304, -106.3468), "Cape Verde": (16.5388, -23.0418),
+ "Central African Republic": (6.6111, 20.9394), "Chad": (15.4542, 18.7322),
+ "Chile": (-35.6751, -71.5430), "China": (35.8617, 104.1954),
+ "Colombia": (4.5709, -74.2973), "Comoros": (-11.6455, 43.3333),
+ "Costa Rica": (9.7489, -83.7534), "Croatia": (45.1000, 15.2000),
+ "Cuba": (21.5218, -77.7812), "Cyprus": (35.1264, 33.4299),
+ "Czechia": (49.8175, 15.4730), "Denmark": (56.2639, 9.5018),
+ "Djibouti": (11.8251, 42.5903), "Dominican Republic": (18.7357, -70.1627),
+ "DR Congo": (-4.0383, 21.7587), "Democratic Republic of the Congo": (-4.0383, 21.7587),
+ "Ecuador": (-1.8312, -78.1834), "Egypt": (26.8206, 30.8025),
+ "El Salvador": (13.7942, -88.8965), "Estonia": (58.5953, 25.0136),
+ "Eswatini": (-26.5225, 31.4659), "Ethiopia": (9.1450, 40.4897),
+ "Fiji": (-17.7134, 178.0650), "Finland": (61.9241, 25.7482),
+ "France": (46.6034, 1.8883), "Gabon": (-0.8037, 11.6094),
+ "Gambia": (13.4432, -15.3101), "Georgia": (42.3154, 43.3569),
+ "Germany": (51.1657, 10.4515), "Ghana": (7.9465, -1.0232),
+ "Greece": (39.0742, 21.8243), "Greenland": (71.7069, -42.6043),
+ "Guatemala": (15.7835, -90.2308), "Guinea": (9.9456, -9.6966),
+ "Guyana": (4.8604, -58.9302), "Haiti": (18.9712, -72.2852),
+ "Honduras": (15.2000, -86.2419), "Hong Kong": (22.3193, 114.1694),
+ "Hungary": (47.1625, 19.5033), "Iceland": (64.9631, -19.0208),
+ "India": (20.5937, 78.9629), "Indonesia": (-0.7893, 113.9213),
+ "Iran": (32.4279, 53.6880), "Iraq": (33.2232, 43.6793),
+ "Ireland": (53.4129, -8.2439), "Israel": (31.0461, 34.8516),
+ "Italy": (41.8719, 12.5674), "Ivory Coast": (7.5400, -5.5471),
+ "Jamaica": (18.1096, -77.2975), "Japan": (36.2048, 138.2529),
+ "Jordan": (30.5852, 36.2384), "Kazakhstan": (48.0196, 66.9237),
+ "Kenya": (-0.0236, 37.9062), "Kosovo": (42.6026, 20.9030),
+ "Kuwait": (29.3117, 47.4818), "Kyrgyzstan": (41.2044, 74.7661),
+ "Laos": (19.8563, 102.4955), "Latvia": (56.8796, 24.6032),
+ "Lebanon": (33.8547, 35.8623), "Lesotho": (-29.6100, 28.2336),
+ "Liberia": (6.4281, -9.4295), "Libya": (26.3351, 17.2283),
+ "Liechtenstein": (47.1660, 9.5554), "Lithuania": (55.1694, 23.8813),
+ "Luxembourg": (49.8153, 6.1296), "Madagascar": (-18.7669, 46.8691),
+ "Malawi": (-13.2543, 34.3015), "Malaysia": (4.2105, 101.9758),
+ "Maldives": (3.2028, 73.2207), "Mali": (17.5707, -3.9962),
+ "Malta": (35.9375, 14.3754), "Mauritania": (21.0079, -10.9408),
+ "Mauritius": (-20.3484, 57.5522), "Mexico": (23.6345, -102.5528),
+ "Moldova": (47.4116, 28.3699), "Monaco": (43.7384, 7.4246),
+ "Mongolia": (46.8625, 103.8467), "Montenegro": (42.7087, 19.3744),
+ "Morocco": (31.7917, -7.0926), "Mozambique": (-18.6657, 35.5296),
+ "Myanmar": (21.9162, 95.9560), "Namibia": (-22.9576, 18.4904),
+ "Nepal": (28.3949, 84.1240), "Netherlands": (52.1326, 5.2913),
+ "New Zealand": (-40.9006, 174.8860), "Nicaragua": (12.8654, -85.2072),
+ "Niger": (17.6078, 8.0817), "Nigeria": (9.0820, 8.6753),
+ "North Korea": (40.3399, 127.5101), "North Macedonia": (41.6086, 21.7453),
+ "Norway": (60.4720, 8.4689), "Oman": (21.4735, 55.9754),
+ "Pakistan": (30.3753, 69.3451), "Palestine": (31.9522, 35.2332),
+ "Panama": (8.5380, -80.7821), "Papua New Guinea": (-6.3150, 143.9555),
+ "Paraguay": (-23.4425, -58.4438), "Peru": (-9.1900, -75.0152),
+ "Philippines": (12.8797, 121.7740), "Poland": (51.9194, 19.1451),
+ "Portugal": (39.3999, -8.2245), "Puerto Rico": (18.2208, -66.5901),
+ "Qatar": (25.3548, 51.1839), "Romania": (45.9432, 24.9668),
+ "Russia": (61.5240, 105.3188), "Rwanda": (-1.9403, 29.8739),
+ "Saudi Arabia": (23.8859, 45.0792), "Senegal": (14.4974, -14.4524),
+ "Serbia": (44.0165, 21.0059), "Singapore": (1.3521, 103.8198),
+ "Slovakia": (48.6690, 19.6990), "Slovenia": (46.1512, 14.9955),
+ "Somalia": (5.1521, 46.1996), "South Africa": (-30.5595, 22.9375),
+ "South Korea": (35.9078, 127.7669), "South Sudan": (6.8770, 31.3070),
+ "Spain": (40.4637, -3.7492), "Sri Lanka": (7.8731, 80.7718),
+ "Sudan": (12.8628, 30.2176), "Suriname": (3.9193, -56.0278),
+ "Sweden": (60.1282, 18.6435), "Switzerland": (46.8182, 8.2275),
+ "Syria": (34.8021, 38.9968), "Taiwan": (23.6978, 120.9605),
+ "Tajikistan": (38.8610, 71.2761), "Tanzania": (-6.3690, 34.8888),
+ "Thailand": (15.8700, 100.9925), "Togo": (8.6195, 0.8248),
+ "Trinidad and Tobago": (10.6918, -61.2225), "Tunisia": (33.8869, 9.5375),
+ "Turkey": (38.9637, 35.2433), "Turkmenistan": (38.9697, 59.5563),
+ "Uganda": (1.3733, 32.2903), "Ukraine": (48.3794, 31.1656),
+ "United Arab Emirates": (23.4241, 53.8478), "United Kingdom": (55.3781, -3.4360),
+ "United States": (39.8283, -98.5795), "Uruguay": (-32.5228, -55.7658),
+ "Uzbekistan": (41.3775, 64.5853), "Venezuela": (6.4238, -66.5897),
+ "Vietnam": (14.0583, 108.2772), "Yemen": (15.5527, 48.5164),
+ "Zambia": (-13.1339, 27.8493), "Zimbabwe": (-19.0154, 29.1549),
}
+ # LinkedIn omits the country entirely for some well-known metro areas (e.g. "San
+ # Francisco Bay Area" instead of "San Francisco Bay Area, United States") — these have
+ # no comma to split on, so they need an explicit lookup instead of the split-on-comma rule.
+ _LOCATION_COUNTRY_OVERRIDES = {
+ "San Francisco Bay Area": "United States",
+ }
+
+ def _followers_by_country(floc_df):
+ """Location -> Country rollup shared by the map and the country-count caption, so both
+ always agree. Deliberately computed before any centroid lookup, so the count reflects
+ every country actually present in the export — even one missing from _COUNTRY_CENTROIDS
+ (which would otherwise silently vanish from the map with no visible sign anything's off)."""
+ by_country = floc_df.copy()
+ by_country["Country"] = by_country["Location"].map(_LOCATION_COUNTRY_OVERRIDES).fillna(
+ by_country["Location"].str.split(",").str[-1].str.strip())
+ return by_country.groupby("Country", as_index=False)["Total followers"].sum()
+
+ def followers_country_count(floc_df):
+ return len(_followers_by_country(floc_df))
def followers_country_map_figure(floc_df):
"""One marker per country (not per city) showing follower count, on a plain
white-land / gray-water base map — no country borders, no choropleth shading."""
- by_country = 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 = _followers_by_country(floc_df)
by_country["lat"] = by_country["Country"].map(lambda c: _COUNTRY_CENTROIDS.get(c, (None, None))[0])
by_country["lon"] = by_country["Country"].map(lambda c: _COUNTRY_CENTROIDS.get(c, (None, None))[1])
+ missing = by_country.loc[by_country["lat"].isna(), "Country"].tolist()
+ if missing:
+ # _COUNTRY_CENTROIDS now covers essentially every country, so this should be rare —
+ # if it ever fires, add the missing name(s) to that dict rather than ignoring this.
+ print(f"[followers_country_map_figure] WARNING: no centroid for {missing} — "
+ f"dropped from the map (but still counted in followers_country_count).")
by_country = by_country.dropna(subset=["lat", "lon"])
fig = go.Figure(go.Scattergeo(
@@ -633,24 +884,33 @@ def refresh_data():
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.
+ # this card's aspect ratio (md=4 column at 240px tall, narrower than the md=8 this was
+ # first tuned for) — fitbounds="locations" left large blank margins here, since our data's
+ # true shape (Canada-to-China, ~7.8:1) doesn't match a narrow box's shape, and Plotly pads
+ # rather than crops to preserve true geographic proportions. Widening the latitude window
+ # (vs. the data's actual ~27° spread) trades some empty sky above/below for less empty
+ # space left/right, which fills a narrower box better without cropping any country's
+ # marker. This is tuned to an estimated box width, not a measured one (no browser
+ # available to verify pixel dimensions here) — nudge further if it's still off.
# No `template=` here deliberately — applying plotly_white to a geo figure resets/breaks
# its geo styling (landcolor/oceancolor/projection all get overridden). We already control
# every visual aspect of this map explicitly via update_geos() above.
fig.update_geos(showland=True, landcolor="white", showocean=True, oceancolor="#d9d9d9",
showcountries=False, showcoastlines=False, showframe=False,
projection_type="equirectangular",
- lonaxis_range=[-115, 133], lataxis_range=[3, 78])
+ lonaxis_range=[-120, 118], lataxis_range=[0, 85])
return fig
_init_label, _init_fig, _init_total = _breakdown_figure("company_size", False)
FOLLOWERS_BY_EXPLAINER = html.Div([
+ html.Div([html.B("Total"), " shows the ", html.B("Top Ten of ALL"), " followers."],
+ style={"marginBottom": "6px"}),
+ html.Div([html.B("When choosing a month"), ", you see the followers gained or lost that "
+ "month, regardless of whether they show up in the Top Ten shown in Total."],
+ style={"marginBottom": "10px"}),
+ html.Hr(style={"margin": "10px 0"}),
html.Div("These are LinkedIn's standard demographic splits of who is following your page — the "
"point is to check whether your content is reaching the audience you actually care about, "
"not just growing follower count in the abstract.", style={"marginBottom": "10px"}),
@@ -663,7 +923,27 @@ def refresh_data():
"follower count."], style={"color": "#6b7a8d"}),
])
+ FOLLOW_RATE_EXPLAINER = html.Div([
+ html.Div([html.B("Follow Rate = New Followers ÷ Impressions × 100"), ", per day."],
+ style={"marginBottom": "8px"}),
+ html.Div("This replaces Conversion Rate (New Followers ÷ Unique Visitors), which silently "
+ "drops any day with 0 tracked visitors — even if real followers were gained that day "
+ "(e.g. someone follows straight from a post in their feed, without ever visiting the "
+ "Page). Impressions are almost never zero on a day with any reach at all, so those "
+ "days no longer disappear.", style={"marginBottom": "10px"}),
+ html.Hr(style={"margin": "10px 0"}),
+ html.Div([html.B("Days flagged red"), " still had 0 tracked visitors ", html.B("and"),
+ " gained followers — the exact case the old chart used to hide. They're not "
+ "errors; they're the days this fix was built for."], style={"color": "#6b7a8d"}),
+ ])
+
followers_tab = dbc.Container([
+ html.Div([
+ html.Div(kpi("Starting Followers", f"{BASELINE_FOLLOWERS:,}", f"as of {pd.Timestamp(BASELINE_DATE).strftime('%b %d, %Y')}", "#999"), style={"flex": "1", "minWidth": "0"}),
+ html.Div(kpi("Total Followers", f"{current_total_followers:,}", f"as of {current_total_followers_as_of}", TEAL), style={"flex": "1", "minWidth": "0"}),
+ html.Div(kpi("New Followers", f"{new_followers_30d:,}", date_range_label(D["fts"])), style={"flex": "1", "minWidth": "0"}),
+ ], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.5rem"}),
+
dbc.Row([
dbc.Col(section("Follower Gains — Calendar (★ = post published)",
followers_calendar(D["fts"], D["posts"], id_prefix="followers", height="300px")
@@ -679,7 +959,17 @@ def refresh_data():
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader([
- html.Strong("Followers by"),
+ html.Strong("Followers by:"),
+ dcc.Dropdown(
+ id="fb-period-select",
+ options=[{"label": "Total", "value": "total"}] + [
+ {"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"}
+ for y, m in sorted(set(_followers_delta_months()) | set(_followers_count_only_months()))
+ ],
+ value="total", clearable=False, searchable=False,
+ className="compact-dropdown",
+ style={"width": "110px", "fontWeight": "700", "color": "#1a2533"},
+ ),
dcc.Dropdown(
id="fb-select",
options=[{"label": label, "value": key} for key, label in FOLLOWER_BREAKDOWNS],
@@ -694,14 +984,43 @@ def refresh_data():
"cursor": "pointer", "padding": "0"}),
info_popover("followers-by", FOLLOWERS_BY_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
+ html.Div(id="fb-month-label", style={"fontSize": "0.72rem", "color": "#6b7a8d",
+ "textAlign": "right", "marginBottom": "2px"}),
dbc.CardBody(G(_init_fig, style={"height": "240px"}), id="followers-breakdown-area"),
dcc.Store(id="fb-expanded", data=False),
+ dcc.Store(id="followers-selected-month", data={
+ "year": int(D["fts"]["Date"].dt.year.max()),
+ "month": _default_active_month(D["fts"], D["posts"], int(D["fts"]["Date"].dt.year.max())),
+ }),
+ ], className="mb-3 shadow-sm h-100"), md=4),
+ dbc.Col(dbc.Card([
+ dbc.CardHeader([
+ html.Strong("Follow Rate Relative to Reach"),
+ dcc.Dropdown(
+ id="frr-month-select",
+ options=_frr_month_options(D["frr"]),
+ value=_frr_month_options(D["frr"])[0]["value"] if _frr_month_options(D["frr"]) else None,
+ clearable=False, searchable=False,
+ className="compact-dropdown",
+ style={"width": "140px", "fontWeight": "700", "color": "#1a2533", "marginLeft": "auto"},
+ ),
+ info_popover("follow-rate", FOLLOW_RATE_EXPLAINER),
+ ], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
+ dbc.CardBody([
+ G(follow_rate_figure(D["frr"],
+ *([int(p) for p in _frr_month_options(D["frr"])[0]["value"].split("-")]
+ if _frr_month_options(D["frr"]) else [None, None])),
+ style={"height": "210px"}),
+ follow_rate_legend(),
+ ], id="frr-area"),
], className="mb-3 shadow-sm h-100"), md=4),
dbc.Col(section("Followers by Country",
+ html.Div(f"{followers_country_count(D['floc'])} countries represented",
+ style={"fontSize": "0.78rem", "color": "#6b7a8d", "marginBottom": "6px"}),
G(followers_country_map_figure(D["floc"]), style={"height": "240px"},
margin=dict(l=0, r=0, t=0, b=0)),
card_class="h-100",
- ), md=8),
+ ), md=4),
], className="gx-3"),
], fluid=True, className="pb-3")
@@ -711,34 +1030,79 @@ def refresh_data():
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"},
+ "What this measures: whether a day's visits correlate with a follow — each day's total unique "
+ "visitors against that day's new followers. It is not a true funnel. LinkedIn doesn't expose "
+ "which specific visitor became a follower, only daily totals for each, so treat this as a "
+ "directional signal rather than individual-level attribution.",
+ style={"marginBottom": "10px", "color": "#6b7a8d"},
),
+ html.Div([html.B("Amber dots"), " mark days with 0 tracked visitors that still had a real follower "
+ "change — e.g. someone follows straight from a post in their feed, without ever visiting "
+ "the Page. These days used to be silently dropped from this chart entirely (division by "
+ "zero); now they show as a flat 0% bar with a dot flagging that something real happened "
+ "even though no rate could be computed."],
+ style={"marginBottom": "10px", "color": "#6b7a8d"}),
+ html.Div([html.B("Why the bar is flat, not tall, on those days:"), " this chart divides by Unique "
+ "Visitors, and 0 visitors makes that a true division by zero — there is no valid "
+ "percentage to plot, not a small one. That's different from Follow Rate on the Followers "
+ "page, which divides by Impressions (almost never zero), so it always has a real number "
+ "to show. Here, the actual follower count for that day lives in the dot's hover text, "
+ "since there's no honest percentage that represents \"followers over 0 visitors.\""],
+ style={"color": "#6b7a8d"}),
])
- _conv_rate_df = D["conv"].dropna(subset=["Conversion Rate"]).copy()
- _conv_rate_df["Sign"] = _conv_rate_df["Conversion Rate"].apply(lambda v: "Positive" if v >= 0 else "Negative")
+ _conv_rate_df = D["conv"].copy()
+ _conv_rate_df["Flagged"] = (_conv_rate_df["Unique Visitors"] == 0) & (_conv_rate_df["New Followers"] != 0)
+ _conv_rate_df["Conversion Rate"] = _conv_rate_df["Conversion Rate"].fillna(0)
- # 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])
+ def _conversion_rate_figure(df):
+ fig = px.bar(df, x="Date", y="Conversion Rate", template=TMPL,
+ color_discrete_sequence=[LIGHT_BLUE],
+ labels={"Conversion Rate": "Conversion Rate (%)"})
+ fig.data[0].name = "Conversion rate"
fig.update_layout(showlegend=False)
- return label, fig
+ flagged = df[df["Flagged"]]
+ if len(flagged):
+ peak = df["Conversion Rate"].abs().max()
+ offset = peak * 0.12 if peak > 0 else 3
+ hover_text = [
+ f"{d.strftime('%b %-d, %Y')}: 0 tracked visitors, {'+' if nf > 0 else ''}{nf} followers that day"
+ for d, nf in zip(flagged["Date"], flagged["New Followers"])
+ ]
+ fig.add_scatter(
+ x=flagged["Date"], y=[offset] * len(flagged), mode="markers",
+ marker=dict(color=AMBER, size=9, symbol="circle", line=dict(width=1, color="white")),
+ name="0 visitors, follower change", text=hover_text,
+ hovertemplate="%{text}",
+ )
+ return fig
+
+ # key -> (label, category column) — the dataframe itself now comes from the running
+ # rollup (visitor_history.rollup_as_of), not a single-file snapshot.
+ VISITOR_BREAKDOWN_MAP = {
+ "seniority": ("Seniority", "Category"),
+ "company_size": ("Company Size", "Category"),
+ "location": ("Location", "Category"),
+ }
+ VISITOR_BREAKDOWNS = [(key, label) for key, (label, _col) in VISITOR_BREAKDOWN_MAP.items()]
+
+ def _visitor_breakdown_figure(key, expanded=False, year=None, month=None):
+ label, col = VISITOR_BREAKDOWN_MAP[key]
+ on_or_before = f"{year:04d}-{month:02d}-31" if year and month else None
+ df = visitor_history.rollup_as_of(D["visitor_category_history"], key, on_or_before)
+ total_n = len(df)
+ show_df = df if expanded else df.head(10)
+ fig = px.bar(show_df.sort_values("Total views"), x="Total views", y="Category",
+ orientation="h", template=TMPL, color_discrete_sequence=[LIGHT_BLUE],
+ labels={"Category": label})
+ fig.update_layout(showlegend=False)
+ return label, fig, total_n
+
+ def _visitor_category_months():
+ return visitor_history.available_months(D["visitor_category_history"])
- _vb_init_label, _vb_init_fig = _visitor_breakdown_figure("seniority")
+ _vb_init_label, _vb_init_fig, _vb_init_total = _visitor_breakdown_figure("seniority", False)
VISITOR_VS_FOLLOWER_EXPLAINER = html.Div([
html.Div([html.B("Visitor"), " = anyone who viewed the Page, for any reason — searched for "
@@ -762,6 +1126,30 @@ def refresh_data():
])
VISITOR_TERMINOLOGY_EXPLAINER = html.Div([
+ html.Div([html.B("Total"), " is a running total across every weekly export uploaded so "
+ "far — every category ever seen keeps its running count, even if it doesn't "
+ "appear in every single week's export."],
+ style={"marginBottom": "6px"}),
+ html.Div([html.B("Choosing a month"), " shows the running total as it stood at the end "
+ "of that month — July includes everything added through July, June only what "
+ "had accumulated by then."],
+ style={"marginBottom": "10px"}),
+ html.Div([html.B("Why some categories appear or vanish between weeks:"), " LinkedIn only "
+ "reports a category here if it clears a minimum-visits threshold that week — so "
+ "we generally only see the higher-traffic cities/categories, and a lower-traffic "
+ "one can appear one week and drop out the next simply by dipping below that "
+ "threshold, not because visits actually stopped. The running total exists so a "
+ "category doesn't lose its count just because a later week's export happened to "
+ "leave it out."],
+ style={"marginBottom": "10px", "color": "#6b7a8d"}),
+ html.Div([html.B("Also important:"), " unlike Followers, a Visitor isn't a standing "
+ "relationship someone can end — a visit already happened and can't be "
+ "\"un-visited.\" And since LinkedIn's per-export numbers are a rolling window "
+ "(not a fresh daily count), consecutive weekly exports likely overlap in which "
+ "visits they're counting — so treat this running total as a cumulative activity "
+ "signal, not a precise unique-visitor count."],
+ style={"marginBottom": "10px", "color": "#6b7a8d"}),
+ html.Hr(style={"margin": "10px 0"}),
html.Div([html.B("Unique Visitor"), " = a distinct person, counted once per day no matter how many "
"tabs they looked at or how many times they viewed the page."], style={"marginBottom": "8px"}),
html.Div([html.B("Page View"), " = every individual view — one person browsing 3 tabs generates 3 "
@@ -790,17 +1178,27 @@ def refresh_data():
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"})),
+ dbc.CardBody([
+ G(_conversion_rate_figure(_conv_rate_df), style={"height": "270px"}),
+ conversion_rate_legend(),
+ ]),
], className="mb-3 shadow-sm"), md=6),
], className="gx-3 mb-3"),
dbc.Row([
dbc.Col(dbc.Card([
dbc.CardHeader([
- html.Strong("Visitors by"),
+ html.Strong("Visitors by:"),
+ dcc.Dropdown(
+ id="vb-period-select",
+ options=[{"label": "Total", "value": "total"}] + [
+ {"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"}
+ for y, m in _visitor_category_months()
+ ],
+ value="total", clearable=False, searchable=False,
+ className="compact-dropdown",
+ style={"width": "110px", "fontWeight": "700", "color": "#1a2533"},
+ ),
dcc.Dropdown(
id="vb-select",
options=[{"label": label, "value": key} for key, label in VISITOR_BREAKDOWNS],
@@ -808,9 +1206,17 @@ def refresh_data():
className="compact-dropdown",
style={"width": "150px", "fontWeight": "700", "color": "#1a2533"},
),
+ html.Button("Show all", id="vb-expand-btn", n_clicks=0,
+ style={"display": "inline-block" if _vb_init_total > 10 else "none",
+ "marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
+ "background": "none", "color": "white", "textDecoration": "underline",
+ "cursor": "pointer", "padding": "0"}),
info_popover("visitor-terminology", VISITOR_TERMINOLOGY_EXPLAINER),
], style={"display": "flex", "alignItems": "center", "gap": "8px"}),
+ html.Div(id="vb-month-label", style={"fontSize": "0.72rem", "color": "#6b7a8d",
+ "textAlign": "right", "marginBottom": "2px"}),
dbc.CardBody(G(_vb_init_fig, style={"height": "240px"}), id="visitor-breakdown-area"),
+ dcc.Store(id="vb-expanded", data=False),
], className="mb-3 shadow-sm h-100"), md=4),
dbc.Col(section("Page Views over Time",
G(px.line(D["vts"][["Date", "Overview page views (total)", "Jobs page views (total)"]]
@@ -820,6 +1226,7 @@ def refresh_data():
labels={"value": "Page Views", "variable": "Section"},
), style={"height": "240px"}),
card_class="h-100",
+ info=("page-views", PAGE_VIEWS_EXPLAINER),
), md=4),
dbc.Col(section("Unique Visitors — Desktop vs Mobile",
G(px.area(D["vts"][["Date", "Total unique visitors (desktop)", "Total unique visitors (mobile)"]]
@@ -1123,7 +1530,7 @@ def _month_day_tiles(fts_df, posts_df, year, month):
"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"})
+ return html.Div(tiles, style={"display": "flex", "gap": "4px", "flexWrap": "nowrap", "overflowX": "auto"})
def followers_calendar(fts_df, posts_df, id_prefix, height="320px"):
@@ -1182,11 +1589,45 @@ def daily_followers_figure(fts_df, posts_df, year=None, month=None):
return _std(mark_posts(fig, df, pdf))
+def _frr_month_options(frr_df):
+ """Every (year, month) with real Follow Rate data, newest first — drives the month
+ dropdown on the 'Follow Rate Relative to Reach' chart."""
+ months = sorted(set(zip(frr_df["Date"].dt.year, frr_df["Date"].dt.month)), reverse=True)
+ return [{"label": f"{_MONTH_NAMES[m - 1]} {y}", "value": f"{y}-{m}"} for y, m in months]
+
+
+def follow_rate_figure(frr_df, year=None, month=None):
+ """Daily Follow Rate (New Followers / Impressions * 100) — every bar is the real value in
+ blue; a red dot floats above any day with 0 tracked visitors that still gained followers,
+ the case Conversion Rate used to silently drop."""
+ df = frr_df.dropna(subset=["Follow Rate"]).copy()
+ if year is not None and month is not None:
+ df = df[(df["Date"].dt.year == year) & (df["Date"].dt.month == month)]
+ fig = px.bar(df, x="Date", y="Follow Rate", template=TMPL,
+ color_discrete_sequence=[LIGHT_BLUE],
+ labels={"Follow Rate": "Follow Rate (%)"})
+ fig.data[0].name = "Follow rate"
+
+ flagged = df[df["No Visitor Gain"]]
+ if len(flagged):
+ peak = df["Follow Rate"].max()
+ offset = peak * 0.1 if peak > 0 else 0.1
+ fig.add_scatter(
+ x=flagged["Date"], y=flagged["Follow Rate"] + offset, mode="markers",
+ marker=dict(color="#d43f3a", size=9, symbol="circle", line=dict(width=1, color="white")),
+ name="0 visitors, gained followers", hoverinfo="skip",
+ )
+ fig.update_layout(showlegend=False)
+ return fig
+
+
def _legend_swatch(color, label, shape="square"):
swatch_style = {"width": "12px", "height": "12px", "backgroundColor": color, "display": "inline-block",
"marginRight": "5px", "verticalAlign": "middle"}
if shape == "square":
swatch_style["borderRadius"] = "2px"
+ elif shape == "circle":
+ swatch_style["borderRadius"] = "50%"
return html.Span([
html.Span(style=swatch_style) if shape != "text" else html.Span(color, style={"marginRight": "5px", "color": color, "fontWeight": "700"}),
html.Span(label, style={"fontSize": "0.72rem", "color": "#6b7a8d"}),
@@ -1198,6 +1639,32 @@ def calendar_legend():
_legend_swatch("#FFCDD2", "Follower loss"),
], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
+def follow_rate_legend():
+ return html.Div([
+ _legend_swatch(LIGHT_BLUE, "Follow rate (%)"),
+ _legend_swatch("#d43f3a", "0 visitors, gained followers", shape="circle"),
+ ], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
+
+def conversion_rate_legend():
+ return html.Div([
+ _legend_swatch(LIGHT_BLUE, "Conversion rate (%)"),
+ _legend_swatch(AMBER, "0 visitors, follower change", shape="circle"),
+ ], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
+
+def delta_legend():
+ return html.Div([
+ _legend_swatch("#52C41A", "Gained"),
+ _legend_swatch("#d43f3a", "Lost"),
+ ], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
+
+def visitor_delta_legend():
+ # "Increased/Decreased" rather than "Gained/Lost" — a visit already happened and can't be
+ # taken back, so this can't be framed as people joining/leaving the way Followers can.
+ return html.Div([
+ _legend_swatch("#52C41A", "Increased"),
+ _legend_swatch("#d43f3a", "Decreased"),
+ ], style={"marginTop": "10px", "display": "flex", "flexWrap": "wrap", "alignItems": "center"})
+
# ── Overview metric explainers ─────────────────────────────────────────────────
SPONSORED_NOTE = html.Div("Sponsored covers paid/boosted promotion — currently flat at 0 since no ads are "
@@ -1229,7 +1696,9 @@ CTR_EXPLAINER = html.Div([
])
PAGE_VIEWS_EXPLAINER = html.Div([
- html.Div("Visits to your LinkedIn Page, split between desktop and mobile.", style={"marginBottom": "8px"}),
+ html.Div("This counts visits to our TXproducts Company Page specifically, not general LinkedIn "
+ "browsing and not posts showing up in someone's feed. Split between desktop and mobile.",
+ style={"marginBottom": "8px"}),
html.Div("Counts page views regardless of whether the visitor is logged in or already follows you.",
style={"color": "#6b7a8d"}),
])
@@ -1571,6 +2040,43 @@ TAB_HERO = {
"growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."),
}
+GROWTH_METRIC_EXPLAINER = html.Div([
+ html.Div(html.B("Growth here is exclusively the cumulative followers."), style={"marginBottom": "8px"}),
+ html.Div("It's a single, narrow metric: are we gaining followers faster or slower than our own best "
+ "pace so far.", style={"color": "#6b7a8d"}),
+])
+
+TAB_HERO_INFO = {"growth": GROWTH_METRIC_EXPLAINER}
+
+
+def _hero_subtitle_children(active):
+ text = TAB_HERO[active][1]
+ info = TAB_HERO_INFO.get(active)
+ if info is None:
+ return text
+ return [text, info_popover(f"hero-{active}", info)]
+
+
+def _hero_actions(active):
+ """Follower Directory / Follower Study links shown in the hero banner — only on the
+ Followers tab, not in the sidebar. Both are standalone HTML files regenerated from
+ manual_followers_list_2026-07-22.xlsx and copied into assets/, so Dash serves them
+ as-is at /assets/... ; re-copy them here after every follower data update."""
+ if active != "followers":
+ return []
+ link_style = {
+ "color": "white", "fontSize": "0.8rem", "fontWeight": "600",
+ "textDecoration": "none", "padding": "7px 14px",
+ "backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px",
+ "border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap",
+ }
+ return [
+ html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank",
+ rel="noopener noreferrer", style=link_style),
+ html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
+ rel="noopener noreferrer", style={**link_style, "marginLeft": "8px"}),
+ ]
+
_SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR)
# Sidebar — vertical logo, LinkedIn+Analytics brand mark, description, vertical nav, footer
@@ -1672,15 +2178,22 @@ sidebar = html.Div([
"display": "flex", "flexDirection": "column", "minHeight": "100vh",
})
-# Gradient hero banner (matches ONTLEX/txdata's .page-hero) — title/subtitle change with the active tab
+# Gradient hero banner (matches ONTLEX/txdata's .page-hero) — title/subtitle change with the active tab.
+# hero-actions is a right-aligned slot for per-tab link buttons (currently just the Followers
+# tab's Directory/Study links) — empty on every other tab.
hero = html.Div([
- html.Div(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"}),
+ html.Div([
+ html.Div(TAB_HERO["overview"][0], id="hero-title",
+ style={"color": "white", "fontSize": "1.45rem", "fontWeight": "800", "lineHeight": "1.1"}),
+ html.Div(TAB_HERO["overview"][1], id="hero-subtitle",
+ style={"color": "rgba(255,255,255,0.84)", "fontSize": "0.9rem", "fontWeight": "500", "marginTop": "4px"}),
+ ], style={"flex": "1", "minWidth": "0"}),
+ html.Div(_hero_actions("overview"), id="hero-actions",
+ style={"display": "flex", "alignItems": "center", "flexShrink": "0", "marginLeft": "16px"}),
], style={
"background": "linear-gradient(90deg,#1b3a5c 0%,#2d6a9f 100%)",
"borderRadius": "8px", "padding": "14px 18px 13px", "marginTop": "4px", "marginBottom": "16px",
+ "display": "flex", "alignItems": "center", "justifyContent": "space-between",
})
main_content = html.Div([
@@ -1693,7 +2206,8 @@ app.layout = html.Div([sidebar, main_content], style={"display": "flex", "alignI
@app.callback(
[Output(f"nav-{key}", "className") for key, _, _ in NAV_ITEMS]
- + [Output("tab-content-area", "children"), Output("hero-title", "children"), Output("hero-subtitle", "children")],
+ + [Output("tab-content-area", "children"), Output("hero-title", "children"), Output("hero-subtitle", "children"),
+ Output("hero-actions", "children")],
[Input(f"nav-{key}", "n_clicks") for key, _, _ in NAV_ITEMS]
+ [Input("growth-indicator-btn", "n_clicks")],
)
@@ -1704,8 +2218,8 @@ def _switch_tab(*_):
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]
+ hero_title = TAB_HERO[active][0]
+ return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), _hero_actions(active)]
FILE_TYPE_KEYWORDS = ["followers", "visitors", "competitor", "content"]
@@ -1723,6 +2237,7 @@ FILE_TYPE_KEYWORDS = ["followers", "visitors", "competitor", "content"]
Output("tab-content-area", "children", allow_duplicate=True),
Output("hero-title", "children", allow_duplicate=True),
Output("hero-subtitle", "children", allow_duplicate=True),
+ Output("hero-actions", "children", allow_duplicate=True),
Input("data-upload", "contents"),
State("data-upload", "filename"),
prevent_initial_call=True,
@@ -1735,7 +2250,7 @@ def _handle_upload(contents_list, filename_list):
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)
+ no_update_rest = [no_update] * (6 + len(NAV_ITEMS) + 4)
if not contents_list:
return [no_update] + no_update_rest
@@ -1768,7 +2283,8 @@ def _handle_upload(contents_list, filename_list):
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"]
+ hero_title = TAB_HERO["growth"][0]
+ hero_subtitle = _hero_subtitle_children("growth")
return [
status_children,
@@ -1782,6 +2298,7 @@ def _handle_upload(contents_list, filename_list):
TAB_CONTENT_MAP["growth"],
hero_title,
hero_subtitle,
+ _hero_actions("growth"),
]
@@ -1799,34 +2316,107 @@ def _toggle_followers_expand(_, expanded):
Output("followers-breakdown-area", "children"),
Output("fb-expand-btn", "children"),
Output("fb-expand-btn", "style"),
+ Output("fb-month-label", "children"),
Input("fb-select", "value"),
+ Input("fb-period-select", "value"),
Input("fb-expanded", "data"),
+ Input("followers-selected-month", "data"),
)
-def _render_followers_breakdown(active, expanded):
- label, fig, total_n = _breakdown_figure(active, expanded)
+def _render_followers_breakdown(active, period, expanded, selected_month):
+ btn_style_base = {"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
+ "background": "none", "color": "white", "textDecoration": "underline",
+ "cursor": "pointer", "padding": "0"}
+
+ if period != "total":
+ year, month = (int(p) for p in period.split("-"))
+ btn_style = {**btn_style_base, "display": "none"}
+
+ if (year, month) in set(_followers_count_only_months()):
+ net, sign, detail = _monthly_count_summary(year, month)
+ month_label = f"{_MONTH_NAMES[month - 1]} {year} — no category breakdown available"
+ note = html.Div([
+ html.Div(f"Net {sign}{net} follower{'s' if abs(net) != 1 else ''} in "
+ f"{_MONTH_NAMES[month - 1]} {year}: {detail}.",
+ style={"marginBottom": "8px", "fontWeight": "700", "color": "#1a2533"}),
+ html.Div("Category breakdown (location, industry, etc.) isn't available for these — "
+ "LinkedIn's demographic export wasn't captured yet at this point, so there's "
+ "no earlier snapshot to know which category they belong to.",
+ style={"color": "#6b7a8d", "fontSize": "0.8rem"}),
+ ], style={"padding": "20px 4px"})
+ return note, "", btn_style, month_label
+
+ label, fig, changed_n, overflow = _breakdown_delta_figure(active, year, month)
+ month_label = f"{_MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
+ if overflow > 0:
+ month_label += f" · showing top {_DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
+ return [G(fig, style={"height": "240px"}), delta_legend()], "", btn_style, month_label
+
+ year = selected_month.get("year") if selected_month else None
+ month = selected_month.get("month") if selected_month else None
+ label, fig, total_n = _breakdown_figure(active, expanded, year, month)
needs_toggle = total_n > 10
- btn_style = {"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_style = {**btn_style_base, "display": "inline-block" if needs_toggle else "none"}
btn_label = "Show top 10" if expanded else f"Show all {total_n}"
- return G(fig, style={"height": "240px"}), btn_label, btn_style
+ month_label = f"as of {_MONTH_NAMES[month - 1]} {year}" if year and month else ""
+ return G(fig, style={"height": "240px"}), btn_label, btn_style, month_label
+
+
+@app.callback(
+ Output("vb-expanded", "data", allow_duplicate=True),
+ Input("vb-expand-btn", "n_clicks"),
+ State("vb-expanded", "data"),
+ prevent_initial_call=True,
+)
+def _toggle_visitors_expand(_, expanded):
+ return not expanded
@app.callback(
Output("visitor-breakdown-area", "children"),
+ Output("vb-expand-btn", "children"),
+ Output("vb-expand-btn", "style"),
+ Output("vb-month-label", "children"),
Input("vb-select", "value"),
+ Input("vb-period-select", "value"),
+ Input("vb-expanded", "data"),
)
-def _render_visitor_breakdown(active):
- label, fig = _visitor_breakdown_figure(active)
- return G(fig, style={"height": "240px"})
+def _render_visitor_breakdown(active, period, expanded):
+ btn_style_base = {"marginLeft": "auto", "fontSize": "0.72rem", "border": "none",
+ "background": "none", "color": "white", "textDecoration": "underline",
+ "cursor": "pointer", "padding": "0"}
+
+ if period != "total":
+ year, month = (int(p) for p in period.split("-"))
+ label, fig, total_n = _visitor_breakdown_figure(active, expanded, year, month)
+ month_label = f"as of {_MONTH_NAMES[month - 1]} {year}"
+ else:
+ label, fig, total_n = _visitor_breakdown_figure(active, expanded)
+ month_label = ""
+
+ needs_toggle = total_n > 10
+ btn_style = {**btn_style_base, "display": "inline-block" if needs_toggle else "none"}
+ btn_label = "Show top 10" if expanded else f"Show all {total_n}"
+ return G(fig, style={"height": "240px"}), btn_label, btn_style, month_label
+
+
+@app.callback(
+ Output("frr-area", "children"),
+ Input("frr-month-select", "value"),
+)
+def _render_follow_rate(period):
+ year, month = (int(p) for p in period.split("-"))
+ fig = follow_rate_figure(D["frr"], year, month)
+ return [G(fig, style={"height": "210px"}), follow_rate_legend()]
def _register_month_click_callback(id_prefix):
+ extra_outputs = [Output("followers-selected-month", "data")] if id_prefix == "followers" else []
+
@app.callback(
[Output(f"{id_prefix}-month-btn-{m}", "children") for m in range(1, 13)]
+ [Output(f"{id_prefix}-month-btn-{m}", "style") for m in range(1, 13)]
- + [Output(f"{id_prefix}-posts-row", "children"), Output(f"{id_prefix}-daily-followers-graph", "figure")],
+ + [Output(f"{id_prefix}-posts-row", "children"), Output(f"{id_prefix}-daily-followers-graph", "figure")]
+ + extra_outputs,
[Input(f"{id_prefix}-year-select", "value")]
+ [Input(f"{id_prefix}-month-btn-{m}", "n_clicks") for m in range(1, 13)],
)
@@ -1840,8 +2430,11 @@ def _register_month_click_callback(id_prefix):
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)]
+ result = children + styles + [_month_day_tiles(D["fts"], D["posts"], year, month),
+ daily_followers_figure(D["fts"], D["posts"], year, month)]
+ if id_prefix == "followers":
+ result.append({"year": year, "month": month})
+ return result
for _prefix in ("overview", "followers"):
diff --git a/build_static_export.py b/build_static_export.py
index 579c3e7..ba732e2 100644
--- a/build_static_export.py
+++ b/build_static_export.py
@@ -6,7 +6,7 @@ component trees (dbc.Card/Row/Col are just Bootstrap-class wrappers) into plain
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.
+client-side with vanilla JS, everything else is a static snapshot of today's data.
"""
import base64
import html as html_escape_mod
@@ -60,11 +60,11 @@ def style_dict_to_css(style):
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
+ 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
+ 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:
@@ -134,13 +134,22 @@ def render(comp):
if cid == "report-timeframe-select":
return (f'')
+ f'title="PDF generation needs the live tool, not available in this snapshot.">{opts}')
return f''
# --- Stores/Download: no static equivalent needed (Download requires a live backend) ---
if isinstance(comp, (dcc.Store, dcc.Download)):
return ""
+ # --- Upload: disabled in the snapshot, saving files needs a live Python backend ---
+ if isinstance(comp, dcc.Upload):
+ style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
+ "opacity": "0.5", "cursor": "not-allowed"})
+ cid = getattr(comp, "id", "") or next_chart_id()
+ return (f'
'
+ f'{render(getattr(comp, "children", None))}
')
+
# --- DataTable: render a plain HTML table from data/columns ---
if isinstance(comp, dash_table.DataTable):
cols = comp.columns
@@ -231,16 +240,19 @@ def render(comp):
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}{tag}>'
+ if cid == "growth-indicator-btn":
+ onclick = f' onclick="switchTab(\'growth\')"'
+ return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}{onclick}>{children_html}{tag}>'
return f'<{tag}{id_attr}{class_attr} style="{style}"{title_attr}>{children_html}{tag}>'
- # Unknown component type — best-effort render children
+ # Unknown component type, best-effort render children
return render(getattr(comp, "children", None))
@@ -250,7 +262,7 @@ def fig_json(fig):
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."""
+ 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"):
@@ -275,33 +287,109 @@ def precompute_calendar_states():
def precompute_fb_states():
- """Followers-By dropdown x Show-all-toggle states."""
+ """Followers-By dropdown x Show-all-toggle x calendar-month states — mirrors the live
+ app's _render_followers_breakdown callback, which re-renders on fb-select, fb-expanded,
+ AND the selected calendar month (so June shows June's actual demographic snapshot, not
+ always the latest one)."""
states = {}
+ months = sorted(A.D["breakdown_history"]) + [None] # None = no month context (fallback/current)
for key, _label in A.FOLLOWER_BREAKDOWNS:
for expanded in (False, True):
- _lbl, fig, total_n = A._breakdown_figure(key, expanded)
+ for ym in months:
+ year, month = ym if ym is not None else (None, None)
+ _lbl, fig, total_n = A._breakdown_figure(key, expanded, year, month)
+ fig = A._std(fig)
+ needs_toggle = total_n > 10
+ btn_label = "Show top 10" if expanded else f"Show all {total_n}"
+ month_label = f"as of {A._MONTH_NAMES[month - 1]} {year}" if year and month else ""
+ month_key = f"{year}-{month}" if year and month else "none"
+ states[f"{key}|{str(expanded).lower()}|{month_key}"] = {
+ "fig": fig_json(fig),
+ "btnLabel": btn_label,
+ "btnDisplay": "inline-block" if needs_toggle else "none",
+ "monthLabel": month_label,
+ }
+ return states
+
+
+def precompute_fb_delta_states():
+ """Followers-By month-over-month delta states — one per breakdown key x delta-eligible
+ month, mirroring the live app's _breakdown_delta_figure branch of _render_followers_breakdown."""
+ states = {}
+ for key, _label in A.FOLLOWER_BREAKDOWNS:
+ for year, month in A._followers_delta_months():
+ _lbl, fig, changed_n, overflow = A._breakdown_delta_figure(key, year, month)
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()}"] = {
+ month_label = f"{A._MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
+ if overflow > 0:
+ month_label += f" · showing top {A._DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
+ states[f"{key}|{year}-{month}"] = {
"fig": fig_json(fig),
- "btnLabel": btn_label,
- "btnDisplay": "inline-block" if needs_toggle else "none",
+ "monthLabel": month_label,
}
return states
-def precompute_vb_states():
- """Visitors-By dropdown states."""
+def precompute_fb_count_only_states():
+ """Months with real follower-count activity but no category snapshot to attribute it to
+ (currently just June 2026) — mirrors the live app's count-only branch of
+ _render_followers_breakdown, which shows a plain explanation instead of a bar chart."""
states = {}
+ for year, month in A._followers_count_only_months():
+ net, sign, detail = A._monthly_count_summary(year, month)
+ month_label = f"{A._MONTH_NAMES[month - 1]} {year} — no category breakdown available"
+ note_html = (
+ f'
'
+ f'
'
+ f'Net {esc(sign)}{net} follower{"s" if abs(net) != 1 else ""} in '
+ f'{A._MONTH_NAMES[month - 1]} {year}: {esc(detail)}.
'
+ f'
'
+ f'Category breakdown (location, industry, etc.) isn\'t available for these — '
+ f'LinkedIn\'s demographic export wasn\'t captured yet at this point, so there\'s no '
+ f'earlier snapshot to know which category they belong to.
'
+ f'
'
+ )
+ states[f"{year}-{month}"] = {"noteHtml": note_html, "monthLabel": month_label}
+ return states
+
+
+def precompute_vb_states():
+ """Visitors-By dropdown x Show-all-toggle x period states — mirrors precompute_fb_states.
+ 'total' means the running total (no month scope); other keys are 'YYYY-M' month cutoffs
+ against the persistent running total (see visitor_history.py)."""
+ states = {}
+ periods = [("total", None, None)] + [
+ (f"{y}-{m}", y, m) for y, m in A._visitor_category_months()
+ ]
for key, _label in A.VISITOR_BREAKDOWNS:
- _lbl, fig = A._visitor_breakdown_figure(key)
- states[key] = {"fig": fig_json(A._std(fig))}
+ for expanded in (False, True):
+ for period_key, year, month in periods:
+ _lbl, fig, total_n = A._visitor_breakdown_figure(key, expanded, year, month)
+ fig = A._std(fig)
+ needs_toggle = total_n > 10
+ btn_label = "Show top 10" if expanded else f"Show all {total_n}"
+ month_label = f"as of {A._MONTH_NAMES[month - 1]} {year}" if year and month else ""
+ states[f"{key}|{str(expanded).lower()}|{period_key}"] = {
+ "fig": fig_json(fig),
+ "btnLabel": btn_label,
+ "btnDisplay": "inline-block" if needs_toggle else "none",
+ "monthLabel": month_label,
+ }
+ return states
+
+
+def precompute_frr_states():
+ """Follow Rate Relative to Reach month-dropdown states."""
+ states = {}
+ for opt in A._frr_month_options(A.D["frr"]):
+ year, month = (int(p) for p in opt["value"].split("-"))
+ fig = A._std(A.follow_rate_figure(A.D["frr"], year, month))
+ states[opt["value"]] = {"fig": fig_json(fig)}
return states
def precompute_content_states():
- """Content tab's month-filter dropdown states — 6 charts + the posts table."""
+ """Content tab's month-filter dropdown states, 6 charts + the posts table."""
states = {}
for opt in A._content_month_options():
value = opt["value"]
@@ -337,16 +425,26 @@ def precompute_content_states():
JS_TEMPLATE = r"""
const CHARTS = %(charts)s;
const TAB_HERO = %(tab_hero)s;
+const TAB_HERO_INFO = %(tab_hero_info)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 FB_DELTA_STATES = %(fb_delta_states)s;
+const FB_COUNT_ONLY_STATES = %(fb_count_only_states)s;
const VB_STATES = %(vb_states)s;
const CONTENT_STATES = %(content_states)s;
+const FRR_STATES = %(frr_states)s;
+
+const DELTA_LEGEND_HTML = '
'
+ + 'Gained'
+ + 'Lost'
+ + '
';
const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH};
-let fbActive = 'company_size', fbExpanded = false;
-let vbActive = 'seniority';
+let fbActive = 'company_size', fbExpanded = false, fbPeriod = 'total';
+let vbActive = 'seniority', vbExpanded = false, vbPeriod = 'total';
+let frrPeriod = %(frr_default_period)s;
function renderChart(divId, figSpec) {
if (!figSpec || !document.getElementById(divId)) return;
@@ -378,7 +476,10 @@ function switchTab(key) {
var hero = TAB_HERO[key];
if (hero) {
document.getElementById('hero-title').textContent = hero[0];
- document.getElementById('hero-subtitle').textContent = hero[1];
+ var subEl = document.getElementById('hero-subtitle');
+ subEl.textContent = hero[1];
+ var infoHtml = TAB_HERO_INFO[key];
+ if (infoHtml) subEl.insertAdjacentHTML('beforeend', infoHtml);
}
window.dispatchEvent(new Event('resize'));
}
@@ -397,12 +498,41 @@ function selectCalendarMonth(prefix, month) {
if (state.metrics) {
Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); });
}
+ if (prefix === 'followers') renderFollowersBreakdown();
}
function renderFollowersBreakdown() {
- var s = FB_STATES[fbActive + '|' + fbExpanded];
+ if (fbPeriod !== 'total') {
+ var cs = FB_COUNT_ONLY_STATES[fbPeriod];
+ if (cs) {
+ var area = document.getElementById('followers-breakdown-area');
+ if (area) area.innerHTML = cs.noteHtml;
+ var clabel = document.getElementById('fb-month-label');
+ if (clabel) clabel.textContent = cs.monthLabel;
+ var cbtn = document.getElementById('fb-expand-btn');
+ if (cbtn) cbtn.style.display = 'none';
+ return;
+ }
+ var ds = FB_DELTA_STATES[fbActive + '|' + fbPeriod];
+ if (!ds) return;
+ var area2 = document.getElementById('followers-breakdown-area');
+ if (area2) area2.innerHTML = '' + DELTA_LEGEND_HTML;
+ renderChartWithin('followers-breakdown-area', ds.fig);
+ var dlabel = document.getElementById('fb-month-label');
+ if (dlabel) dlabel.textContent = ds.monthLabel;
+ var dbtn = document.getElementById('fb-expand-btn');
+ if (dbtn) dbtn.style.display = 'none';
+ return;
+ }
+ var month = calMonth.followers;
+ var monthKey = month ? (CAL_YEAR + '-' + month) : 'none';
+ var s = FB_STATES[fbActive + '|' + fbExpanded + '|' + monthKey] || FB_STATES[fbActive + '|' + fbExpanded + '|none'];
if (!s) return;
+ var totalArea = document.getElementById('followers-breakdown-area');
+ if (totalArea) totalArea.innerHTML = '';
renderChartWithin('followers-breakdown-area', s.fig);
+ var label = document.getElementById('fb-month-label');
+ if (label) label.textContent = s.monthLabel;
var btn = document.getElementById('fb-expand-btn');
if (btn) {
btn.textContent = s.btnLabel;
@@ -411,9 +541,28 @@ function renderFollowersBreakdown() {
}
function renderVisitorBreakdown() {
- var s = VB_STATES[vbActive];
+ var s = VB_STATES[vbActive + '|' + vbExpanded + '|' + vbPeriod];
if (!s) return;
+ var area = document.getElementById('visitor-breakdown-area');
+ if (area) area.innerHTML = '';
renderChartWithin('visitor-breakdown-area', s.fig);
+ var label = document.getElementById('vb-month-label');
+ if (label) label.textContent = s.monthLabel;
+ var btn = document.getElementById('vb-expand-btn');
+ if (btn) {
+ btn.textContent = s.btnLabel;
+ btn.style.display = s.btnDisplay;
+ }
+}
+
+function renderFollowRate() {
+ var s = FRR_STATES[frrPeriod];
+ if (!s) return;
+ var area = document.getElementById('frr-area');
+ if (area && !area.querySelector('.js-plotly-chart')) {
+ area.innerHTML = '';
+ }
+ renderChartWithin('frr-area', s.fig);
}
function renderContentState(value) {
@@ -459,11 +608,19 @@ window.__onClick = function (el) {
renderFollowersBreakdown();
return;
}
+ if (id === 'vb-expand-btn') {
+ vbExpanded = !vbExpanded;
+ renderVisitorBreakdown();
+ 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 === 'fb-period-select') { fbPeriod = el.value; renderFollowersBreakdown(); return; }
+ if (el.id === 'vb-select') { vbActive = el.value; vbExpanded = false; renderVisitorBreakdown(); return; }
+ if (el.id === 'vb-period-select') { vbPeriod = el.value; renderVisitorBreakdown(); return; }
+ if (el.id === 'frr-month-select') { frrPeriod = el.value; renderFollowRate(); return; }
if (el.id === 'content-month-select') { renderContentState(el.value); return; }
};
@@ -482,10 +639,20 @@ def build():
sidebar_html = render(A.sidebar)
hero_html = render(A.hero)
+ # Per-tab extra markup appended after the hero subtitle text (e.g. the Growth tab's
+ # "what does growth measure" info popover) — switchTab() re-appends this on tab change
+ # since setting textContent wipes any previously-injected popover markup.
+ tab_hero_info_html = {key: render(A.info_popover(f"hero-{key}", info))
+ for key, info in A.TAB_HERO_INFO.items()}
cal_states, cal_year, cal_default_month = precompute_calendar_states()
fb_states = precompute_fb_states()
+ fb_delta_states = precompute_fb_delta_states()
+ fb_count_only_states = precompute_fb_count_only_states()
vb_states = precompute_vb_states()
+ frr_states = precompute_frr_states()
+ _frr_opts = A._frr_month_options(A.D["frr"])
+ frr_default_period = _frr_opts[0]["value"] if _frr_opts else None
content_states = precompute_content_states()
with open(os.path.join(ASSETS_DIR, "custom.css")) as f:
@@ -500,11 +667,16 @@ def build():
js = JS_TEMPLATE % {
"charts": json.dumps(CHARTS),
"tab_hero": json.dumps(A.TAB_HERO),
+ "tab_hero_info": json.dumps(tab_hero_info_html),
"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),
+ "fb_delta_states": json.dumps(fb_delta_states),
+ "fb_count_only_states": json.dumps(fb_count_only_states),
"vb_states": json.dumps(vb_states),
+ "frr_states": json.dumps(frr_states),
+ "frr_default_period": json.dumps(frr_default_period),
"content_states": json.dumps(content_states),
}
@@ -512,7 +684,7 @@ def build():
-TXproducts LinkedIn Analytics — Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})
+TXproducts LinkedIn Analytics, Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})
@@ -538,7 +710,7 @@ details[open] > .static-accordion-title:before {{ content:'\\25BE '; }}
-
Static snapshot as of {A.D['last_updated'].strftime('%B %d, %Y')} — dropdowns, month clicks,
+
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}
diff --git a/visitor_history.py b/visitor_history.py
new file mode 100644
index 0000000..e5f0aea
--- /dev/null
+++ b/visitor_history.py
@@ -0,0 +1,98 @@
+"""Persists every weekly Visitors export's category breakdown (Location/Seniority/Company
+size) as an append-only observation log, so a location that drops out of one week's export
+(LinkedIn only shows a category if it clears a minimum-visits threshold that week) isn't lost
+just because a later export didn't happen to include it.
+
+Each observation is one (file date, category type, category value, count) reading. Nothing
+is ever overwritten — a category's running total "as of" any given month is the sum of every
+observation for it up to that date. This is a running additive tally, not a deduplicated
+unique-visitor count: LinkedIn's per-export numbers are a rolling window (not a fresh daily
+count), so consecutive weekly exports likely overlap in which underlying visits they're
+counting. Treat the totals here as a cumulative activity signal, not a precise headcount.
+
+Zero import-time dependency on app.py, same reason growth.py has none: app.py runs as
+"__main__", so `import app` here would re-execute it as a second module copy still mid-import
+when these functions get called.
+"""
+import json
+import os
+
+import pandas as pd
+
+_SHEET_TYPES = {
+ "location": "Location",
+ "seniority": "Seniority",
+ "company_size": "Company size",
+}
+
+
+def _history_path(upload_dir):
+ return os.path.join(upload_dir, "visitor_category_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 backfill_history(upload_dir, visitor_files):
+ """Scans every visitor export file and appends any observation not already recorded
+ (keyed by exact file date + category type + category value, so re-running this on
+ every app refresh never double-counts the same file twice)."""
+ history = load_history(upload_dir)
+ seen_keys = {(h["file_date"], h["type"], h["category"]) for h in history}
+ added = 0
+
+ for fp in visitor_files:
+ file_date = pd.Timestamp(os.path.getmtime(fp), unit="s").strftime("%Y-%m-%d")
+ for type_key, sheet_name in _SHEET_TYPES.items():
+ try:
+ df = pd.read_excel(fp, sheet_name=sheet_name)
+ except ValueError:
+ continue # sheet missing from this particular export, skip it
+ col = df.columns[0]
+ for _, row in df.iterrows():
+ category = str(row[col])
+ key = (file_date, type_key, category)
+ if key in seen_keys:
+ continue
+ history.append({
+ "file_date": file_date,
+ "type": type_key,
+ "category": category,
+ "count": int(row["Total views"]),
+ })
+ seen_keys.add(key)
+ added += 1
+
+ if added:
+ _save_history(upload_dir, history)
+ return history
+
+
+def rollup_as_of(history, type_key, on_or_before=None):
+ """Running additive total per category for one breakdown type, summed across every
+ observation on or before `on_or_before` (a 'YYYY-MM-DD' string or None for everything)."""
+ rows = [h for h in history if h["type"] == type_key]
+ if on_or_before is not None:
+ rows = [h for h in rows if h["file_date"] <= on_or_before]
+ if not rows:
+ return pd.DataFrame(columns=["Category", "Total views"])
+ df = pd.DataFrame(rows)
+ out = df.groupby("category", as_index=False)["count"].sum()
+ out.columns = ["Category", "Total views"]
+ return out.sort_values("Total views", ascending=False).reset_index(drop=True)
+
+
+def available_months(history):
+ """Every (year, month) with at least one observation, sorted oldest first."""
+ months = {tuple(int(p) for p in h["file_date"].split("-")[:2]) for h in history}
+ return sorted(months)