Build the End of Campaign report (3-month synthesis + Oct recommendations)

The "campaign-end" dropdown option existed but was a placeholder ("not
built yet") in the live tab and explicitly blocked in the download
callback. Built it out properly, same condensed Executive Summary
philosophy as the monthly reports, not a merge of all three:

- report.py: "campaign-end" is now a real timeframe_value throughout
  _filtered/_period_label/compute_period_stats, scoped to the fixed
  campaign window (CAMPAIGN_START/CAMPAIGN_END, 2026-07-01 to
  2026-09-30) rather than "all" (which would pull in pre-campaign
  June data). Its Posts Published excludes the repost and the one
  non-campaign post, unlike a single month's own count, since a
  capstone number should mean genuine campaign content. Monthly
  Growth Rate anchors to the campaign's own start instead of a
  calendar month's.

- monthly_report_notes.py: new NOTES["campaign-end"] executive_summary
  synthesizing all three months — what each stage actually proved
  (Carousel wins, a stable Academia/Research/Germany audience, one
  real trigger found but not repeated), the campaign-wide best-posting
  -day finding, and a new "recommendations" field: concrete, data-
  grounded ideas for October and beyond (bring Carousel back, lead
  with named pain points, move asks into direct outreach using the
  21 already-engaged non-followers sitting in outreach_contacts.py,
  track conversion as its own funnel, properly separate the day/
  format confound next time).

- build_report_docx() renders "recommendations" as a new section when
  present. app.py's download callback no longer blocks campaign-end,
  and the live Reports tab gets a real (if intentionally simple)
  preview via a new _render_campaign_end_summary(), built as its own
  self-contained renderer rather than stretching the 7 per-month
  insights pages (built around a single YYYY-MM, not a 3-month span)
  to fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-01 10:49:47 +02:00
1 parent 50bfb9025f
commit 6722b29fe8
3 files changed
+259 -21

No files matched your search

+109 -13
View File
@@ -4234,6 +4234,110 @@ _INSIGHTS_PAGE_BUILDERS = {
}
def _campaign_end_goal_met_color(val):
v = str(val or "").strip().lower()
if v == "yes":
return GREEN
if v == "no":
return "#d43f3a"
if v == "partial":
return "#B26A00"
return BLUE
def _render_campaign_end_summary():
"""Live-tab mirror of report.build_report_docx()'s campaign-end output — same
compute_period_stats()/executive_summary data, lightweight Dash components
instead of python-docx. Deliberately NOT built on the 7 per-month insights
pages (_roadmap_insights_page etc.): those are written around a single YYYY-MM
month_value (hardcoded per-month content like _JULY_REGISTER_RATINGS, month-
filtered composition_stats), not a 3-month span, so this is a separate,
self-contained renderer instead of trying to stretch them to fit."""
title = html.H3("3-Month Campaign Report (July-September 2026)",
style={"color": "#1b3a5c", "marginBottom": "0", "fontWeight": "700",
"fontSize": "2rem", "whiteSpace": "nowrap"})
stats = report.compute_period_stats(_build_report_ctx(), "campaign-end")
notes = monthly_report_notes.NOTES.get("campaign-end") or {}
exec_summary = notes.get("executive_summary") or {}
fts_f, posts_f = stats["fts_f"], stats["posts_f"]
kpi_row = html.Div([
html.Div(kpi("Campaign Start", f"{stats['start_of_month_followers']:,}" if stats["start_of_month_followers"] is not None else "—"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("New Followers", f"{stats['new_followers']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Campaign End", f"{stats['end_of_month_followers']:,}" if stats["end_of_month_followers"] is not None else "—", "total followers"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Posts Published", f"{stats['posts_published']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Total Impressions", f"{stats['total_impressions']:,}"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Best Post Real Eng.", _fmt_best_eng(stats["best_eng"]), color=GREEN), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Avg. Eng. Rate", f"{stats['avg_eng']:.2f}%" if stats["avg_eng"] else "—", "raw, unaudited"), style={"flex": "1", "minWidth": "0"}),
html.Div(kpi("Campaign Growth", f"{stats['monthly_growth_rate']:+.1f}%" if stats["monthly_growth_rate"] is not None else "—", color=GREEN), style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"})
if not exec_summary:
return title, html.Div([kpi_row, dbc.Alert("Executive Summary not written up yet.", color="info")])
body = [kpi_row]
goal_line = exec_summary.get("goal_line")
if goal_line:
body.append(html.P(goal_line, style={"fontWeight": "600"}))
met_children = [html.Strong("Goal Met: "),
html.Span(str(exec_summary.get("goal_met", "—")),
style={"color": _campaign_end_goal_met_color(exec_summary.get("goal_met")),
"fontWeight": "700"})]
growth_note = exec_summary.get("growth_note")
if growth_note:
met_children += [html.Span(" Growth: ", style={"fontWeight": "700"}), growth_note]
body.append(html.P(met_children))
eng_q = exec_summary.get("engagement_question")
if eng_q:
body.append(html.P(eng_q, style={"fontWeight": "600"}))
findings = exec_summary.get("engagement_findings") or []
if findings:
body.append(html.Ul([html.Li(f) for f in findings]))
trilogy_intro = exec_summary.get("trilogy_intro")
if trilogy_intro:
body.append(html.P(trilogy_intro))
trilogy_posts = exec_summary.get("trilogy_posts") or []
if trilogy_posts:
body.append(html.Ul([html.Li(p) for p in trilogy_posts]))
trilogy_closing = exec_summary.get("trilogy_closing")
if trilogy_closing:
body.append(html.P(trilogy_closing))
best_day_note = exec_summary.get("best_posting_day_note")
if best_day_note:
body.append(html.P(best_day_note))
best_day_caveat = exec_summary.get("best_posting_day_caveat")
if best_day_caveat:
body.append(html.P(html.Em(best_day_caveat)))
validation_gap = exec_summary.get("validation_gap")
if validation_gap:
body.append(html.Ul([html.Li(validation_gap)]))
comp_line = exec_summary.get("audience_composition_line")
if comp_line:
body.append(html.P(comp_line))
for dim_title, dim_text in exec_summary.get("audience_dimensions") or []:
body.append(html.P([html.Strong(f"{dim_title}: "), dim_text]))
recommendations = exec_summary.get("recommendations") or []
if recommendations:
body.append(html.H5("Recommendations for October & Beyond",
style={"marginTop": "18px", "color": "#1b3a5c"}))
body.append(html.Ul([html.Li(r) for r in recommendations]))
if not fts_f.empty:
body.append(html.H5("Follower Growth", style={"marginTop": "18px", "color": "#1b3a5c"}))
fig = daily_followers_figure(fts_f, posts_f)
fig.update_layout(showlegend=True)
body.append(G(fig, style={"height": "300px"}))
return title, html.Div(body, style={"fontSize": "0.9rem"})
@app.callback(
Output("reports-title", "children"),
Output("reports-content-area", "children"),
@@ -4241,23 +4345,17 @@ _INSIGHTS_PAGE_BUILDERS = {
Input("reports-month-select", "value"),
)
def _render_reports_tab(month_value):
# "campaign-end" isn't a real YYYY-MM period (no per-post data behind it), so the
# shared store keeps whatever real month was last selected rather than adopting a
# value none of the insights pages know how to compute against.
# "campaign-end" isn't a real YYYY-MM period, so the shared store (used by the 7
# per-month insights pages) keeps whatever real month was last selected rather
# than adopting a value none of those pages know how to compute against — the
# campaign-end preview below is self-contained and doesn't need the store.
store_value = month_value if month_value and month_value != "campaign-end" else no_update
if not month_value:
return "", dbc.Alert("No campaign months have been written up yet.", color="info"), store_value
if month_value == "campaign-end":
title = html.H3("End of Campaign Report", style={"color": "#1b3a5c", "marginBottom": "0",
"fontWeight": "700", "fontSize": "2rem",
"whiteSpace": "nowrap"})
placeholder = dbc.Alert(
"The End of Campaign report has its own design, not built yet, this isn't just "
"another empty month. Check back once the 3-month campaign wraps up.",
color="info")
return title, placeholder, store_value
return _render_campaign_end_summary() + (store_value,)
stats = report.compute_period_stats(_build_report_ctx(), month_value)
notes = monthly_report_notes.NOTES.get(month_value)
@@ -4481,8 +4579,6 @@ def _render_reports_tab(month_value):
prevent_initial_call=True,
)
def _download_reports_pdf(_n_clicks, month_value):
if month_value == "campaign-end":
return no_update
ctx_data = _build_report_ctx()
ctx_data["monthly_notes"] = monthly_report_notes.NOTES.get(month_value)
docx_bytes = report.build_report_docx(ctx_data, month_value)
+106
View File
@@ -372,4 +372,110 @@ NOTES = {
(" What made people act — reactions, reposts, and new connections.", False),
],
},
"campaign-end": {
"executive_summary": {
"goal_line": "3-Month Campaign: Find the Voice (July) -> Find the "
"Audience (August) -> Find the Trigger (September).",
"goal_met": "Partial",
"growth_note": "Page grew from 85 to 140 followers (+55, +64.7% "
"cumulative) — but the monthly pace decelerated every "
"single month (July +25.9%, August +17.8%, September "
"+11.1%).",
"engagement_question": "What did the campaign actually prove, stage "
"by stage?",
"engagement_findings": [
"Carousel won decisively both times it was tried (09/07: 45.0%, "
"04/08: 50.8%) — then was never tried again in September, the one "
"proven lever sitting unused by month 3.",
"The same audience showed up every month: Academia/Research "
"Institute, Germany-based, Researcher/Scientist-heavy in July, "
"August, and September alike.",
"A real trigger was found once (September's 08/09 pain-point "
"post, the only genuine discussion thread of the whole campaign) "
"but never repeated or turned into a system.",
],
"trilogy_intro": "Three stages, three different kinds of answer:",
"trilogy_posts": [
"July — Find the Voice: a clear tone/format winner (Carousel + "
"personal, emotional-register storytelling, the 14/07-28/07-30/07 "
"trilogy)",
"August — Find the Audience: a clear demographic answer "
"(Academia/Research, Germany, Researcher/Scientist), plus a "
"6-post TXServices product launch arc",
"September — Find the Trigger: one real trigger found (08/09's "
"pain-point post), but not repeated — growth and engagement both "
"decelerated",
],
"trilogy_closing": "Each stage answered its own question, but the "
"campaign never closed the loop — the two things "
"July and August both proved (Carousel format, a "
"receptive technical audience) were never combined "
"with September's one real trigger (naming a "
"concrete pain point) into a single, repeatable "
"post.",
"best_posting_day_note": "Best Posting Day: Tuesday won 2 of 3 months "
"(August, September); July was the only "
"Thursday-best month.",
"best_posting_day_caveat": "Note: every month's day finding is "
"confounded by which format happened to "
"run that day — a pattern flagged in July "
"and never fully resolved across the whole "
"campaign.",
"validation_gap": "Every direct validation attempt underperformed: "
"the 21/07 poll got zero responses, the 17/09 "
"pilot-partner ask got 5.0% — asking this audience "
"directly, by poll or by CTA, never once "
"outperformed organic storytelling or a named "
"pain point.",
"audience_composition_line": "AUDIENCE COMPOSITION (campaign-wide): "
"Academia/Research Institute and "
"Germany dominate in every single month "
"profiled — the most stable finding of "
"the entire campaign.",
"audience_dimensions": [
("Consistency", "The audience itself never changed: Academia/"
"Research Institute, Germany-based, Researcher/Scientist-heavy "
"in July, August, and September alike — whatever comes next can "
"target this cohort with confidence."),
("What Worked", "Carousel format (2 for 2 when tried) and "
"specific, concrete claims (a cost number in August, a named "
"pain point in September) outperformed everything else — vague "
"or feature-list content consistently sat in the 2-8% range."),
("What Didn't", "Direct asks (the poll, the pilot-partner CTA) "
"underperformed every time they were tried, across all 3 "
"months — this audience responds to a story or a stated "
"problem, not a request."),
],
"recommendations": [
"Bring Carousel back as the default format, not an occasional "
"one-off — it won decisively both times it ran (45.0%, 50.8%) "
"and then sat unused for all of September.",
"Lead posts with a named, specific pain point (like 08/09's "
"'materials change under stress') instead of a feature list or "
"a general announcement — it's the only format this audience has "
"actually stopped to discuss, not just liked.",
"Move the 'ask' out of the public post and into direct outreach: "
"every public CTA tried (poll, pilot-partner request) "
"underperformed, but 21 people who engaged with a post without "
"following are already sitting in outreach_contacts.py, "
"unconverted — a warmer, already-interested list to work "
"directly instead of re-trying the same public ask.",
"Treat conversion as a separate funnel from engagement: nothing "
"audited this campaign tracked whether a like/comment ever "
"became a pilot partner or a meeting — October needs its own "
"metric for that (e.g. DM replies, calls booked, pilot sign-ups) "
"since Real Eng % only measures attention, not outcome.",
"Keep testing Tuesday vs. Thursday, but pair it with a *fixed* "
"format next time (e.g. two Carousels, one each day) so the day "
"effect and the format effect can finally be told apart — every "
"month so far has confounded the two.",
"The audience itself hasn't moved in 3 months (Academia/"
"Research, Germany, Researcher/Scientist) — October doesn't need "
"a new audience-discovery push, it needs content built "
"specifically for that cohort's actual pain points, sourced "
"from real conversations (like 08/09's), not assumed from the "
"outside.",
],
},
},
}
+44 -8
View File
@@ -24,9 +24,18 @@ from docx.oxml import OxmlElement
import engagement_audit
# The 3-month branding campaign's fixed window (per "LinkedIn Branding Campaign
# Roadmap.pdf"), used only by the "campaign-end" timeframe below — distinct from
# "all", which would also pull in pre-campaign June data (BASELINE_DATE 2026-06-16).
CAMPAIGN_START = pd.Timestamp("2026-07-01")
CAMPAIGN_END = pd.Timestamp("2026-09-30")
def _filtered(df, date_col, value):
if not value or value == "all":
return df
if value == "campaign-end":
return df[(df[date_col] >= CAMPAIGN_START) & (df[date_col] <= CAMPAIGN_END)]
if value.startswith("year-"):
year = int(value.split("-", 1)[1])
return df[df[date_col].dt.year == year]
@@ -40,6 +49,8 @@ def _period_label(value):
ahead of time in the Reports tab) that has no export data yet."""
if not value or value == "all":
return "All Time"
if value == "campaign-end":
return "3-Month Campaign (Jul-Sep 2026)"
if value.startswith("year-"):
return value.split("-", 1)[1]
import calendar as calmod
@@ -62,6 +73,16 @@ def compute_period_stats(ctx, timeframe_value="all"):
cts_f = _filtered(D["cts"], "Date", timeframe_value)
vts_f = _filtered(D["vts"], "Date", timeframe_value)
if timeframe_value == "campaign-end":
# A campaign-wide "Posts Published" should mean genuine campaign content —
# unlike a single month's own count (which deliberately still counts a
# repost, "a record of what was published"), the capstone number excludes
# the repost and the one non-campaign post the same way every per-post
# performance read already does elsewhere.
excluded = (ctx.get("repost_dates") or set()) | (ctx.get("non_campaign_dates") or set())
if excluded:
posts_f = posts_f[~posts_f["Created date"].dt.strftime("%Y-%m-%d").isin(excluded)]
new_followers = int(fts_f["Total followers"].sum()) if not fts_f.empty else 0
total_impressions = int(cts_f["Impressions (total)"].sum()) if not cts_f.empty else 0
total_page_views = int(vts_f["Total page views (total)"].sum()) if not vts_f.empty else 0
@@ -90,17 +111,22 @@ def compute_period_stats(ctx, timeframe_value="all"):
# scale to percentage-point form so the "%" already in every display string is correct.
avg_eng = float(avg_eng_series.mean()) * 100 if not avg_eng_series.dropna().empty else 0.0
# Monthly Growth Rate: this month's net new followers as a percentage of the
# cumulative total the page already had at the start of that month, resets every
# Monthly Growth Rate: this period's net new followers as a percentage of the
# cumulative total the page already had at the start of the period, resets every
# month against its own starting count rather than a single fixed campaign baseline
# (e.g. July: 22 new / 85 at start of July = +25.9%). Only defined for a specific
# "YYYY-MM" timeframe_value, not "all" or a whole year, since "start of the period"
# is only a clean concept for a single calendar month.
# (e.g. July: 22 new / 85 at start of July = +25.9%). "campaign-end" anchors to
# the campaign's own start (CAMPAIGN_START) instead of a calendar month's start,
# giving one growth figure for the full 3 months. Undefined for "all"/a whole
# year, since "start of the period" isn't a clean concept there.
start_of_month_followers = None
monthly_growth_rate = None
if timeframe_value and timeframe_value != "all" and not timeframe_value.startswith("year-"):
first_day = None
if timeframe_value == "campaign-end":
first_day = CAMPAIGN_START
elif timeframe_value and timeframe_value != "all" and not timeframe_value.startswith("year-"):
year, month = (int(p) for p in timeframe_value.split("-"))
first_day = pd.Timestamp(year=year, month=month, day=1)
if first_day is not None:
prior = D["fts"][D["fts"]["Date"] < first_day]
if not prior.empty:
start_of_month_followers = int(prior["Cumulative Followers"].iloc[-1])
@@ -218,8 +244,12 @@ def build_report_docx(ctx, timeframe_value="all"):
section.left_margin = Cm(1.8)
section.right_margin = Cm(1.8)
title_text = (f"{period_label} End of Month Campaign Report" if monthly_notes
else "TXproducts, LinkedIn Performance Report")
if timeframe_value == "campaign-end":
title_text = "TXproducts — 3-Month Campaign Report (July-September 2026)"
elif monthly_notes:
title_text = f"{period_label} End of Month Campaign Report"
else:
title_text = "TXproducts, LinkedIn Performance Report"
doc.add_heading(title_text, level=0)
exec_summary = (monthly_notes or {}).get("executive_summary")
@@ -298,6 +328,12 @@ def build_report_docx(ctx, timeframe_value="all"):
p = doc.add_paragraph()
p.add_run(f"{dim_title}: ").bold = True
p.add_run(dim_text)
recommendations = exec_summary.get("recommendations") or []
if recommendations:
doc.add_heading("Recommendations for October & Beyond", level=1)
for item in recommendations:
doc.add_paragraph(item, style="List Bullet")
elif monthly_notes:
doc.add_paragraph("Executive Summary not written up yet for this month.")