Add Core Message/Roadmap/Monthly Notes cards and cross-page nav to Reports tab

Restructures the Reports tab into a CSS Grid layout (Core Message + Campaign
Roadmap stacked, spanning Follower Growth below, alongside All Posts; Audience
Composition + Follower Study + Monthly Notes as a second row), each card
carrying a short abstract with a link to its full detail. Adds three new
"insights" sub-pages (Roadmap, Core Message, Monthly Notes) reachable via a
shared hero-banner nav so hopping between them doesn't require detouring
through the main Reports page each time. Also fixes two content-type
misclassifications (23/07, 30/07 should be Carousel) and trims chart chrome
(legend, axis labels/padding) to reclaim vertical space without shrinking bar
height.

PDF (report.py) and static export (build_static_export.py) intentionally not
yet updated to mirror these Reports tab changes — deferred until layout
iteration is done.
This commit is contained in:
beantownbabe committed 2026-08-07 12:29:21 +02:00
1 parent aa30aa992c
commit cbb308cf37
3 files changed
+386 -63

No files matched your search

+4
View File
@@ -19,3 +19,7 @@ __pycache__/
# OS # OS
.DS_Store .DS_Store
manual_followers_list_* manual_followers_list_*
# Dev server logs from manual restarts during a session — not shared code
app_restart*.log
app_morning.log
+371 -63
View File
@@ -4,6 +4,7 @@ import warnings; warnings.filterwarnings("ignore")
import os import os
from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update from dash import Dash, dcc, html, dash_table, Input, Output, State, ctx, no_update
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go import plotly.graph_objects as go
@@ -42,6 +43,8 @@ MANUAL_CONTENT_TYPES = {
"2026-07-09": "Carousel", "2026-07-09": "Carousel",
"2026-07-16": "Carousel", "2026-07-16": "Carousel",
"2026-07-21": "Poll", "2026-07-21": "Poll",
"2026-07-23": "Carousel",
"2026-07-30": "Carousel",
} }
# ── Palette — matched to txdata_project/portal.py's navy/blue design language ── # ── Palette — matched to txdata_project/portal.py's navy/blue design language ──
@@ -1608,7 +1611,7 @@ def daily_followers_figure(fts_df, posts_df, year=None, month=None):
fig = px.bar(df, x="Date", y=["Organic followers", "Auto-invited followers"], fig = px.bar(df, x="Date", y=["Organic followers", "Auto-invited followers"],
template=TMPL, barmode="stack", template=TMPL, barmode="stack",
color_discrete_sequence=[BLUE, AMBER], color_discrete_sequence=[BLUE, AMBER],
labels={"value": "Followers", "variable": ""}) labels={"value": "Followers", "variable": "", "Date": ""})
return _std(mark_posts(fig, df, pdf)) return _std(mark_posts(fig, df, pdf))
@@ -2114,7 +2117,10 @@ TAB_HERO = {
"visitors": ("Visitors", "Page views and unique visitors to the TXproducts LinkedIn Page, broken down by device and section."), "visitors": ("Visitors", "Page views and unique visitors to the TXproducts LinkedIn Page, broken down by device and section."),
"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 written progress reports for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory."), "reports": ("Reports", "Monthly progress reports for LinkedIn, combining the dashboard data with the Follower Directory."),
"roadmap-insights": ("Campaign Roadmap — Insights", "July's Month 1 checkpoint: traction by format, top posts by comments and shares, and best posting day."),
"core-message-insights": ("Core Message — Insights", "How July's new followers compare to the Core Message's foundation, by function and sector."),
"monthly-notes-insights": ("Monthly Notes — Insights", "Features/fixes shipped, data quality issues found and fixed, Follower Study/Directory updates, and outstanding items for July."),
} }
GROWTH_METRIC_EXPLAINER = html.Div([ GROWTH_METRIC_EXPLAINER = html.Div([
@@ -2126,7 +2132,19 @@ GROWTH_METRIC_EXPLAINER = html.Div([
TAB_HERO_INFO = {"growth": GROWTH_METRIC_EXPLAINER} TAB_HERO_INFO = {"growth": GROWTH_METRIC_EXPLAINER}
def _hero_title_children(active):
"""Reports folds its subtitle onto the same line as the title ("Reports — Monthly
progress reports...") instead of a separate line below, to give the tab's own
content (already the most vertically dense tab) a bit more viewing space."""
title, subtitle = TAB_HERO[active]
if active != "reports":
return title
return [title, html.Span(f" — {subtitle}", style={"fontWeight": "500", "fontSize": "0.85rem"})]
def _hero_subtitle_children(active): def _hero_subtitle_children(active):
if active == "reports":
return ""
text = TAB_HERO[active][1] text = TAB_HERO[active][1]
info = TAB_HERO_INFO.get(active) info = TAB_HERO_INFO.get(active)
if info is None: if info is None:
@@ -2134,25 +2152,50 @@ def _hero_subtitle_children(active):
return [text, info_popover(f"hero-{active}", info)] return [text, info_popover(f"hero-{active}", info)]
_REPORT_HERO_LINK_STYLE = {
"color": "white", "fontSize": "0.8rem", "fontWeight": "600",
"textDecoration": "none", "padding": "7px 14px",
"backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px",
"border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap",
}
# The 3 sub-pages reachable from Reports cards, plus Reports itself — every one of
# these 4 gets a nav back to the other 3 (+ the external Follower Study) in its own
# hero banner, so hopping between insights doesn't require detouring through Reports
# each time.
_REPORT_SECTIONS = [
("reports", "Reports", "/reports"),
("roadmap-insights", "Roadmap Insights", "/roadmap-insights"),
("core-message-insights", "Core Message Insights", "/core-message-insights"),
("monthly-notes-insights", "Monthly Notes Insights", "/monthly-notes-insights"),
]
def _report_section_nav(active):
links = [dcc.Link(label, href=href, style=_REPORT_HERO_LINK_STYLE)
for key, label, href in _REPORT_SECTIONS if key != active]
links.append(html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE))
for link in links[1:]:
link.style = {**link.style, "marginLeft": "8px"}
return links
def _hero_actions(active): def _hero_actions(active):
"""Follower Directory / Follower Study links shown in the hero banner — only on the """Follower Directory / Follower Study links shown in the hero banner — only on the
Followers tab, not in the sidebar. Both are standalone HTML files regenerated from Followers tab, not in the sidebar. Both are standalone HTML files regenerated from
manual_followers_list_2026-07-22.xlsx and copied into assets/, so Dash serves them manual_followers_list_2026-07-22.xlsx and copied into assets/, so Dash serves them
as-is at /assets/... ; re-copy them here after every follower data update.""" as-is at /assets/... ; re-copy them here after every follower data update."""
if active != "followers": if active == "followers":
return [] return [
link_style = { html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank",
"color": "white", "fontSize": "0.8rem", "fontWeight": "600", rel="noopener noreferrer", style=_REPORT_HERO_LINK_STYLE),
"textDecoration": "none", "padding": "7px 14px", html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
"backgroundColor": "rgba(255,255,255,0.14)", "borderRadius": "6px", rel="noopener noreferrer", style={**_REPORT_HERO_LINK_STYLE, "marginLeft": "8px"}),
"border": "1px solid rgba(255,255,255,0.35)", "whiteSpace": "nowrap", ]
} if active in {key for key, _, _ in _REPORT_SECTIONS}:
return [ return _report_section_nav(active)
html.A("Follower Directory", href="/assets/follower_directory.html", target="_blank", return []
rel="noopener noreferrer", style=link_style),
html.A("Follower Study", href="/assets/follower_study.html", target="_blank",
rel="noopener noreferrer", style={**link_style, "marginLeft": "8px"}),
]
_SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR) _SIDEBAR_GROWTH_HISTORY = growth.load_history(UPLOAD_DIR)
@@ -2242,13 +2285,20 @@ main_content = html.Div([
html.Div(overview, id="tab-content-area"), html.Div(overview, id="tab-content-area"),
], style={"flex": "1", "minWidth": "0", "padding": "20px 24px"}) ], style={"flex": "1", "minWidth": "0", "padding": "20px 24px"})
app.layout = html.Div([sidebar, main_content], style={"display": "flex", "alignItems": "stretch"}) # A single, persistent dcc.Location (never remounted) drives the two insights-page
# links via pathname, instead of n_clicks on the link elements themselves — those get
# recreated every time the Reports tab re-renders, and Dash fires a dynamically-mounted
# component's callback once on its first appearance regardless of prevent_initial_call,
# which was hijacking the view back to an insights page immediately on every Reports
# render. pathname changes only on an actual click, so this doesn't have that problem.
app.layout = html.Div([dcc.Location(id="url-router", refresh=False), sidebar, main_content],
style={"display": "flex", "alignItems": "stretch"})
@app.callback( @app.callback(
[Output(f"nav-{key}", "className") for key, _, _ in NAV_ITEMS] [Output(f"nav-{key}", "className") for key, _, _ in NAV_ITEMS]
+ [Output("tab-content-area", "children"), Output("hero-title", "children"), Output("hero-subtitle", "children"), + [Output("tab-content-area", "children"), Output("hero-title", "children"), Output("hero-subtitle", "children"),
Output("hero-actions", "children")], Output("hero-actions", "children"), Output("url-router", "pathname")],
[Input(f"nav-{key}", "n_clicks") for key, _, _ in NAV_ITEMS] [Input(f"nav-{key}", "n_clicks") for key, _, _ in NAV_ITEMS]
+ [Input("growth-indicator-btn", "n_clicks")], + [Input("growth-indicator-btn", "n_clicks")],
) )
@@ -2259,7 +2309,41 @@ def _switch_tab(*_):
else: else:
active = triggered.replace("nav-", "") if triggered else "overview" active = triggered.replace("nav-", "") if triggered else "overview"
classes = ["sidebar-nav-btn active" if key == active else "sidebar-nav-btn" for key, _, _ in NAV_ITEMS] classes = ["sidebar-nav-btn active" if key == active else "sidebar-nav-btn" for key, _, _ in NAV_ITEMS]
hero_title = TAB_HERO[active][0] hero_title = _hero_title_children(active)
# Also resets url-router's pathname to "/" — without this, revisiting the SAME
# insights link twice in a row (e.g. Follower Growth then All Posts, both pointing
# at /roadmap-insights) does nothing the second time, since pathname never actually
# changed value in between (going back to Reports via the sidebar doesn't touch it).
return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active),
_hero_actions(active), "/"]
_INSIGHTS_ROUTES = {"/roadmap-insights": "roadmap-insights", "/core-message-insights": "core-message-insights",
"/monthly-notes-insights": "monthly-notes-insights", "/reports": "reports"}
@app.callback(
[Output(f"nav-{key}", "className", allow_duplicate=True) for key, _, _ in NAV_ITEMS]
+ [Output("tab-content-area", "children", allow_duplicate=True),
Output("hero-title", "children", allow_duplicate=True),
Output("hero-subtitle", "children", allow_duplicate=True),
Output("hero-actions", "children", allow_duplicate=True)],
Input("url-router", "pathname"),
prevent_initial_call=True,
)
def _switch_to_insights_page(pathname):
"""Driven by dcc.Location's pathname rather than n_clicks on the link elements
themselves: those links live inside reports-content-area, which gets torn down and
recreated every time the Reports tab re-renders, and Dash fires a dynamically
re-mounted component's callback once on each fresh appearance regardless of
prevent_initial_call — that was hijacking the view back to an insights page on
every single Reports render. url-router is a single persistent component defined
once in app.layout, so its pathname only changes on an actual click."""
active = _INSIGHTS_ROUTES.get(pathname)
if active is None:
raise PreventUpdate
classes = ["sidebar-nav-btn" for _ in NAV_ITEMS]
hero_title = _hero_title_children(active)
return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), _hero_actions(active)] return classes + [TAB_CONTENT_MAP[active], hero_title, _hero_subtitle_children(active), _hero_actions(active)]
@@ -2466,6 +2550,47 @@ def _build_report_ctx():
} }
def _rich_text(parts, style=None):
"""Renders a list of (text, is_bold) tuples as one html.P, e.g. for a fixed tagline
that needs bolding inside otherwise-plain narrative copy. bold="br" inserts a line
break instead of wrapping text (text is ignored for that entry)."""
children = []
for text, bold in parts:
if bold == "br":
children.append(html.Br())
elif bold:
children.append(html.Strong(text))
else:
children.append(text)
return html.P(children, style=style)
def _insights_link(href):
return html.Div(
dcc.Link("Click here for the insights", href=href,
style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f",
"textDecoration": "none", "cursor": "pointer"}),
style={"textAlign": "right"})
def _insights_link_external(label, href):
"""Same look as _insights_link, but a plain html.A opening in a new tab — for
standalone hand-maintained documents (like the Follower Study) that live outside
the Dash app entirely, as opposed to an internal SPA route."""
return html.Div(
html.A(label, href=href, target="_blank", rel="noopener noreferrer",
style={"fontSize": "0.75rem", "fontWeight": "600", "color": "#2d6a9f",
"textDecoration": "none", "cursor": "pointer"}),
style={"textAlign": "right"})
def _reports_card_body_style():
"""Flex column with space-between so an _insights_link sits flush against the
CardBody's own bottom padding (real breathing room before the card's border)
instead of wherever the content above happens to end."""
return {"display": "flex", "flexDirection": "column", "justifyContent": "space-between", "height": "100%"}
def _narrative_section(title, items): def _narrative_section(title, items):
if not items: if not items:
return html.Div() return html.Div()
@@ -2495,12 +2620,14 @@ def _reports_posts_table(posts_f):
if not len(posts_f): if not len(posts_f):
return html.Div("No posts published in this period.", className="text-muted") return html.Div("No posts published in this period.", className="text-muted")
all_posts = posts_f.sort_values("Created date") all_posts = posts_f.sort_values("Created date")
_post_title_style = {"whiteSpace": "nowrap", "overflow": "hidden", "textOverflow": "ellipsis",
"maxWidth": "260px"}
return dbc.Table([ return dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in html.Thead(html.Tr([html.Th(c) for c in
["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]])), ["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]])),
html.Tbody([ html.Tbody([
html.Tr([ html.Tr([
html.Td(str(r["Short title"])[:60]), html.Td(str(r["Short title"]), title=str(r["Short title"]), style=_post_title_style),
html.Td(r["Created date"].strftime("%d/%m/%Y")), html.Td(r["Created date"].strftime("%d/%m/%Y")),
html.Td(r["Content Type"]), html.Td(r["Content Type"]),
html.Td(f"{int(r['Impressions']):,}"), html.Td(f"{int(r['Impressions']):,}"),
@@ -2508,7 +2635,116 @@ def _reports_posts_table(posts_f):
html.Td(f"{r['CTR %']:.1f}%"), html.Td(f"{r['CTR %']:.1f}%"),
]) for _, r in all_posts.iterrows() ]) for _, r in all_posts.iterrows()
]), ]),
], bordered=True, size="sm", style={"fontSize": "0.8rem"}) ], bordered=True, size="sm", style={"fontSize": "0.75rem"})
def _roadmap_insights_page():
"""Reads the Roadmap's own Month 1 checkpoint (page 5): traction by format, top
posts by comments+shares, and best posting day, computed live from July's posts
rather than hand-typed, so it can't drift from the dashboard's own numbers."""
stats = report.compute_period_stats(_build_report_ctx(), "2026-07")
posts_f = stats["posts_f"].copy()
posts_f["Shares"] = posts_f["Comments"].fillna(0) + posts_f["Reposts"].fillna(0)
posts_f["Weekday"] = posts_f["Created date"].dt.day_name()
by_format = (posts_f.groupby("Content Type")
.agg(Posts=("Content Type", "size"), **{"Avg Eng %": ("Eng %", "mean")},
Shares=("Shares", "sum"))
.round(1).sort_values("Avg Eng %", ascending=False).reset_index())
format_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Format", "Posts", "Avg Eng %", "Comments + Shares"]])),
html.Tbody([html.Tr([html.Td(r["Content Type"]), html.Td(int(r["Posts"])),
html.Td(f"{r['Avg Eng %']}%"), html.Td(int(r["Shares"]))])
for _, r in by_format.iterrows()]),
], bordered=True, size="sm")
top_posts = posts_f.sort_values("Shares", ascending=False).head(3)
top_posts_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Post", "Date", "Format", "Comments", "Reposts"]])),
html.Tbody([html.Tr([html.Td(str(r["Short title"])[:60]), html.Td(r["Created date"].strftime("%d/%m/%Y")),
html.Td(r["Content Type"]), html.Td(int(r["Comments"])), html.Td(int(r["Reposts"]))])
for _, r in top_posts.iterrows()]),
], bordered=True, size="sm")
by_day = (posts_f.groupby("Weekday").agg(Posts=("Weekday", "size"), **{"Avg Eng %": ("Eng %", "mean")})
.round(1).sort_values("Avg Eng %", ascending=False).reset_index())
day_table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in ["Day", "Posts", "Avg Eng %"]])),
html.Tbody([html.Tr([html.Td(r["Weekday"]), html.Td(int(r["Posts"])), html.Td(f"{r['Avg Eng %']}%")])
for _, r in by_day.iterrows()]),
], bordered=True, size="sm")
return dbc.Container([
dbc.Card([dbc.CardHeader("Traction by Format"), dbc.CardBody(format_table)], className="mb-3 shadow-sm"),
dbc.Card([dbc.CardHeader("Top Posts by Comments + Shares"), dbc.CardBody(top_posts_table)],
className="mb-3 shadow-sm"),
dbc.Card([dbc.CardHeader("Best Posting Day"), dbc.CardBody(day_table)], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
def _core_message_insights_page():
"""Checks July's actual new followers against the Core Message's foundation: how
many sit in Research/Science or Engineering/Technical roles (the audience the
content itself speaks to), computed live from the Follower Directory."""
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, "2026-07")
func_table = dbc.Table([
html.Thead(html.Tr([html.Th("Function (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["function"].most_common()]),
], bordered=True, size="sm")
sector_table = dbc.Table([
html.Thead(html.Tr([html.Th("Sector (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["sector"].most_common()]),
], bordered=True, size="sm")
seniority_table = dbc.Table([
html.Thead(html.Tr([html.Th("Seniority (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["seniority"].most_common()]),
], bordered=True, size="sm")
country_table = dbc.Table([
html.Thead(html.Tr([html.Th("Country (July cohort)"), html.Th("Count")])),
html.Tbody([html.Tr([html.Td(k), html.Td(str(v))]) for k, v in cohort["country"]]),
], bordered=True, size="sm")
return dbc.Container([
dbc.Card([
dbc.CardHeader("July's Followers, by Function and Sector"),
dbc.CardBody([
html.P(f"{cohort['total']} new followers were identified and profiled in July.",
style={"fontSize": "0.85rem"}),
dbc.Row([dbc.Col(func_table, md=6), dbc.Col(sector_table, md=6)]),
]),
], className="mb-3 shadow-sm"),
dbc.Card([
dbc.CardHeader("July's Followers, by Seniority and Country"),
dbc.CardBody(dbc.Row([dbc.Col(seniority_table, md=6), dbc.Col(country_table, md=6)])),
], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
def _monthly_notes_insights_page():
"""Full detail behind the Monthly Notes card's one-line description — moved to its
own page so the Reports tab's Audience Composition / Monthly Notes row can stay as
compact side-by-side abstracts, same pattern as Core Message / Campaign Roadmap."""
notes = monthly_report_notes.NOTES.get("2026-07")
if not notes:
return dbc.Alert("Notes for this month have not been written yet.", color="info")
return dbc.Container([
dbc.Card([
dbc.CardHeader("Monthly Notes — July 2026"),
dbc.CardBody([
_narrative_section("Features / Fixes Shipped", notes.get("features_shipped", [])),
_narrative_section("Data Quality Issues Found and Fixed", notes.get("data_quality", [])),
_narrative_section("Follower Study / Directory Updates", notes.get("follower_study", [])),
_narrative_section("Outstanding", notes.get("outstanding", [])),
]),
], className="mb-3 shadow-sm"),
], fluid=True, className="pb-3")
# Not in NAV_ITEMS/the sidebar — reached only via the "Click here for the insights"
# links on the Reports tab's Core Message / Campaign Roadmap cards, same trick as
# growth-indicator-btn (a non-sidebar Input into the shared _switch_tab callback).
TAB_CONTENT_MAP["roadmap-insights"] = _roadmap_insights_page()
TAB_CONTENT_MAP["core-message-insights"] = _core_message_insights_page()
TAB_CONTENT_MAP["monthly-notes-insights"] = _monthly_notes_insights_page()
@app.callback( @app.callback(
@@ -2611,38 +2847,85 @@ def _render_reports_tab(month_value):
), style={"flex": "1", "minWidth": "0"}), ), style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"}) ], style={"display": "flex", "gap": "0.75rem", "marginBottom": "1.25rem", "flexWrap": "wrap"})
_reports_abstract_body_style = {"fontSize": "0.8rem"}
# Core Message sizes to its own content (flex: 0 0 auto) instead of splitting the
# stack 50/50 with Roadmap — it no longer needs to match Roadmap's height, only the
# STACK as a whole needs to reach the row height below.
# 2026-08-07 layout experiment: CSS Grid instead of nested Bootstrap rows/cols, since
# this shape (Core Message + Roadmap side by side on top, Follower Growth spanning
# both of their combined width below, All Posts spanning both rows on the right)
# needs real cell-spanning that dbc.Row/Col can't express without deep nesting.
# Grid's own default stretch behavior handles the equal-height alignment natively
# (no more manual flex/h-100 juggling) — Core Message and Roadmap auto-match each
# other's row height, and All Posts auto-stretches to match Roadmap-row + Follower
# Growth-row combined. Prior (pre-grid) layout measurements, for reference/revert:
# Core Message 304x188.7, Roadmap 304x227.1 (stack 427.8 total), Follower Growth
# 304x427.8, All Posts 632x427.8, all four sharing top=290.1/bottom=717.95.
core_message_card = dbc.Card([
dbc.CardHeader("Core Message"),
dbc.CardBody([
_rich_text(notes.get("core_message_abstract", []), style=_reports_abstract_body_style)
if notes else html.Div("Not written up yet.", className="text-muted"),
_insights_link("/core-message-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "cm"})
roadmap_card = dbc.Card([
dbc.CardHeader("Campaign Roadmap"),
dbc.CardBody([
_rich_text(notes.get("roadmap_abstract", []), style=_reports_abstract_body_style)
if notes else html.Div("Not written up yet.", className="text-muted"),
_insights_link("/roadmap-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "rm"})
if fts_f.empty:
chart_content = html.Div("No follower data for this period.", className="text-muted")
else:
_reports_growth_fig = daily_followers_figure(fts_f, posts_f)
# No legend, no autorange padding above the tallest bar: this compact card is an
# at-a-glance summary (full chart with its legend lives on the Followers tab and
# via the "insights" link), so every pixel of its fixed height goes to bar height
# instead of chrome around it.
_reports_growth_fig.update_layout(showlegend=False)
# A star sitting exactly on the y=0 baseline (a post published on a day with no
# net follower change) gets its bottom half clipped by the plot area's own edge
# under rangemode="tozero" — small negative headroom keeps zero off that edge.
_reports_ymax = (fts_f["Organic followers"] + fts_f["Auto-invited followers"]).max()
_reports_growth_fig.update_yaxes(range=[-max(_reports_ymax * 0.12, 0.4), _reports_ymax * 1.15])
# Date tick labels dropped: the exact day is already on hover for every bar/star,
# and freeing the vertical space they took lets the card shrink without touching
# bar height itself.
_reports_growth_fig.update_xaxes(showticklabels=False)
# Total height reduced by roughly what the tick labels used to take, so the plot
# area (and thus bar height) stays the same as before instead of auto-expanding
# into the freed space — the box shrinks, the bars don't grow past what was approved.
chart_content = G(_reports_growth_fig, style={"height": "165px"}, margin=dict(t=10, b=2, l=10, r=10))
chart_card = dbc.Card([ chart_card = dbc.Card([
dbc.CardHeader("Follower Growth"), dbc.CardHeader("Follower Growth"),
dbc.CardBody(G(daily_followers_figure(fts_f, posts_f), style={"height": "280px"}) if not fts_f.empty # Tighter padding than the other cards (8px vs the dbc default 16px each side):
else html.Div("No follower data for this period.", className="text-muted")), # the chart's own 190px height is fixed and not to be touched, so shaving the
], className="shadow-sm h-100") # padding around it is the only real lever left to shrink this card's total
# height without shrinking the bars themselves.
dbc.CardBody([
chart_content,
_insights_link("/roadmap-insights"),
], style={**_reports_card_body_style(), "padding": "8px"}),
], className="shadow-sm", style={"gridArea": "fg"})
posts_card = dbc.Card([ posts_card = dbc.Card([
dbc.CardHeader(f"All Posts, {stats['period_label']}"), dbc.CardHeader(f"All Posts, {stats['period_label']}"),
dbc.CardBody(_reports_posts_table(posts_f)), dbc.CardBody([
], className="shadow-sm h-100") _reports_posts_table(posts_f),
_insights_link("/roadmap-insights"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "ap"})
cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value) cohort = follower_directory_data.composition_stats(DIRECTORY_DATA, month_value)
all_time = follower_directory_data.composition_stats(DIRECTORY_DATA)
if cohort["total"]: if cohort["total"]:
composition_body = [ composition_body = [_composition_table(cohort["sector"], "Sector (this month)")]
html.P([
f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}. "
f"Of those, ",
html.Strong(f"{cohort['total']} have been identified and profiled"),
f" in the Follower Directory so far (a separate, hand-maintained audit of who our "
f"followers are, not a second follower count), out of {all_time['total']} audited "
f"across the whole page to date.",
], style={"fontSize": "0.85rem"}),
html.Div([
html.Div(_composition_table(cohort["sector"], "Sector (this month)"),
style={"flex": "1", "minWidth": "220px"}),
html.Div(_composition_table(cohort["seniority"], "Seniority (this month)"),
style={"flex": "1", "minWidth": "220px"}),
html.Div(_composition_table(cohort["country"], "Country (this month)"),
style={"flex": "1", "minWidth": "220px"}),
], style={"display": "flex", "gap": "1rem", "flexWrap": "wrap"}),
]
else: else:
composition_body = [html.P( composition_body = [html.P(
f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}, " f"LinkedIn recorded {stats['new_followers']} new followers in {stats['period_label']}, "
@@ -2651,29 +2934,54 @@ def _render_reports_tab(month_value):
composition_info = info_popover( composition_info = info_popover(
"reports-composition", "reports-composition",
"The Follower Directory is updated by hand each time a new follower comes in, " "The Follower Directory is updated by hand each time a new follower comes in, "
"so it can run ahead of the dashboard's weekly LinkedIn export.", "so it can run ahead of the dashboard's weekly LinkedIn export. Seniority and "
"country breakdowns are on the insights page below.",
) )
composition_card = dbc.Card([ composition_card = dbc.Card([
dbc.CardHeader(["Audience Composition (Follower Directory Coverage)", composition_info]), dbc.CardHeader(["Audience Composition (Follower Directory Coverage)", composition_info]),
dbc.CardBody(composition_body), dbc.CardBody(composition_body + [_insights_link("/core-message-insights")],
], className="mb-3 shadow-sm") style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "ac"})
if notes: _notes_description = (
narrative_card = dbc.Card([ "Contains Features/Fixes Shipped, Data Quality Issues Found and Fixed, "
dbc.CardHeader("Monthly Notes"), "Follower Study/Directory Updates, and any Outstanding areas that need "
dbc.CardBody([ "further attention."
_narrative_section("Features / Fixes Shipped", notes.get("features_shipped", [])), )
_narrative_section("Data Quality Issues Found and Fixed", notes.get("data_quality", [])), notes_card = dbc.Card([
_narrative_section("Follower Study / Directory Updates", notes.get("follower_study", [])), dbc.CardHeader("Monthly Notes"),
_narrative_section("Outstanding", notes.get("outstanding", [])), dbc.CardBody([
]), html.P(_notes_description, style={"fontSize": "0.8rem"}) if notes
], className="mb-3 shadow-sm") else html.Div("Not written up yet.", className="text-muted"),
else: _insights_link("/monthly-notes-insights"),
narrative_card = dbc.Alert("Notes for this month have not been written yet.", color="warning") ], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "mn"})
charts_row = dbc.Row([dbc.Col(chart_card, md=3), dbc.Col(posts_card, md=9)], _study_description = (
style={"marginBottom": "1rem"}) "Narrative analysis of who's following us: key findings, growth timeline, "
return title_heading, html.Div([kpi_row, charts_row, composition_card, narrative_card]) "seniority/function/sector/geography breakdowns, and notable organizational "
"clusters. Hand-maintained alongside the Directory, not computed from it."
)
study_card = dbc.Card([
dbc.CardHeader("Follower Study"),
dbc.CardBody([
html.P(_study_description, style={"fontSize": "0.8rem"}),
_insights_link_external("Click here for the study", "/assets/follower_study.html"),
], style=_reports_card_body_style()),
], className="shadow-sm", style={"gridArea": "fs"})
charts_row = html.Div(
[core_message_card, roadmap_card, chart_card, posts_card],
style={"display": "grid", "gridTemplateColumns": "1fr 1fr 2fr",
"gridTemplateAreas": '"cm rm ap" "fg fg ap"',
"gap": "1rem", "marginBottom": "1rem"},
)
composition_row = html.Div(
[composition_card, notes_card, study_card],
style={"display": "grid", "gridTemplateColumns": "1fr 1fr 1fr", "gridTemplateAreas": '"ac fs mn"',
"gap": "1rem", "marginBottom": "1rem"},
)
return title_heading, html.Div([kpi_row, charts_row, composition_row])
@app.callback( @app.callback(
+11
View File
@@ -10,6 +10,17 @@ code changes are needed for the Reports tab or PDF to pick them up.
NOTES = { NOTES = {
"2026-07": { "2026-07": {
"core_message_abstract": [
("\"We reveal the processes inside your materials,\"", True),
("", "br"),
(" This defines TXproducts' central value proposition. It's the foundation "
"of all outreach and content.", False),
],
"roadmap_abstract": [
("Find the Voice.", True),
(" Establish a consistent brand voice and discover the tone that resonates. "
"Identify which posts and formats drove the most engagement.", False),
],
"features_shipped": [ "features_shipped": [
"Initial dashboard build (Jul 21): Overview, Content, Followers, Visitors, " "Initial dashboard build (Jul 21): Overview, Content, Followers, Visitors, "
"Competitors, and Growth tabs, weekly-pace tracking, PDF report generation, " "Competitors, and Growth tabs, weekly-pace tracking, PDF report generation, "