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:
1 parent
0335e17e89
commit
25d1cba05f
1 file changed
+77
-27
@@ -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),
|
||||
])
|
||||
|
||||
Reference in new issue
Block a user