diff --git a/app.py b/app.py index 7b0e122..c7b880c 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,7 @@ import engagement_audit import theme_tracker import conversation_tracker import team_exports +import visibility_tracker # Overridable via env var so the same code runs unmodified in Docker/internal deployments — # defaults to this machine's path for local dev. @@ -2698,6 +2699,38 @@ def _team_kpi_row(tiles): style={"display": "flex", "gap": "0.75rem", "marginBottom": "1rem"}) +def _team_visibility_row(name): + """Profile Appearances/Views - hand-logged (visibility_tracker.py), not parsed + from the Aggregate Analytics file, since LinkedIn has no export for either. + Shown regardless of whether the person's main export exists yet - visibility + logging and the Monday export are two independent data sources.""" + entry = visibility_tracker.latest_for(name) + if not entry: + tiles = [ + kpi("All Appearances", "—", "not logged yet"), + kpi("Search Appearances", "—", "not logged yet"), + kpi("Profile Views", "—", "not logged yet"), + ] + else: + week_label = f"week of {entry['week_start']} – {entry['week_end']}" + + def _pct_note(pct): + if pct is None: + return week_label + arrow = "▲" if pct > 0 else ("▼" if pct < 0 else "–") + return f"{arrow} {abs(pct)}% past 7 days · {week_label}" + + tiles = [ + kpi("All Appearances", f"{entry['all_appearances']:,}", + _pct_note(entry.get("all_appearances_change_pct"))), + kpi("Search Appearances", f"{entry['search_appearances']:,}", + _pct_note(entry.get("search_appearances_change_pct")), ORANGE), + kpi("Profile Views", f"{entry['profile_views']:,}" if entry.get("profile_views") is not None else "—", + week_label if entry.get("profile_views") is not None else "not logged yet", TEAL), + ] + return section("Visibility (hand-logged, no LinkedIn export exists for this)", _team_kpi_row(tiles)) + + def _team_engagement_rate_stats(top_posts_df): """(best_pct, avg_pct, post_count) - engagement rate per post is Engagements / Impressions, the same definition as the Page's own Avg. Eng. Rate tile. None for @@ -2943,10 +2976,11 @@ def _render_team_tab(name): data lands. No code change needed when a person's first export arrives - team_exports.load_person_data() picks it up automatically by filename. The Download Report button is disabled whenever there's nothing real to send yet.""" + visibility_row = _team_visibility_row(name) data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR) if not data: - return _build_empty_person_dashboard(name), True - return _build_person_dashboard(name, data), False + return html.Div([visibility_row, _build_empty_person_dashboard(name)]), True + return html.Div([visibility_row, _build_person_dashboard(name, data)]), False @app.callback( diff --git a/visibility_tracker.py b/visibility_tracker.py new file mode 100644 index 0000000..8e3e74d --- /dev/null +++ b/visibility_tracker.py @@ -0,0 +1,42 @@ +"""Hand-logged Profile Views / Profile Appearances per team member, since LinkedIn +doesn't offer a file export for either (unlike the Aggregate Analytics content file +team_exports.py parses) - both only show live on a person's own profile page, under +"Profile appearances" and "Who's viewed your profile." + +Profile Appearances = how often someone's name/headline showed up across LinkedIn +(search results, others' feeds, comments, People You May Know), split into All +appearances and the Search-appearances subset. Profile Views = actual visits to the +profile page. Add an entry here each Monday alongside that person's export - same +"add an entry, no code change needed" pattern as conversation_tracker.py. +""" + +# { +# "name": "Mckinley Black", +# "week_start": "2026-10-20", +# "week_end": "2026-10-26", +# "all_appearances": 10, +# "all_appearances_change_pct": 67, +# "search_appearances": 1, +# "search_appearances_change_pct": 0, +# "profile_views": None, +# }, +VISIBILITY_LOG = [ + { + "name": "Mckinley Black", + "week_start": "2026-10-20", + "week_end": "2026-10-26", + "all_appearances": 10, + "all_appearances_change_pct": 67, + "search_appearances": 1, + "search_appearances_change_pct": 0, + "profile_views": None, + }, +] + + +def latest_for(name): + """Most recent logged week for a person, by week_end - None if nothing logged yet.""" + entries = [e for e in VISIBILITY_LOG if e["name"] == name] + if not entries: + return None + return max(entries, key=lambda e: e["week_end"])