Add Follower Directory/Study hero links, fix country-map centroid gaps, and sync accumulated dashboard work

- Hero banner now shows Follower Directory / Follower Study links on the
  Followers tab only (not the sidebar), opening the standalone reports served
  from assets/.
- Followers by Country: _COUNTRY_CENTROIDS expanded from 14 hardcoded entries
  to a near-complete world table, so a country missing from the old dict no
  longer vanishes from the map silently; added a console warning for any
  country still missing a centroid, and a "N countries represented" caption
  computed from the same rollup the map uses so the two can never disagree.
- Added visitor_history.py: append-only observation log for the Visitors
  export's category breakdowns (Location/Seniority/Company size), since
  LinkedIn only shows a category if it clears a per-export minimum-visits
  threshold and otherwise silently drops it.
- Synced other accumulated app.py/build_static_export.py work not yet
  committed: calendar month drill-down tiles, follower-breakdown expand
  toggle, CTR explainer, manual post title overrides, and matching static-
  export precompute/JS for the calendar and content tabs.
- .gitignore: exclude the newer generated snapshot outputs (follower study/
  directory HTML copies, PDF report, visitor_category_history.json) using
  the same rationale as the existing growth_history.json/snapshot rules —
  regenerable from code + data, not itself code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-07-31 08:49:19 +02:00
1 parent f631ba26a0
commit 8a72eeb402
4 files changed
+984 -116

No files matched your search

+5
View File
@@ -2,9 +2,14 @@
*.xls *.xls
*.xlsx *.xlsx
growth_history.json growth_history.json
visitor_category_history.json
# Generated output — regenerate locally with build_static_export.py, not shared code # Generated output — regenerate locally with build_static_export.py, not shared code
TXproducts_Dashboard_Snapshot.html TXproducts_Dashboard_Snapshot.html
TXproducts_Follower_Study_*.html
LinkedIn_Dashboard_Daily_Report_*.pdf
assets/follower_directory.html
assets/follower_study.html
# Python # Python
__pycache__/ __pycache__/
+682 -89
View File
File diff suppressed because it is too large. Load diff
+199 -27
View File
@@ -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 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 embedded JSON. A handful of interactive elements (calendar month click, breakdown
dropdowns, content month filter) have every possible state precomputed and swapped 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 base64
import html as html_escape_mod import html as html_escape_mod
@@ -60,11 +60,11 @@ def style_dict_to_css(style):
def conditional_row_bg(conditional, row, idx): def conditional_row_bg(conditional, row, idx):
"""Replays a DataTable's style_data_conditional row_index/filter_query rules for one """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. 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 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-<td>, codebase, not the full Dash query mini-language. Caller applies the result per-<td>,
not on the <tr> — Bootstrap 5 paints table backgrounds via a CSS variable applied not on the <tr>, 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.""" directly to each cell, which sits on top of and hides a tr-level background-color."""
bg = None bg = None
for rule in conditional: for rule in conditional:
@@ -134,13 +134,22 @@ def render(comp):
if cid == "report-timeframe-select": if cid == "report-timeframe-select":
return (f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" ' return (f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" '
f'style="{style};opacity:0.5;cursor:not-allowed" disabled ' f'style="{style};opacity:0.5;cursor:not-allowed" disabled '
f'title="PDF generation needs the live tool — not available in this snapshot.">{opts}</select>') f'title="PDF generation needs the live tool, not available in this snapshot.">{opts}</select>')
return f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" style="{style}" onchange="window.__onSelect && window.__onSelect(this)">{opts}</select>' return f'<select id="{esc(cid)}" class="form-select compact-dropdown {esc(extra_class)}" style="{style}" onchange="window.__onSelect && window.__onSelect(this)">{opts}</select>'
# --- Stores/Download: no static equivalent needed (Download requires a live backend) --- # --- Stores/Download: no static equivalent needed (Download requires a live backend) ---
if isinstance(comp, (dcc.Store, dcc.Download)): if isinstance(comp, (dcc.Store, dcc.Download)):
return "" 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'<div id="{esc(cid)}" style="{style}" '
f'title="Uploading new data needs the live tool (requires a Python backend), not available in this snapshot.">'
f'{render(getattr(comp, "children", None))}</div>')
# --- DataTable: render a plain HTML table from data/columns --- # --- DataTable: render a plain HTML table from data/columns ---
if isinstance(comp, dash_table.DataTable): if isinstance(comp, dash_table.DataTable):
cols = comp.columns 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()}, disabled_style = style_dict_to_css({**{k: v for k, v in (getattr(comp, "style", None) or {}).items()},
"opacity": "0.5", "cursor": "not-allowed"}) "opacity": "0.5", "cursor": "not-allowed"})
return (f'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled ' return (f'<button type="button"{id_attr}{class_attr} style="{disabled_style}" disabled '
f'title="PDF generation needs the live tool (requires a Python backend) — not available in this snapshot.">' f'title="PDF generation needs the live tool (requires a Python backend), not available in this snapshot.">'
f'{children_html}</button>') f'{children_html}</button>')
onclick = f' onclick="window.__onClick && window.__onClick(this)"' onclick = f' onclick="window.__onClick && window.__onClick(this)"'
return f'<button type="button"{id_attr}{class_attr} style="{style}"{onclick}{title_attr}>{children_html}</button>' return f'<button type="button"{id_attr}{class_attr} style="{style}"{onclick}{title_attr}>{children_html}</button>'
if cid.startswith("info-"): if cid.startswith("info-"):
onclick = f' onclick="window.__onClick && window.__onClick(this)"' onclick = f' onclick="window.__onClick && window.__onClick(this)"'
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}{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}>' 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)) return render(getattr(comp, "children", None))
@@ -250,7 +262,7 @@ def fig_json(fig):
def precompute_calendar_states(): def precompute_calendar_states():
"""Every month's tile-row + daily graph (+ Overview's 4 metric charts), for both """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 = {} states = {}
year = int(A.D["fts"]["Date"].dt.year.max()) year = int(A.D["fts"]["Date"].dt.year.max())
for prefix in ("overview", "followers"): for prefix in ("overview", "followers"):
@@ -275,33 +287,109 @@ def precompute_calendar_states():
def precompute_fb_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 = {} states = {}
months = sorted(A.D["breakdown_history"]) + [None] # None = no month context (fallback/current)
for key, _label in A.FOLLOWER_BREAKDOWNS: for key, _label in A.FOLLOWER_BREAKDOWNS:
for expanded in (False, True): 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) fig = A._std(fig)
needs_toggle = total_n > 10 month_label = f"{A._MONTH_NAMES[month - 1]} {year}: {changed_n} categories gained or lost followers vs the prior month"
btn_label = "Show top 10" if expanded else f"Show all {total_n}" if overflow > 0:
states[f"{key}|{str(expanded).lower()}"] = { month_label += f" · showing top {A._DELTA_MONTH_CAP}, +{overflow} more with smaller changes"
states[f"{key}|{year}-{month}"] = {
"fig": fig_json(fig), "fig": fig_json(fig),
"btnLabel": btn_label, "monthLabel": month_label,
"btnDisplay": "inline-block" if needs_toggle else "none",
} }
return states return states
def precompute_vb_states(): def precompute_fb_count_only_states():
"""Visitors-By dropdown 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 = {} 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'<div style="padding:20px 4px;">'
f'<div style="margin-bottom:8px;font-weight:700;color:#1a2533;">'
f'Net {esc(sign)}{net} follower{"s" if abs(net) != 1 else ""} in '
f'{A._MONTH_NAMES[month - 1]} {year}: {esc(detail)}.</div>'
f'<div style="color:#6b7a8d;font-size:0.8rem;">'
f'Category breakdown (location, industry, etc.) isn\'t available for these &mdash; '
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.</div>'
f'</div>'
)
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: for key, _label in A.VISITOR_BREAKDOWNS:
_lbl, fig = A._visitor_breakdown_figure(key) for expanded in (False, True):
states[key] = {"fig": fig_json(A._std(fig))} 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 return states
def precompute_content_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 = {} states = {}
for opt in A._content_month_options(): for opt in A._content_month_options():
value = opt["value"] value = opt["value"]
@@ -337,16 +425,26 @@ def precompute_content_states():
JS_TEMPLATE = r""" JS_TEMPLATE = r"""
const CHARTS = %(charts)s; const CHARTS = %(charts)s;
const TAB_HERO = %(tab_hero)s; const TAB_HERO = %(tab_hero)s;
const TAB_HERO_INFO = %(tab_hero_info)s;
const CAL_STATES = %(cal_states)s; const CAL_STATES = %(cal_states)s;
const CAL_YEAR = %(cal_year)s; const CAL_YEAR = %(cal_year)s;
const CAL_DEFAULT_MONTH = %(cal_default_month)s; const CAL_DEFAULT_MONTH = %(cal_default_month)s;
const FB_STATES = %(fb_states)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 VB_STATES = %(vb_states)s;
const CONTENT_STATES = %(content_states)s; const CONTENT_STATES = %(content_states)s;
const FRR_STATES = %(frr_states)s;
const DELTA_LEGEND_HTML = '<div style="margin-top:10px;display:flex;flex-wrap:wrap;align-items:center;">'
+ '<span style="margin-right:16px;white-space:nowrap;"><span style="width:12px;height:12px;background-color:#52C41A;display:inline-block;margin-right:5px;vertical-align:middle;border-radius:2px;"></span><span style="font-size:0.72rem;color:#6b7a8d;">Gained</span></span>'
+ '<span style="margin-right:16px;white-space:nowrap;"><span style="width:12px;height:12px;background-color:#d43f3a;display:inline-block;margin-right:5px;vertical-align:middle;border-radius:2px;"></span><span style="font-size:0.72rem;color:#6b7a8d;">Lost</span></span>'
+ '</div>';
const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH}; const calMonth = {overview: CAL_DEFAULT_MONTH, followers: CAL_DEFAULT_MONTH};
let fbActive = 'company_size', fbExpanded = false; let fbActive = 'company_size', fbExpanded = false, fbPeriod = 'total';
let vbActive = 'seniority'; let vbActive = 'seniority', vbExpanded = false, vbPeriod = 'total';
let frrPeriod = %(frr_default_period)s;
function renderChart(divId, figSpec) { function renderChart(divId, figSpec) {
if (!figSpec || !document.getElementById(divId)) return; if (!figSpec || !document.getElementById(divId)) return;
@@ -378,7 +476,10 @@ function switchTab(key) {
var hero = TAB_HERO[key]; var hero = TAB_HERO[key];
if (hero) { if (hero) {
document.getElementById('hero-title').textContent = hero[0]; 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')); window.dispatchEvent(new Event('resize'));
} }
@@ -397,12 +498,41 @@ function selectCalendarMonth(prefix, month) {
if (state.metrics) { if (state.metrics) {
Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); }); Object.keys(state.metrics).forEach(function (divId) { renderChart(divId, state.metrics[divId]); });
} }
if (prefix === 'followers') renderFollowersBreakdown();
} }
function 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 = '<div class="js-plotly-chart" id="fb-breakdown-chart"></div>' + 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; if (!s) return;
var totalArea = document.getElementById('followers-breakdown-area');
if (totalArea) totalArea.innerHTML = '<div class="js-plotly-chart" id="fb-breakdown-chart"></div>';
renderChartWithin('followers-breakdown-area', s.fig); 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'); var btn = document.getElementById('fb-expand-btn');
if (btn) { if (btn) {
btn.textContent = s.btnLabel; btn.textContent = s.btnLabel;
@@ -411,9 +541,28 @@ function renderFollowersBreakdown() {
} }
function renderVisitorBreakdown() { function renderVisitorBreakdown() {
var s = VB_STATES[vbActive]; var s = VB_STATES[vbActive + '|' + vbExpanded + '|' + vbPeriod];
if (!s) return; if (!s) return;
var area = document.getElementById('visitor-breakdown-area');
if (area) area.innerHTML = '<div class="js-plotly-chart" id="vb-breakdown-chart"></div>';
renderChartWithin('visitor-breakdown-area', s.fig); 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 = '<div class="js-plotly-chart" id="frr-chart"></div>';
}
renderChartWithin('frr-area', s.fig);
} }
function renderContentState(value) { function renderContentState(value) {
@@ -459,11 +608,19 @@ window.__onClick = function (el) {
renderFollowersBreakdown(); renderFollowersBreakdown();
return; return;
} }
if (id === 'vb-expand-btn') {
vbExpanded = !vbExpanded;
renderVisitorBreakdown();
return;
}
}; };
window.__onSelect = function (el) { window.__onSelect = function (el) {
if (el.id === 'fb-select') { fbActive = el.value; fbExpanded = false; renderFollowersBreakdown(); return; } 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; } if (el.id === 'content-month-select') { renderContentState(el.value); return; }
}; };
@@ -482,10 +639,20 @@ def build():
sidebar_html = render(A.sidebar) sidebar_html = render(A.sidebar)
hero_html = render(A.hero) 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() cal_states, cal_year, cal_default_month = precompute_calendar_states()
fb_states = precompute_fb_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() 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() content_states = precompute_content_states()
with open(os.path.join(ASSETS_DIR, "custom.css")) as f: with open(os.path.join(ASSETS_DIR, "custom.css")) as f:
@@ -500,11 +667,16 @@ def build():
js = JS_TEMPLATE % { js = JS_TEMPLATE % {
"charts": json.dumps(CHARTS), "charts": json.dumps(CHARTS),
"tab_hero": json.dumps(A.TAB_HERO), "tab_hero": json.dumps(A.TAB_HERO),
"tab_hero_info": json.dumps(tab_hero_info_html),
"cal_states": json.dumps(cal_states), "cal_states": json.dumps(cal_states),
"cal_year": json.dumps(cal_year), "cal_year": json.dumps(cal_year),
"cal_default_month": json.dumps(cal_default_month), "cal_default_month": json.dumps(cal_default_month),
"fb_states": json.dumps(fb_states), "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), "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), "content_states": json.dumps(content_states),
} }
@@ -512,7 +684,7 @@ def build():
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>TXproducts LinkedIn Analytics — Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})</title> <title>TXproducts LinkedIn Analytics, Snapshot ({A.D['last_updated'].strftime('%b %d, %Y')})</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script> <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
@@ -538,7 +710,7 @@ details[open] > .static-accordion-title:before {{ content:'\\25BE '; }}
</style> </style>
</head> </head>
<body> <body>
<div class="snapshot-banner">Static snapshot as of {A.D['last_updated'].strftime('%B %d, %Y')} — dropdowns, month clicks, <div class="snapshot-banner">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.</div> and breakdown selectors work, but this file does not update with new data. Open the live tool for current numbers.</div>
<div class="app-shell"> <div class="app-shell">
{sidebar_html} {sidebar_html}
+98
View File
@@ -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)