Add Engagement Response tab for the new campaign (pending approval)

New tab, separate from the Reports dropdown (which stays tied to
actual calendar-month/campaign-approval data), covering what the new
campaign plan cares about that the old reporting never tracked:

- theme_tracker.py: per-post theme classification (Failure Analysis /
  Standardization / WaveGate), same hand-tagged pattern as
  MANUAL_CONTENT_TYPES. Powers a Theme Performance chart showing each
  theme's Real Eng % across the whole campaign, not just its lead
  month - the explicit ask was to see if a theme keeps earning
  engagement even when a different theme is leading that month.

- conversation_tracker.py: logs every real conversation (a comment
  from an actual person) through to resolution (open -> replied ->
  followed_up -> closed). Exists because the old campaign's two real
  conversations, Sabine Brock on 20/08 and Dr. Debnath on 08/09, were
  both left open with no reply and no follow-up - this makes that
  visible instead of silent. An "N open" badge flags anything still
  needing a reply.

Both data sources start empty and render a clean "nothing logged yet"
state - nothing to log until the campaign is approved and posts start
going up, but the tooling is ready.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-26 11:41:21 +01:00
1 parent e60eb034ce
commit efcad4a6da
3 files changed
+151

No files matched your search

+83
View File
@@ -15,6 +15,8 @@ import visitor_history
import monthly_report_notes import monthly_report_notes
import follower_directory_data import follower_directory_data
import engagement_audit import engagement_audit
import theme_tracker
import conversation_tracker
# Overridable via env var so the same code runs unmodified in Docker/internal deployments — # Overridable via env var so the same code runs unmodified in Docker/internal deployments —
# defaults to this machine's path for local dev. # defaults to this machine's path for local dev.
@@ -2208,7 +2210,86 @@ reports_tab = dbc.Container([
], fluid=True, className="pb-3") ], fluid=True, className="pb-3")
def _build_engagement_response_tab():
"""New-campaign tooling (Nov 2026 onward, pending approval): theme performance
across the whole campaign (not just a post's lead month) and a conversation
tracker so a real comment never gets silently left open the way Sabine Brock's
and Dr. Debnath's both were last campaign. Both data sources
(theme_tracker.POST_THEMES, conversation_tracker.CONVERSATIONS) are hand-
maintained and start empty — this renders a clean "nothing logged yet" state
until the campaign actually starts."""
theme_rows = []
for theme in theme_tracker.THEMES:
theme_dates = {d for d, t in theme_tracker.POST_THEMES.items() if t == theme}
if not theme_dates:
continue
matched = posts[posts["Created date"].dt.strftime("%Y-%m-%d").isin(theme_dates)]
for _, r in matched.iterrows():
eng = engagement_audit.real_eng_pct(
r["Created date"].strftime("%d/%m"), r["Impressions"], r.get("Clicks", 0))
if eng is not None:
theme_rows.append({"Theme": theme, "Real Eng %": eng})
if theme_rows:
theme_df = pd.DataFrame(theme_rows)
theme_avg = theme_df.groupby("Theme", as_index=False)["Real Eng %"].mean()
theme_fig = px.bar(theme_avg, x="Theme", y="Real Eng %", color="Theme",
template=TMPL, color_discrete_map=theme_tracker.THEME_COLORS)
theme_fig.update_layout(showlegend=False)
theme_content = G(theme_fig, style={"height": "280px"})
else:
theme_content = html.Div(
"No posts tagged with a theme yet — add entries to theme_tracker.POST_THEMES "
"as campaign posts go up.", className="text-muted", style={"padding": "1rem"})
theme_card = dbc.Card([
dbc.CardHeader("Theme Performance (campaign-wide, not just the lead month)"),
dbc.CardBody(theme_content),
], className="mb-3 shadow-sm")
convos = conversation_tracker.CONVERSATIONS
open_count = sum(1 for c in convos if c.get("state") == "open")
if convos:
rows = []
for c in sorted(convos, key=lambda c: c.get("comment_date", ""), reverse=True):
state = c.get("state", "open")
rows.append(html.Tr([
html.Td(c.get("comment_date", "")),
html.Td(c.get("person", "")),
html.Td(c.get("theme", "")),
html.Td(html.Span(conversation_tracker.STATE_LABELS.get(state, state),
style={"color": "white", "backgroundColor":
conversation_tracker.STATE_COLORS.get(state, "#999"),
"padding": "2px 8px", "borderRadius": "4px",
"fontSize": "0.75rem"})),
html.Td(c.get("notes", ""), style={"fontSize": "0.8rem", "color": "#6b7a8d"}),
]))
convo_content = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in
["Date", "Person", "Theme", "State", "Notes"]])),
html.Tbody(rows),
], bordered=True, size="sm", style={"fontSize": "0.85rem"})
else:
convo_content = html.Div(
"No conversations logged yet — add an entry to "
"conversation_tracker.CONVERSATIONS the same day a real comment comes in.",
className="text-muted", style={"padding": "1rem"})
convo_card = dbc.Card([
dbc.CardHeader([
"Conversation Tracker",
dbc.Badge(f"{open_count} open" if open_count else "0 open",
color="danger" if open_count else "success",
className="ms-2"),
]),
dbc.CardBody(convo_content),
], className="mb-3 shadow-sm")
return dbc.Container([theme_card, convo_card], fluid=True, className="pb-3")
refresh_data() refresh_data()
engagement_response_tab = _build_engagement_response_tab()
# ── App layout ──────────────────────────────────────────────────────────────── # ── App layout ────────────────────────────────────────────────────────────────
@@ -2227,6 +2308,7 @@ NAV_ITEMS = [
("competitors", "Competitors", competitors_tab), ("competitors", "Competitors", competitors_tab),
("growth", "Growth", growth_tab), ("growth", "Growth", growth_tab),
("reports", "Reports", reports_tab), ("reports", "Reports", reports_tab),
("engagement-response", "Engagement Response", engagement_response_tab),
] ]
TAB_CONTENT_MAP = {key: content for key, _, content in NAV_ITEMS} TAB_CONTENT_MAP = {key: content for key, _, content in NAV_ITEMS}
@@ -2238,6 +2320,7 @@ TAB_HERO = {
"competitors": ("Competitors", "Benchmarking TXproducts against tracked competitor pages on LinkedIn."), "competitors": ("Competitors", "Benchmarking TXproducts against tracked competitor pages on LinkedIn."),
"growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."), "growth": ("Growth", "Historical follower growth, tracked weekly against your own rolling best."),
"reports": ("Reports", "Monthly progress reports for LinkedIn, combining the dashboard data with the Follower Directory."), "reports": ("Reports", "Monthly progress reports for LinkedIn, combining the dashboard data with the Follower Directory."),
"engagement-response": ("Engagement Response", "Theme performance across the whole campaign and a tracker for every real conversation, so none get left open."),
"roadmap-insights": ("Campaign Roadmap — Insights", ""), "roadmap-insights": ("Campaign Roadmap — Insights", ""),
"all-posts-insights": ("All Posts — Insights", "The Content tab's own post list and format/activity charts, mirrored here for the selected month."), "all-posts-insights": ("All Posts — Insights", "The Content tab's own post list and format/activity charts, mirrored here for the selected month."),
"follower-growth-insights": ("Follower Growth — Insights", "The Overview and Growth tabs' own follower charts, mirrored here for the selected month."), "follower-growth-insights": ("Follower Growth — Insights", "The Overview and Growth tabs' own follower charts, mirrored here for the selected month."),
+45
View File
@@ -0,0 +1,45 @@
"""Tracks each real conversation (a comment thread from a real person, not the team's
own activity) through to resolution, for the new engagement-response campaign (Nov
2026 onward, pending approval). The old campaign's two real conversations (Sabine
Brock on 20/08, Dr. Arunangshu Debnath on 08/09) were both left open with no reply
and no follow-up - this exists so that doesn't happen again silently.
A conversation starts the moment a real person comments. State moves forward as the
team acts on it:
open - comment received, no reply sent yet
replied - TXproducts replied (should happen within 24h per the campaign plan)
followed_up - a 1-week follow-up was sent after the conversation went quiet
closed - resolved either way (they replied back and it wrapped up, or the
follow-up got no further response and it's being treated as done)
Add an entry the same day a real comment comes in, update its state as it moves
along - this is a log, not something computed from the LinkedIn export (LinkedIn
gives no reply-timestamp data at all, this is entirely hand-tracked).
"""
STATE_COLORS = {
"open": "#d43f3a",
"replied": "#FFA726",
"followed_up": "#2d6a9f",
"closed": "#52C41A",
}
STATE_LABELS = {
"open": "Open — needs a reply",
"replied": "Replied",
"followed_up": "Followed up",
"closed": "Closed",
}
# {
# "post_date": "2026-11-03",
# "theme": "Failure Analysis",
# "person": "Jane Doe",
# "comment_date": "2026-11-03",
# "state": "open",
# "reply_date": None,
# "followup_date": None,
# "notes": "",
# },
CONVERSATIONS = [
]
+23
View File
@@ -0,0 +1,23 @@
"""Per-post theme classification for the new engagement-response campaign (Nov 2026
onward, pending approval). Same pattern as app.py's MANUAL_CONTENT_TYPES (LinkedIn's
export has no "theme" field at all, so this is entirely hand-tagged), keyed by
"YYYY-MM-DD". Empty until the campaign starts and posts go up — add an entry here as
each post is published, no other code changes needed for it to show up in the
Theme Performance chart.
"""
THEMES = ["Failure Analysis", "Standardization", "WaveGate"]
THEME_COLORS = {
"Failure Analysis": "#E8825A",
"Standardization": "#1b3a5c",
"WaveGate": "#52C41A",
}
# "2026-11-03": "Failure Analysis",
POST_THEMES = {
}
def theme_for(date_iso):
return POST_THEMES.get(date_iso)