Files
beantownbabeandClaude Sonnet 5 8a72eeb402 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>
2026-07-31 08:49:19 +02:00

99 lines
3.9 KiB
Python

"""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)