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
|
*.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__/
|
||||||
|
|||||||
+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
|
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 — '
|
||||||
|
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}
|
||||||
|
|||||||
@@ -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