|
|
@@ -18,6 +18,131 @@ except ImportError:
|
|
|
|
|
|
st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
|
|
|
|
|
|
+# ---- UI i18n ----
|
|
|
+I18N = {
|
|
|
+ "zh": {
|
|
|
+ "dashboard_title": "Lyrria 增长看板",
|
|
|
+ "sidebar_lang": "Language / 语言",
|
|
|
+ "sidebar_filters": "筛选器",
|
|
|
+ "sidebar_segment": "用户分层(users.user_segment)",
|
|
|
+ "sidebar_school": "学校(org_name)",
|
|
|
+ "sidebar_exclude_test": "排除 users.is_test_user = 1",
|
|
|
+ "sidebar_target_date": "目标日期",
|
|
|
+ "sidebar_view_members": "查看共创社区成员",
|
|
|
+ "community_dialog_title": "共创社区成员",
|
|
|
+ "community_subheader": "共创社区成员",
|
|
|
+ "community_rule": "规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。",
|
|
|
+ "community_count": "参与成员数",
|
|
|
+ "community_empty": "当前没有符合条件的共创社区成员。",
|
|
|
+ "dau_trend": "DAU 趋势",
|
|
|
+ "curr_trend": "CURR 趋势",
|
|
|
+ "axis_date": "日期",
|
|
|
+ "axis_dau": "DAU",
|
|
|
+ "axis_curr": "CURR(%)",
|
|
|
+ "tooltip_curr": "CURR(%)",
|
|
|
+ "tooltip_prev_day_active": "昨日活跃",
|
|
|
+ "tooltip_retained_today": "今日留存",
|
|
|
+ "tooltip_dau_today": "今日 DAU",
|
|
|
+ "trend3_caption_1": "30 天走势:左轴=每日用户对话总时长(秒),右轴=页面浏览量(次)。",
|
|
|
+ "trend3_caption_2": "图例说明:蓝线=对话时长(秒);橙线=Lumi 商店 PV;绿线=Lumi 装扮 PV。",
|
|
|
+ "line_duration": "对话时长(秒)",
|
|
|
+ "line_shop_pv": "Lumi 商店 PV",
|
|
|
+ "line_outfits_pv": "Lumi 装扮 PV",
|
|
|
+ "legend_lines": "曲线",
|
|
|
+ "axis_duration": "对话时长(秒)",
|
|
|
+ "axis_pv_count": "页面浏览量(次)",
|
|
|
+ "tooltip_pv_type": "PV 类型",
|
|
|
+ "tooltip_pv": "PV",
|
|
|
+ "segments_on": "分群(日期:{date})",
|
|
|
+ "segment_new": "新用户",
|
|
|
+ "segment_current": "持续活跃",
|
|
|
+ "segment_reactivated": "重新活跃",
|
|
|
+ "segment_at_risk_7d": "风险流失(7 天)",
|
|
|
+ "segment_at_risk_30d": "风险流失(30 天)",
|
|
|
+ "segment_dormant_30d": "沉睡(>=30 天)",
|
|
|
+ "active_today_composition": "今日活跃构成",
|
|
|
+ "not_active_today": "今日未活跃",
|
|
|
+ "metric_inactive_total": "未活跃 / 总用户",
|
|
|
+ "caption_active_comp": "今日 DAU 的构成。",
|
|
|
+ "caption_inactive_comp": "当前筛选人群中,今日未活跃用户的分布。",
|
|
|
+ "hint_share_dau": "占 DAU 的比例。",
|
|
|
+ "hint_share_inactive": "占今日未活跃用户的比例。",
|
|
|
+ "caption_of": "占 {denom} 的 {pct}。{hint}",
|
|
|
+ "drilldown_title": "明细下钻(前 50 位用户)",
|
|
|
+ "drilldown_caption": "邮箱、手机号为 `users` 表字段;未录入则为空。",
|
|
|
+ "tab_active_today": "今日活跃",
|
|
|
+ "tab_at_risk": "风险流失",
|
|
|
+ "tab_dormant": "沉睡",
|
|
|
+ },
|
|
|
+ "en": {
|
|
|
+ "dashboard_title": "Lyrria Growth Dashboard",
|
|
|
+ "sidebar_lang": "Language / 语言",
|
|
|
+ "sidebar_filters": "Filters",
|
|
|
+ "sidebar_segment": "users.user_segment",
|
|
|
+ "sidebar_school": "School (org_name)",
|
|
|
+ "sidebar_exclude_test": "Exclude users.is_test_user = 1",
|
|
|
+ "sidebar_target_date": "Target date",
|
|
|
+ "sidebar_view_members": "View Community Members",
|
|
|
+ "community_dialog_title": "Community Members",
|
|
|
+ "community_subheader": "Community Members",
|
|
|
+ "community_rule": "Rule: not opted out, and email comes from users.email or from early_community after phone sign-up.",
|
|
|
+ "community_count": "Members Count",
|
|
|
+ "community_empty": "No community members match the current rule.",
|
|
|
+ "dau_trend": "DAU trend",
|
|
|
+ "curr_trend": "CURR trend",
|
|
|
+ "axis_date": "Date",
|
|
|
+ "axis_dau": "DAU",
|
|
|
+ "axis_curr": "CURR (%)",
|
|
|
+ "tooltip_curr": "CURR (%)",
|
|
|
+ "tooltip_prev_day_active": "Prev-day active",
|
|
|
+ "tooltip_retained_today": "Retained today",
|
|
|
+ "tooltip_dau_today": "DAU today",
|
|
|
+ "trend3_caption_1": "30-day trend: left axis = total daily conversation duration (s), right axis = page views.",
|
|
|
+ "trend3_caption_2": "Legend: blue = Conversation duration (s); orange = Lumi shop PV; green = Lumi outfits PV.",
|
|
|
+ "line_duration": "Conversation duration (s)",
|
|
|
+ "line_shop_pv": "Lumi shop PV",
|
|
|
+ "line_outfits_pv": "Lumi outfits PV",
|
|
|
+ "legend_lines": "Lines",
|
|
|
+ "axis_duration": "Conversation duration (s)",
|
|
|
+ "axis_pv_count": "Page views (count)",
|
|
|
+ "tooltip_pv_type": "PV type",
|
|
|
+ "tooltip_pv": "PV",
|
|
|
+ "segments_on": "Segments on {date}",
|
|
|
+ "segment_new": "New",
|
|
|
+ "segment_current": "Current",
|
|
|
+ "segment_reactivated": "Reactivated",
|
|
|
+ "segment_at_risk_7d": "At-risk (7d)",
|
|
|
+ "segment_at_risk_30d": "At-risk (30d)",
|
|
|
+ "segment_dormant_30d": "Dormant (>=30d)",
|
|
|
+ "active_today_composition": "Active today composition",
|
|
|
+ "not_active_today": "Not active today",
|
|
|
+ "metric_inactive_total": "Inactive / Total",
|
|
|
+ "caption_active_comp": "How today's DAU is composed.",
|
|
|
+ "caption_inactive_comp": "Breakdown of users who are not active today (within the current filtered cohort).",
|
|
|
+ "hint_share_dau": "Share of DAU.",
|
|
|
+ "hint_share_inactive": "Share of users not active today.",
|
|
|
+ "caption_of": "{pct} of {denom}. {hint}",
|
|
|
+ "drilldown_title": "Drill-down (first 50 users)",
|
|
|
+ "drilldown_caption": "Email and phone come from the `users` table; blank means missing.",
|
|
|
+ "tab_active_today": "Active today",
|
|
|
+ "tab_at_risk": "At-risk",
|
|
|
+ "tab_dormant": "Dormant",
|
|
|
+ },
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+def get_lang() -> str:
|
|
|
+ selected = st.sidebar.selectbox(I18N["en"]["sidebar_lang"], options=["中文", "English"], index=1)
|
|
|
+ return "zh" if selected == "中文" else "en"
|
|
|
+
|
|
|
+
|
|
|
+lang = get_lang()
|
|
|
+
|
|
|
+
|
|
|
+def t(key: str, **kwargs) -> str:
|
|
|
+ text = I18N.get(lang, I18N["en"]).get(key, key)
|
|
|
+ return text.format(**kwargs) if kwargs else text
|
|
|
+
|
|
|
# ---- MySQL config from setting.conf ----
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
|
|
|
@@ -137,10 +262,10 @@ def read_community_members_df() -> pd.DataFrame:
|
|
|
|
|
|
def _render_community_members_content():
|
|
|
members_df = read_community_members_df()
|
|
|
- st.caption("规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。")
|
|
|
- st.metric("参与成员数", f"{len(members_df):,}")
|
|
|
+ st.caption(t("community_rule"))
|
|
|
+ st.metric(t("community_count"), f"{len(members_df):,}")
|
|
|
if members_df.empty:
|
|
|
- st.info("当前没有符合条件的共创社区成员。")
|
|
|
+ st.info(t("community_empty"))
|
|
|
return
|
|
|
st.dataframe(
|
|
|
members_df,
|
|
|
@@ -164,28 +289,53 @@ def _apply_dialog_extra_wide_style():
|
|
|
|
|
|
|
|
|
if hasattr(st, "dialog"):
|
|
|
- @st.dialog("共创社区成员", width="large")
|
|
|
+ @st.dialog(t("community_dialog_title"), width="large")
|
|
|
def show_community_members_dialog():
|
|
|
_apply_dialog_extra_wide_style()
|
|
|
_render_community_members_content()
|
|
|
else:
|
|
|
def show_community_members_dialog():
|
|
|
- st.subheader("共创社区成员")
|
|
|
+ st.subheader(t("community_subheader"))
|
|
|
_render_community_members_content()
|
|
|
|
|
|
-st.title("Lyrria Growth Dashboard")
|
|
|
+st.title(t("dashboard_title"))
|
|
|
|
|
|
# ---- Sidebar ----
|
|
|
-st.sidebar.header("Filters")
|
|
|
+st.sidebar.header(t("sidebar_filters"))
|
|
|
|
|
|
segments = read_df("SELECT DISTINCT user_segment AS seg FROM users WHERE user_segment IS NOT NULL ORDER BY seg")["seg"].dropna().tolist()
|
|
|
-orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
|
|
|
+org_df = read_df(
|
|
|
+ """
|
|
|
+ SELECT DISTINCT uda.org_id_snapshot AS org_id, o.org_name
|
|
|
+ FROM user_daily_activity uda
|
|
|
+ LEFT JOIN organizations o
|
|
|
+ ON o.org_id COLLATE utf8mb4_unicode_ci = uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
|
|
|
+ WHERE uda.org_id_snapshot IS NOT NULL
|
|
|
+ ORDER BY
|
|
|
+ COALESCE(
|
|
|
+ o.org_name COLLATE utf8mb4_unicode_ci,
|
|
|
+ uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
|
|
|
+ ),
|
|
|
+ uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
|
|
|
+ """
|
|
|
+)
|
|
|
+org_df["org_id"] = org_df["org_id"].astype(str)
|
|
|
+org_map = {
|
|
|
+ row["org_id"]: (str(row["org_name"]).strip() if pd.notna(row["org_name"]) and str(row["org_name"]).strip() else row["org_id"])
|
|
|
+ for _, row in org_df.iterrows()
|
|
|
+}
|
|
|
+orgs = org_df["org_id"].tolist()
|
|
|
|
|
|
segment_default = [s for s in segments if s != "internal"] if segments else []
|
|
|
-segment_sel = st.sidebar.multiselect("users.user_segment", options=segments, default=segment_default)
|
|
|
-org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
|
|
|
+segment_sel = st.sidebar.multiselect(t("sidebar_segment"), options=segments, default=segment_default)
|
|
|
+org_sel = st.sidebar.multiselect(
|
|
|
+ t("sidebar_school"),
|
|
|
+ options=orgs,
|
|
|
+ default=[],
|
|
|
+ format_func=lambda org_id: org_map.get(org_id, org_id),
|
|
|
+)
|
|
|
|
|
|
-exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
|
|
|
+exclude_test = st.sidebar.checkbox(t("sidebar_exclude_test"), value=True)
|
|
|
|
|
|
# Date bounds
|
|
|
max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
|
|
|
@@ -194,14 +344,14 @@ today = date.today()
|
|
|
|
|
|
# UX: 默认选择今天,允许选择今天之前所有日期(若库里最小日期晚于今天,则回退为该最小日期)
|
|
|
target_day = st.sidebar.date_input(
|
|
|
- "Target date",
|
|
|
+ t("sidebar_target_date"),
|
|
|
value=today if today >= min_day else min_day,
|
|
|
min_value=min_day,
|
|
|
max_value=today if today >= min_day else min_day,
|
|
|
)
|
|
|
|
|
|
st.sidebar.markdown("---")
|
|
|
-if st.sidebar.button("查看共创社区成员", use_container_width=True):
|
|
|
+if st.sidebar.button(t("sidebar_view_members"), use_container_width=True):
|
|
|
show_community_members_dialog()
|
|
|
|
|
|
# ---- Load users (for universe + test filter) ----
|
|
|
@@ -260,7 +410,7 @@ else:
|
|
|
cohort_universe = universe_users
|
|
|
|
|
|
# ---- Trend (DAU, by day) ----
|
|
|
-st.subheader("DAU trend")
|
|
|
+st.subheader(t("dau_trend"))
|
|
|
# 最近 30 天(含 target_day);若早于库内最小日期则截断
|
|
|
trend_start = max(min_day, target_day - timedelta(days=29))
|
|
|
dau_raw = (
|
|
|
@@ -285,15 +435,15 @@ chart = (
|
|
|
alt.Chart(trend)
|
|
|
.mark_line(point=True)
|
|
|
.encode(
|
|
|
- x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
|
|
|
- y=alt.Y("dau:Q", axis=alt.Axis(title="DAU")),
|
|
|
+ x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
|
|
|
+ y=alt.Y("dau:Q", axis=alt.Axis(title=t("axis_dau"))),
|
|
|
tooltip=["day", "dau"],
|
|
|
)
|
|
|
)
|
|
|
st.altair_chart(chart, use_container_width=True)
|
|
|
|
|
|
# ---- Trend (CURR, by day from curr_daily) ----
|
|
|
-st.subheader("CURR trend")
|
|
|
+st.subheader(t("curr_trend"))
|
|
|
curr_start = max(min_day, target_day - timedelta(days=29))
|
|
|
curr_df = read_df(
|
|
|
"""
|
|
|
@@ -321,14 +471,14 @@ curr_chart = (
|
|
|
alt.Chart(curr_plot)
|
|
|
.mark_line(point=True, color="#f59e0b")
|
|
|
.encode(
|
|
|
- x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
|
|
|
- y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title="CURR (%)", format=".2f")),
|
|
|
+ x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
|
|
|
+ y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title=t("axis_curr"), format=".2f")),
|
|
|
tooltip=[
|
|
|
"day",
|
|
|
- alt.Tooltip("curr_rate_pct:Q", title="CURR (%)", format=".2f"),
|
|
|
- alt.Tooltip("prev_day_active_count:Q", title="Prev-day active"),
|
|
|
- alt.Tooltip("retained_count:Q", title="Retained today"),
|
|
|
- alt.Tooltip("total_active_today:Q", title="DAU today"),
|
|
|
+ alt.Tooltip("curr_rate_pct:Q", title=t("tooltip_curr"), format=".2f"),
|
|
|
+ alt.Tooltip("prev_day_active_count:Q", title=t("tooltip_prev_day_active")),
|
|
|
+ alt.Tooltip("retained_count:Q", title=t("tooltip_retained_today")),
|
|
|
+ alt.Tooltip("total_active_today:Q", title=t("tooltip_dau_today")),
|
|
|
],
|
|
|
)
|
|
|
)
|
|
|
@@ -380,23 +530,37 @@ for col in ["lumi_shop_pv", "lumi_outfits_pv", "total_conversation_duration_seco
|
|
|
trend3_plot[col] = trend3_plot[col].fillna(0)
|
|
|
trend3_plot["day"] = trend3_plot["activity_date"].astype(str)
|
|
|
|
|
|
-st.caption("30d 走势:左轴=每日用户对话总时长(秒),右轴=页面浏览量(次)。三条线:lumi-shop、lumi-outfits、对话时长。")
|
|
|
+st.caption(t("trend3_caption_1"))
|
|
|
+st.caption(t("trend3_caption_2"))
|
|
|
+
|
|
|
+duration_label = t("line_duration")
|
|
|
+shop_label = t("line_shop_pv")
|
|
|
+outfits_label = t("line_outfits_pv")
|
|
|
|
|
|
duration_line = (
|
|
|
alt.Chart(trend3_plot)
|
|
|
+ .transform_calculate(line_type=f"'{duration_label}'")
|
|
|
.mark_line(point=True, color="#3b82f6")
|
|
|
.encode(
|
|
|
- x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
|
|
|
+ x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
|
|
|
y=alt.Y(
|
|
|
"total_conversation_duration_seconds:Q",
|
|
|
- axis=alt.Axis(title="Conversation duration (s)"),
|
|
|
+ axis=alt.Axis(title=t("axis_duration")),
|
|
|
scale=alt.Scale(domainMin=0),
|
|
|
),
|
|
|
+ color=alt.Color(
|
|
|
+ "line_type:N",
|
|
|
+ scale=alt.Scale(
|
|
|
+ domain=[duration_label, shop_label, outfits_label],
|
|
|
+ range=["#3b82f6", "#f59e0b", "#10b981"],
|
|
|
+ ),
|
|
|
+ legend=alt.Legend(title=t("legend_lines")),
|
|
|
+ ),
|
|
|
tooltip=[
|
|
|
"day",
|
|
|
- alt.Tooltip("total_conversation_duration_seconds:Q", title="Conversation duration (s)", format=",.0f"),
|
|
|
- alt.Tooltip("lumi_shop_pv:Q", title="Lumi shop PV", format=",d"),
|
|
|
- alt.Tooltip("lumi_outfits_pv:Q", title="Lumi outfits PV", format=",d"),
|
|
|
+ alt.Tooltip("total_conversation_duration_seconds:Q", title=duration_label, format=",.0f"),
|
|
|
+ alt.Tooltip("lumi_shop_pv:Q", title=shop_label, format=",d"),
|
|
|
+ alt.Tooltip("lumi_outfits_pv:Q", title=outfits_label, format=",d"),
|
|
|
],
|
|
|
)
|
|
|
)
|
|
|
@@ -407,30 +571,32 @@ pv_lines = (
|
|
|
["lumi_shop_pv", "lumi_outfits_pv"],
|
|
|
as_=["pv_type", "pv_count"],
|
|
|
)
|
|
|
+ .transform_calculate(
|
|
|
+ line_type=f"""
|
|
|
+ datum.pv_type === 'lumi_shop_pv' ? '{shop_label}' : '{outfits_label}'
|
|
|
+ """
|
|
|
+ )
|
|
|
.mark_line(point=True)
|
|
|
.encode(
|
|
|
x=alt.X("day:N"),
|
|
|
y=alt.Y(
|
|
|
"pv_count:Q",
|
|
|
- axis=alt.Axis(title="Page views (count)", orient="right"),
|
|
|
+ axis=alt.Axis(title=t("axis_pv_count"), orient="right"),
|
|
|
scale=alt.Scale(domainMin=0),
|
|
|
),
|
|
|
color=alt.Color(
|
|
|
- "pv_type:N",
|
|
|
+ "line_type:N",
|
|
|
scale=alt.Scale(
|
|
|
- domain=["lumi_shop_pv", "lumi_outfits_pv"],
|
|
|
- range=["#f59e0b", "#10b981"],
|
|
|
- ),
|
|
|
- legend=alt.Legend(
|
|
|
- title="PV lines",
|
|
|
- labelExpr="datum.value === 'lumi_shop_pv' ? 'Lumi shop PV' : 'Lumi outfits PV'",
|
|
|
+ domain=[duration_label, shop_label, outfits_label],
|
|
|
+ range=["#3b82f6", "#f59e0b", "#10b981"],
|
|
|
),
|
|
|
+ legend=alt.Legend(title=t("legend_lines")),
|
|
|
),
|
|
|
tooltip=[
|
|
|
"day",
|
|
|
- alt.Tooltip("pv_type:N", title="PV type"),
|
|
|
- alt.Tooltip("pv_count:Q", title="PV", format=",d"),
|
|
|
- alt.Tooltip("total_conversation_duration_seconds:Q", title="Conversation duration (s)", format=",.0f"),
|
|
|
+ alt.Tooltip("pv_type:N", title=t("tooltip_pv_type")),
|
|
|
+ alt.Tooltip("pv_count:Q", title=t("tooltip_pv"), format=",d"),
|
|
|
+ alt.Tooltip("total_conversation_duration_seconds:Q", title=duration_label, format=",.0f"),
|
|
|
],
|
|
|
)
|
|
|
)
|
|
|
@@ -501,19 +667,19 @@ dormant_users = {
|
|
|
}
|
|
|
|
|
|
# ---- Segments visualization (Duolingo-like, no tables) ----
|
|
|
-st.subheader(f"Segments on {d0.isoformat()}")
|
|
|
+st.subheader(t("segments_on", date=d0.isoformat()))
|
|
|
|
|
|
active_buckets = [
|
|
|
- ("New", len(new_users)),
|
|
|
- ("Current", len(current_users)),
|
|
|
- ("Reactivated", len(reactivated_users)),
|
|
|
+ ("segment_new", len(new_users)),
|
|
|
+ ("segment_current", len(current_users)),
|
|
|
+ ("segment_reactivated", len(reactivated_users)),
|
|
|
]
|
|
|
active_total = len(active_today)
|
|
|
inactive_total = max(0, len(cohort_universe) - len(active_today))
|
|
|
inactive_buckets = [
|
|
|
- ("At-risk (7d)", len(at_risk_wau)),
|
|
|
- ("At-risk (30d)", len(at_risk_mau)),
|
|
|
- ("Dormant (≥30d)", len(dormant_users)),
|
|
|
+ ("segment_at_risk_7d", len(at_risk_wau)),
|
|
|
+ ("segment_at_risk_30d", len(at_risk_mau)),
|
|
|
+ ("segment_dormant_30d", len(dormant_users)),
|
|
|
]
|
|
|
|
|
|
def segment_card(label: str, value: int, denom: int, hint: str):
|
|
|
@@ -528,7 +694,7 @@ def segment_card(label: str, value: int, denom: int, hint: str):
|
|
|
unsafe_allow_html=True,
|
|
|
)
|
|
|
st.progress(min(max(pct, 0.0), 1.0))
|
|
|
- st.caption(f"{pct:.1%} of {denom:,}. {hint}")
|
|
|
+ st.caption(t("caption_of", pct=f"{pct:.1%}", denom=f"{denom:,}", hint=hint))
|
|
|
|
|
|
|
|
|
def drilldown_table(user_ids: set, users_df: pd.DataFrame, limit: int = 50) -> pd.DataFrame:
|
|
|
@@ -562,31 +728,31 @@ left, right = st.columns(2, gap="large")
|
|
|
with left:
|
|
|
t1, t2 = st.columns([3, 1], vertical_alignment="center")
|
|
|
with t1:
|
|
|
- st.markdown("### Active today composition")
|
|
|
+ st.markdown(f"### {t('active_today_composition')}")
|
|
|
with t2:
|
|
|
st.metric("DAU", len(active_today))
|
|
|
|
|
|
- st.caption("How today's DAU is composed.")
|
|
|
- for label, val in active_buckets:
|
|
|
- segment_card(label, val, active_total, "Share of DAU.")
|
|
|
+ st.caption(t("caption_active_comp"))
|
|
|
+ for label_key, val in active_buckets:
|
|
|
+ segment_card(t(label_key), val, active_total, t("hint_share_dau"))
|
|
|
|
|
|
with right:
|
|
|
t1, t2 = st.columns([2, 2], vertical_alignment="center")
|
|
|
with t1:
|
|
|
- st.markdown("### Not active today")
|
|
|
+ st.markdown(f"### {t('not_active_today')}")
|
|
|
with t2:
|
|
|
- st.metric("Inactive / Total", f"{inactive_total:,} / {len(cohort_universe):,}")
|
|
|
+ st.metric(t("metric_inactive_total"), f"{inactive_total:,} / {len(cohort_universe):,}")
|
|
|
|
|
|
- st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
|
|
|
- for label, val in inactive_buckets:
|
|
|
- segment_card(label, val, inactive_total, "Share of users not active today.")
|
|
|
+ st.caption(t("caption_inactive_comp"))
|
|
|
+ for label_key, val in inactive_buckets:
|
|
|
+ segment_card(t(label_key), val, inactive_total, t("hint_share_inactive"))
|
|
|
|
|
|
|
|
|
st.markdown("---")
|
|
|
# ---- Drill down ----
|
|
|
-st.subheader("Drill-down (first 50 users)")
|
|
|
-st.caption("邮箱、手机号为 `users` 表字段;未录入则为空。")
|
|
|
-tab1, tab2, tab3 = st.tabs(["Active today", "At-risk", "Dormant"])
|
|
|
+st.subheader(t("drilldown_title"))
|
|
|
+st.caption(t("drilldown_caption"))
|
|
|
+tab1, tab2, tab3 = st.tabs([t("tab_active_today"), t("tab_at_risk"), t("tab_dormant")])
|
|
|
with tab1:
|
|
|
st.dataframe(
|
|
|
drilldown_table(active_today, users),
|