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:
1 parent
f631ba26a0
commit
8a72eeb402
4 files changed
+984
-116
No files matched your search
@@ -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__/
|
||||
|
||||
+199
-27
@@ -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-<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."""
|
||||
bg = None
|
||||
for rule in conditional:
|
||||
@@ -134,13 +134,22 @@ def render(comp):
|
||||
if cid == "report-timeframe-select":
|
||||
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'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>'
|
||||
|
||||
# --- 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'<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 ---
|
||||
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'<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>')
|
||||
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>'
|
||||
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'<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 — '
|
||||
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:
|
||||
_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 = '<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};
|
||||
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 = '<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;
|
||||
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);
|
||||
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 = '<div class="js-plotly-chart" id="vb-breakdown-chart"></div>';
|
||||
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) {
|
||||
@@ -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():
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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"/>
|
||||
<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>
|
||||
@@ -538,7 +710,7 @@ details[open] > .static-accordion-title:before {{ content:'\\25BE '; }}
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<div class="app-shell">
|
||||
{sidebar_html}
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user