Files
LinkedIn-Analytics-Dashboard/growth.py
T
beantownbabe 348fe65b88 Initial commit: TXproducts LinkedIn Analytics Dashboard
Dash app with Overview, Content, Followers, Visitors, Competitors, and
Growth tabs. Includes weekly-pace tracking, PDF report generation, data
upload with live refresh, and a static HTML export builder for sharing
via NextCloud.
2026-07-21 15:14:28 +02:00

194 lines
10 KiB
Python

"""Tracks follower-growth history over time: an append-only-per-day log that powers the
sidebar Growth Statistics indicator, the Growth tab's historical table/chart, and the PDF
report's weekly-pace section. Persisted as JSON in UPLOAD_DIR so it survives restarts and
uploads on any team member's machine, not just the original author's.
Pace is judged against a rolling personal best rather than a fixed percentage: the current
week's growth-to-date is compared against the best of the last ROLLING_BEST_WEEKS COMPLETED
weeks (never against earlier days within the current week itself). Weeks are real Monday-
Sunday calendar weeks, not a daily trailing window and not a 7-day block anchored to
whichever day-of-week the data happens to start on — an earlier version compared each day's
trailing-7-day rate against the best trailing rate from the past N weeks, which could
include days from later in the SAME displayed week, so a week could get flagged "Behind" due
to its own mid-week peak rather than genuinely falling short of prior weeks. This rises
automatically as performance improves, but a one-off spike ages out of the window after
ROLLING_BEST_WEEKS instead of setting an unreachable target forever.
Zero import-time dependency on app.py, for the same reason report.py has none: app.py runs
as "__main__" (`python3 app.py`), so `import app` here would re-execute it as a second
module copy that's still mid-import when these functions get called.
"""
import datetime
import json
import os
import pandas as pd
ROLLING_BEST_WEEKS = 4
def _history_path(upload_dir):
return os.path.join(upload_dir, "growth_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 _followers_as_of(fts_df, on_or_before):
"""Cumulative Followers value from the most recent row on or before `on_or_before`;
falls back to the earliest available row if the whole series starts later."""
prior = fts_df[fts_df["Date"] <= on_or_before]
if prior.empty:
return float(fts_df["Cumulative Followers"].iloc[0])
return float(prior["Cumulative Followers"].iloc[-1])
def _week_start_for(date):
"""Monday of the standard calendar week `date` falls into — real Monday-Sunday weeks,
not a 7-day block anchored to whatever day-of-week the data happens to start on (which
produced Tuesday-Monday "weeks" here, since the tracked history starts on a Tuesday)."""
return date - pd.Timedelta(days=date.weekday())
def compute_snapshot(fts_df, prior_history, week_boundary_date=None):
"""Computes (but does not persist) a growth snapshot as of the last date in fts_df,
given the history entries recorded so far (used for since_last_pct and the rolling-best
pace target). Taking the prior history as a plain list — rather than reading it from
disk — lets backfill_history() replay this against progressively larger slices of
fts_df without hitting disk each time.
`week_boundary_date` only affects which past weeks are eligible for the rolling-best
lookback below — it does NOT move week-to-date's own week, which always stays the
actual last date in fts_df (so a week with zero new data yet never gets falsely reset
to a hypothetical 0%). Defaults to this entry's own week, which reproduces the original
behavior of excluding the current week from its own target pool. backfill_history()
passes today's real date for the latest entry only, so a week that's actually finished
in real life (e.g. LinkedIn's export lagging a day behind on Mondays) immediately
becomes eligible as a rolling-best candidate, instead of waiting for next week's data
to start arriving before it's ever counted."""
fts_df = fts_df.sort_values("Date")
today = fts_df["Date"].iloc[-1]
total_followers = float(fts_df["Cumulative Followers"].iloc[-1])
# Trailing 7-day rate: a smoother, purely informational "how's it going right now"
# figure. Not used for the pace judgment (see week_to_date_pct below) since a trailing
# window updates every day and doesn't respect week boundaries.
week_ago = today - pd.Timedelta(days=7)
followers_week_ago = _followers_as_of(fts_df, week_ago)
weekly_pct = ((total_followers - followers_week_ago) / followers_week_ago * 100
if followers_week_ago else None)
weekly_pct = round(weekly_pct, 2) if weekly_pct is not None else None
if prior_history:
prev_total = prior_history[-1]["total_followers"]
since_last_pct = (total_followers - prev_total) / prev_total * 100 if prev_total else None
else:
since_last_pct = None
# Discrete week-over-week pace: growth since the start of THIS week (always the real
# last-data week — never moved by week_boundary_date), compared against the best of the
# last ROLLING_BEST_WEEKS COMPLETED weeks.
current_week_start = _week_start_for(today)
week_start_baseline = _followers_as_of(fts_df, current_week_start - pd.Timedelta(days=1))
week_to_date_pct = ((total_followers - week_start_baseline) / week_start_baseline * 100
if week_start_baseline else 0.0)
week_to_date_pct = round(week_to_date_pct, 2)
# Which past weeks are eligible for the rolling-best pool: strictly before the lookback
# boundary, which defaults to this entry's own week (excluding it from its own target,
# since an ONGOING week can't fairly be judged against its own still-accumulating
# total) but can be pushed forward to the real current date for the live/latest entry —
# at that point this week is no longer "ongoing" in real life even if fresh data for it
# hasn't arrived, so it's fair game as a candidate for whatever comes next.
lookback_boundary_week_start = (_week_start_for(week_boundary_date)
if week_boundary_date is not None else current_week_start)
lookback_start = lookback_boundary_week_start - pd.Timedelta(weeks=ROLLING_BEST_WEEKS)
last_entry_per_week = {}
for h in prior_history:
h_week_start = h.get("week_start")
if h_week_start is None or not (lookback_start <= pd.Timestamp(h_week_start) < lookback_boundary_week_start):
continue
existing = last_entry_per_week.get(h_week_start)
if existing is None or h["date"] > existing["date"]:
last_entry_per_week[h_week_start] = h
completed_week_rates = [h["week_to_date_pct"] for h in last_entry_per_week.values()]
rolling_best_weekly_pct = round(max(completed_week_rates), 2) if completed_week_rates else None
on_track = (None if rolling_best_weekly_pct is None
else week_to_date_pct >= rolling_best_weekly_pct)
return {
"date": today.strftime("%Y-%m-%d"),
"total_followers": int(total_followers),
"weekly_pct": weekly_pct,
"since_last_pct": round(since_last_pct, 2) if since_last_pct is not None else None,
"week_start": current_week_start.strftime("%Y-%m-%d"),
"week_to_date_pct": week_to_date_pct,
"rolling_best_weekly_pct": rolling_best_weekly_pct,
"rolling_best_weeks": ROLLING_BEST_WEEKS,
"on_track": on_track,
}
def last_two_completed_weeks(history):
"""The most recent COMPLETED week's final entry, and the one before it — both full
Monday-Sunday weeks. Used for an apples-to-apples week-over-week comparison, as opposed
to comparing a full week against the current week, which is always partial until the
next Monday's upload.
"Completed" is judged against the real calendar (today's actual date), not the last date
present in the export — LinkedIn's export routinely lags a day or two behind (e.g. it may
stop at Saturday), which previously made an already-finished week look "still in
progress" and made the sidebar fall back to showing the week before it."""
if not history:
return None, None
real_today = pd.Timestamp(datetime.date.today())
current_week_start = (real_today - pd.Timedelta(days=real_today.weekday())).strftime("%Y-%m-%d")
last_entry_per_week = {}
for h in history:
if h["week_start"] >= current_week_start:
continue
existing = last_entry_per_week.get(h["week_start"])
if existing is None or h["date"] > existing["date"]:
last_entry_per_week[h["week_start"]] = h
completed_weeks = sorted(last_entry_per_week.values(), key=lambda h: h["week_start"], reverse=True)
last_week = completed_weeks[0] if len(completed_weeks) >= 1 else None
prior_week = completed_weeks[1] if len(completed_weeks) >= 2 else None
return last_week, prior_week
def backfill_history(fts_df, upload_dir):
"""Ensures the growth-history log has one entry per day already present in fts_df —
backfills any days from before this feature existed (each computed using only the data
that would have been available up to that day, so backfilled entries are historically
accurate, not hindsight-adjusted), while always recomputing the MOST RECENT day fresh so
a same-day re-upload/correction updates that day's entry instead of reusing a stale one.
Returns the full merged history list, sorted chronologically."""
existing = {e["date"]: e for e in load_history(upload_dir)}
fts_sorted = fts_df.sort_values("Date")
dates = list(fts_sorted["Date"])
real_today = pd.Timestamp(datetime.date.today())
history = []
for i, d in enumerate(dates):
date_str = d.strftime("%Y-%m-%d")
is_latest = (i == len(dates) - 1)
if date_str in existing and not is_latest:
history.append(existing[date_str])
continue
subset = fts_sorted[fts_sorted["Date"] <= d]
# Only the latest entry gets the real-calendar boundary — backfilled days stay
# reproducible, using just their own date as before.
boundary = max(d, real_today) if is_latest else None
history.append(compute_snapshot(subset, history, week_boundary_date=boundary))
_save_history(upload_dir, history)
return history