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:
1 parent
e60eb034ce
commit
efcad4a6da
3 files changed
+151
No files matched your search
@@ -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."),
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user