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:
1 parent
8f63b7dd53
commit
79d15806ec
1 file changed
+63
-39
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user