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:
1 parent
50bfb9025f
commit
6722b29fe8
3 files changed
+259
-21
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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.")
|
||||
|
||||
|
||||
Reference in new issue
Block a user