diff --git a/app.py b/app.py index ab78bd2..70f7b23 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,8 @@ import visitor_history import monthly_report_notes import follower_directory_data import engagement_audit +import theme_tracker +import conversation_tracker # Overridable via env var so the same code runs unmodified in Docker/internal deployments — # defaults to this machine's path for local dev. @@ -2208,7 +2210,86 @@ reports_tab = dbc.Container([ ], 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() +engagement_response_tab = _build_engagement_response_tab() # ── App layout ──────────────────────────────────────────────────────────────── @@ -2227,6 +2308,7 @@ NAV_ITEMS = [ ("competitors", "Competitors", competitors_tab), ("growth", "Growth", growth_tab), ("reports", "Reports", reports_tab), + ("engagement-response", "Engagement Response", engagement_response_tab), ] 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."), "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."), + "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", ""), "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."), diff --git a/conversation_tracker.py b/conversation_tracker.py new file mode 100644 index 0000000..c962d92 --- /dev/null +++ b/conversation_tracker.py @@ -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 = [ +] diff --git a/theme_tracker.py b/theme_tracker.py new file mode 100644 index 0000000..03a5342 --- /dev/null +++ b/theme_tracker.py @@ -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)