Fix calendar cumulative total bug; make months clickable with a year dropdown

Bug: _team_fts_like truncated to >= TEAM_BASELINE_DATE (June 1) before computing
the cumulative running total, which silently dropped a real +1 gain from May 30
(one day before baseline). Two visible symptoms Mckinley caught directly: every
day's running total was permanently 1 lower than the real current follower
count (e.g. Oct 30 showed 20, not the real 21), and May disappeared from the
calendar entirely instead of showing its real "+1". Fixed by anchoring the
cumulative sum at the export's own true window start instead (confirmed
correct - summing every daily gain across the whole file exactly equals the
reported current total). TEAM_BASELINE_DATE remains intentionally separate,
used only for the Starting/New Followers KPI tiles (when this work started,
not literally the first follower ever).

Also made the calendar fully interactive: a year dropdown (ready for 2027) and
clickable month boxes, via one callback registered once and driven by pattern-
matching ids + State("team-member-select") rather than needing per-person
wiring. Guards against Dash's known quirk where a newly-mounted pattern-matched
button fires the callback once on its own with n_clicks still 0 - checks the
triggered input's actual value, not just which id fired, so a fresh render
doesn't spuriously jump to the wrong month.

Also dropped the Total Page Views / Total Clicks KPI tiles entirely (per
Mckinley) - neither exists in any personal-profile export, so a permanent "—"
placeholder for either was never going to show real data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-30 14:14:21 +01:00
1 parent 0335e17e89
commit 25d1cba05f
1 file changed
+77 -27
+77 -27
View File
@@ -3,7 +3,7 @@ import warnings; warnings.filterwarnings("ignore")
import os
from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update
from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update, ALL
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
import plotly.express as px
@@ -2771,14 +2771,21 @@ def _team_mark_posts(fig, engagement_df, top_posts_df):
def _team_fts_like(followers_daily_df):
"""Renamed/shaped to match the Page's own fts_df (Date/Total followers/Cumulative
Followers), filtered to >= TEAM_BASELINE_DATE the same way app.load() filters the
Page's own fts to >= BASELINE_DATE - so the shared calendar/mark_posts helpers can
be reused directly, and the calendar/chart only show the post-baseline window the
same way the Overview tab's own do, not the export's full ~365-day span."""
baseline_ts = pd.Timestamp(team_exports.TEAM_BASELINE_DATE)
Followers) so the shared calendar/mark_posts helpers can be reused directly.
Deliberately NOT truncated to >= TEAM_BASELINE_DATE the way an earlier version
did - that cut off a real +1 gain on May 30 (one day before the June 1 baseline),
which quietly: (a) made every single day's running total afterward permanently
off by 1 versus the real current follower count, and (b) made May disappear from
the calendar entirely instead of showing its real "+1". Cumulative Followers is
anchored at 0 from the export's own true window start instead - confirmed correct
because summing every daily gain across the whole file exactly equals the
reported current total, so this cumulative series always reconciles with the
Total Followers KPI tile. TEAM_BASELINE_DATE/TEAM_BASELINE_FOLLOWERS remain a
separate, intentionally different fact used only for the Starting/New Followers
KPI tiles (when "this work" started, not literally the first follower ever)."""
df = followers_daily_df.rename(columns={"New followers": "Total followers"}).copy()
df = df[df["Date"] >= baseline_ts].reset_index(drop=True)
df["Cumulative Followers"] = team_exports.TEAM_BASELINE_FOLLOWERS + df["Total followers"].cumsum()
df["Cumulative Followers"] = df["Total followers"].cumsum()
return df
@@ -2796,31 +2803,78 @@ def _team_posts_like(top_posts_df):
def _team_calendar_grid(followers_daily_df, top_posts_df):
"""Same month-box grid + day-tile row as the Overview/Followers tabs' "Follower
Gains — Calendar", always showing the default (most recent) month's day tiles
rather than needing a click - clicking a different month box would need its own
per-person callback wiring (Dash callbacks are registered once at app-build time,
not per dropdown selection), so month switching isn't interactive here yet."""
Gains — Calendar", now actually interactive: a year dropdown (ready for 2027 as
more data comes in) and clickable month boxes, both wired to _update_team_calendar
below via pattern-matching ids - a single callback registered once at app-build
time handles whichever team member happens to be selected, rather than needing
per-person callback registration."""
fts_like = _team_fts_like(followers_daily_df)
posts_like = _team_posts_like(top_posts_df)
years = _available_years(fts_like, posts_like)
if not years:
return html.Div("No follower data.", className="text-muted")
year = years[0]
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(fts_like, posts_like, year)
default_month = _default_active_month(fts_like, posts_like, year)
boxes = [html.Div(_month_box_children(monthly_net, monthly_posts, months_with_data, m),
style=_month_box_style(monthly_net, months_with_data, max_gain, m,
selected=(m == default_month)))
for m in range(1, 13)]
return html.Div([
html.Div(str(year), style={"fontWeight": "800", "fontSize": "0.85rem", "marginBottom": "4px"}),
html.Div(boxes, style={"display": "flex", "gap": "6px"}),
html.Div(_month_day_tiles(fts_like, posts_like, year, default_month),
style={"marginTop": "8px"}),
dcc.Dropdown(
id="team-calendar-year-select",
options=[{"label": str(y), "value": y} for y in years],
value=years[0], clearable=False, searchable=False,
className="compact-dropdown",
style={"width": "100px", "marginBottom": "6px", "fontWeight": "800", "fontSize": "0.85rem"},
),
html.Div(id="team-month-boxes-area"),
html.Div(id="team-day-tiles-area", style={"marginTop": "8px"}),
calendar_legend(),
])
@app.callback(
Output("team-month-boxes-area", "children"),
Output("team-day-tiles-area", "children"),
Input("team-calendar-year-select", "value"),
Input({"type": "team-month-btn", "month": ALL}, "n_clicks"),
State("team-member-select", "value"),
)
def _update_team_calendar(year, _month_clicks, name):
"""Fires once automatically when the calendar first mounts (the year dropdown is
freshly created each time a person is selected, and Dash fires a callback for a
newly-mounted Input regardless of prevent_initial_call) - that initial fire is
what populates the month boxes and default day tiles, no separate setup needed.
A newly-mounted month button also counts as "appearing" the same way, with
n_clicks still at 0 - checking ctx.triggered's actual value (not just which id
triggered) tells a real click apart from that spurious re-fire, so a fresh
person/year selection doesn't accidentally jump to whichever month button
happens to mount last."""
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data:
raise PreventUpdate
fts_like = _team_fts_like(data["followers_daily"])
posts_like = _team_posts_like(data["top_posts"])
if year is None:
years = _available_years(fts_like, posts_like)
if not years:
raise PreventUpdate
year = years[0]
triggered_id = ctx.triggered_id
triggered_value = ctx.triggered[0]["value"] if ctx.triggered else None
if isinstance(triggered_id, dict) and triggered_id.get("type") == "team-month-btn" and triggered_value:
month = triggered_id["month"]
else:
month = _default_active_month(fts_like, posts_like, year)
monthly_net, monthly_posts, months_with_data, max_gain = _monthly_summary(fts_like, posts_like, year)
boxes = [html.Button(_month_box_children(monthly_net, monthly_posts, months_with_data, m),
id={"type": "team-month-btn", "month": m}, n_clicks=0,
style=_month_box_style(monthly_net, months_with_data, max_gain, m,
selected=(m == month)))
for m in range(1, 13)]
boxes_div = html.Div(boxes, style={"display": "flex", "gap": "6px"})
tiles = _month_day_tiles(fts_like, posts_like, year, month)
return boxes_div, tiles
def _team_followers_figure(followers_daily_df, top_posts_df):
fts_like = _team_fts_like(followers_daily_df)
posts_like = _team_posts_like(top_posts_df)
@@ -2932,8 +2986,6 @@ def _build_person_dashboard(name, data):
kpi("Total Followers", f"{data['followers_total']:,}", f"as of {window_end}", TEAL),
kpi("New Followers", f"{new_followers_window:,}", f"{baseline_label} – {window_end}"),
kpi("Total Impressions", f"{discovery.get('Impressions', 0):,}", window_label),
kpi("Total Page Views", "—", "not available for personal profiles"),
kpi("Total Clicks", "—", "not available for personal profiles"),
kpi("Best Post Real Eng.", f"{best_eng_pct:.2f}%" if best_eng_pct is not None else "—",
f"across {rated_post_count} posts", GREEN),
kpi("Avg. Eng. Rate", f"{avg_eng_pct:.2f}%" if avg_eng_pct is not None else "—",
@@ -2985,8 +3037,6 @@ def _build_empty_person_dashboard(name):
kpi("Total Followers", "—", "", TEAL),
kpi("New Followers", "—", ""),
kpi("Total Impressions", "—", ""),
kpi("Total Page Views", "—", "not available for personal profiles"),
kpi("Total Clicks", "—", "not available for personal profiles"),
kpi("Best Post Real Eng.", "—", "", GREEN),
kpi("Avg. Eng. Rate", "—", "", TEAL),
])