Move KPI tiles next to the Team member dropdown; Visibility + Posts on one line

KPIs now render into their own area (team-kpi-area) placed directly next to the
Team member dropdown/Download Report row, instead of scrolled down inside the
main content area - extracted into _team_stat_row()/_team_empty_stat_row() and
wired as a third Output on _render_team_tab's callback.

Visibility and the Posts table now sit side by side in one row instead of each
being a separate full-width section. The Posts table (11 columns) gets its own
horizontal scroll container now that it's at half-width instead of full.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
beantownbabeandClaude Sonnet 5 committed 2026-10-31 08:32:39 +01:00
1 parent 8f63b7dd53
commit 79d15806ec
1 file changed
+63 -39
+63 -39
View File
@@ -2338,14 +2338,17 @@ _TEAM_DEFAULT_MEMBER = team_exports.TEAM_MEMBERS[0]
team_tab = dbc.Container([
html.Div([
html.Label("Team member", style={"fontSize": "0.75rem", "fontWeight": "600",
"marginBottom": "4px", "display": "block"}),
dcc.Dropdown(
id="team-member-select",
options=_TEAM_MEMBER_OPTIONS, value=_TEAM_DEFAULT_MEMBER,
clearable=False, searchable=False, style={"width": "280px"},
),
], style={"marginBottom": "1.25rem"}),
html.Div([
html.Label("Team member", style={"fontSize": "0.75rem", "fontWeight": "600",
"marginBottom": "4px", "display": "block"}),
dcc.Dropdown(
id="team-member-select",
options=_TEAM_MEMBER_OPTIONS, value=_TEAM_DEFAULT_MEMBER,
clearable=False, searchable=False, style={"width": "280px"},
),
], style={"flexShrink": "0"}),
html.Div(id="team-kpi-area", style={"flex": "1", "minWidth": "0"}),
], style={"display": "flex", "alignItems": "flex-end", "gap": "1rem", "marginBottom": "1.25rem"}),
html.Div(id="team-content-area"),
], fluid=True, className="pb-3")
@@ -2957,7 +2960,7 @@ def _team_top_posts_table(top_posts_df, post_details):
html.Td(_cell(details.get("reposts"))),
html.Td(r["Post"]),
]))
return dbc.Table([
table = dbc.Table([
html.Thead(html.Tr([html.Th(c) for c in [
"Date", "Description", "Type", "Impressions", "Engagements (export)", "Profile Viewers*",
"Followers Gained*", "Reactions*", "Comments*", "Reposts*", "Link",
@@ -2967,6 +2970,9 @@ def _team_top_posts_table(top_posts_df, post_details):
"* from each post's own \"Export\" button (SinglePostAnalytics file) — not in the Aggregate Analytics export",
colSpan=11, style={"fontSize": "0.7rem", "color": "#6b7a8d", "fontStyle": "italic"}))),
], bordered=True, size="sm", style={"fontSize": "0.85rem"})
# Sitting at half-width (next to Visibility) now rather than full-width, with 11
# columns - needs its own horizontal scroll so it doesn't force the whole page wide.
return html.Div(table, style={"overflowX": "auto"})
def _team_demographics_table(demo_df, top_n=5):
@@ -2981,10 +2987,10 @@ def _team_demographics_table(demo_df, top_n=5):
], bordered=True, size="sm", style={"fontSize": "0.75rem"})
def _build_person_dashboard(name, data):
def _team_stat_row(data):
discovery = data["discovery"]
window_label = discovery.get("date_range", "")
window_start, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "")
_, window_end = (window_label.split(" - ") + [""])[:2] if window_label else ("", "")
# team_exports.TEAM_BASELINE_DATE/TEAM_BASELINE_FOLLOWERS - everyone started this
# work in June 2026 at 0 followers (confirmed directly), not the Page's own
@@ -2997,7 +3003,7 @@ def _build_person_dashboard(name, data):
baseline_label = baseline_ts.strftime("%b %d, %Y")
best_eng_pct, avg_eng_pct, rated_post_count = _team_engagement_rate_stats(data["top_posts"])
stat_row = _team_kpi_row([
return _team_kpi_row([
kpi("Starting Followers", f"{starting_followers:,}", f"as of {baseline_label}", "#999"),
kpi("Total Followers", f"{data['followers_total']:,}", f"as of {window_end}", TEAL),
kpi("New Followers", f"{new_followers_window:,}", f"{baseline_label} – {window_end}"),
@@ -3008,6 +3014,22 @@ def _build_person_dashboard(name, data):
"raw, personal profile (organic)", TEAL),
])
def _team_empty_stat_row():
return _team_kpi_row([
kpi("Starting Followers", "—", "", "#999"),
kpi("Total Followers", "—", "", TEAL),
kpi("New Followers", "—", ""),
kpi("Total Impressions", "—", ""),
kpi("Best Post Real Eng.", "—", "", GREEN),
kpi("Avg. Eng. Rate", "—", "", TEAL),
])
def _build_person_dashboard(name, data):
discovery = data["discovery"]
window_label = discovery.get("date_range", "")
source_note = html.Div(
f"Source: {', '.join(data['source_files'])} — window {window_label}",
style={"fontSize": "0.72rem", "color": "#6b7a8d", "marginBottom": "0.75rem"})
@@ -3030,13 +3052,7 @@ def _build_person_dashboard(name, data):
_team_demographics_table(data["content_demographics"])), md=3),
], className="gx-3 mb-3")
post_details = post_details_tracker.load_post_details(DATA_DIR, UPLOAD_DIR)
posts_row = dbc.Row([
dbc.Col(section("Posts This Window — Originals vs Reposts",
_team_top_posts_table(data["top_posts"], post_details)), md=12),
], className="gx-3")
return html.Div([stat_row, source_note, calendar_row, charts_row, posts_row])
return html.Div([source_note, calendar_row, charts_row])
_TEAM_EMPTY_PLACEHOLDER = html.Div("No data yet", className="text-muted text-center",
@@ -3044,19 +3060,10 @@ _TEAM_EMPTY_PLACEHOLDER = html.Div("No data yet", className="text-muted text-cen
def _build_empty_person_dashboard(name):
"""Same box layout as _build_person_dashboard (stat tiles, chart cards, posts
table, demographics cards) so every team member's tab looks consistent before
their first export lands, instead of a lone text alert - each box just shows a
"""Same box layout as _build_person_dashboard (chart cards, posts table,
demographics cards) so every team member's tab looks consistent before their
first export lands, instead of a lone text alert - each box just shows a
"No data yet" placeholder in place of real content."""
stat_row = _team_kpi_row([
kpi("Starting Followers", "—", "", "#999"),
kpi("Total Followers", "—", "", TEAL),
kpi("New Followers", "—", ""),
kpi("Total Impressions", "—", ""),
kpi("Best Post Real Eng.", "—", "", GREEN),
kpi("Avg. Eng. Rate", "—", "", TEAL),
])
source_note = dbc.Alert(
f"No personal LinkedIn export data for {name} yet. Drop their "
f"\"AggregateAnalytics_{name}_...\" export into the project folder and it will "
@@ -3075,14 +3082,11 @@ def _build_empty_person_dashboard(name):
dbc.Col(section("Content Demographics — Top 5 (who engages)", _TEAM_EMPTY_PLACEHOLDER), md=3),
], className="gx-3 mb-3")
posts_row = dbc.Row([
dbc.Col(section("Posts This Window — Originals vs Reposts", _TEAM_EMPTY_PLACEHOLDER), md=12),
], className="gx-3")
return html.Div([stat_row, source_note, calendar_row, charts_row, posts_row])
return html.Div([source_note, calendar_row, charts_row])
@app.callback(
Output("team-kpi-area", "children"),
Output("team-content-area", "children"),
Output("team-download-btn", "disabled"),
Input("team-member-select", "value"),
@@ -3094,12 +3098,32 @@ def _render_team_tab(name):
text alert, so the tab looks consistent across all five people before and after
data lands. No code change needed when a person's first export arrives -
team_exports.load_person_data() picks it up automatically by filename. The
Download Report button is disabled whenever there's nothing real to send yet."""
Download Report button is disabled whenever there's nothing real to send yet.
KPI tiles render into their own area next to the Team member dropdown/Download
button rather than inside team-content-area, and Visibility sits next to the
Posts table in one row rather than each being its own full-width section."""
visibility_row = _team_visibility_row(name)
data = team_exports.load_person_data(name, DATA_DIR, UPLOAD_DIR)
if not data:
return html.Div([visibility_row, _build_empty_person_dashboard(name)]), True
return html.Div([visibility_row, _build_person_dashboard(name, data)]), False
stat_row = _team_empty_stat_row()
posts_section = section("Posts This Window — Originals vs Reposts", _TEAM_EMPTY_PLACEHOLDER)
body = _build_empty_person_dashboard(name)
disabled = True
else:
stat_row = _team_stat_row(data)
post_details = post_details_tracker.load_post_details(DATA_DIR, UPLOAD_DIR)
posts_section = section("Posts This Window — Originals vs Reposts",
_team_top_posts_table(data["top_posts"], post_details))
body = _build_person_dashboard(name, data)
disabled = False
visibility_posts_row = dbc.Row([
dbc.Col(visibility_row, md=6),
dbc.Col(posts_section, md=6),
], className="gx-3 mb-3")
return stat_row, html.Div([visibility_posts_row, body]), disabled
@app.callback(