Spaces:
Sleeping
Sleeping
| """Trigger metric trend charts โ Plotly mini charts for action items dashboard.""" | |
| from __future__ import annotations | |
| from collections import defaultdict | |
| from datetime import date, timedelta | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| CHART_HEIGHT = 260 | |
| MINI_LAYOUT = dict( | |
| margin=dict(t=30, b=40, l=50, r=20), | |
| height=CHART_HEIGHT, | |
| legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), | |
| xaxis=dict(tickformat="%m/%d"), | |
| ) | |
| def _get_client(): | |
| from core.supabase_client import get_supabase_client | |
| return get_supabase_client() | |
| def _fetch_visibility_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]: | |
| """Cached fetch for visibility trend data.""" | |
| client = _get_client() | |
| try: | |
| result = ( | |
| client.table("report_visibility_daily") | |
| .select("task_date, brand_name, brand_type, visibility_pct") | |
| .eq("campaign_id", campaign_id) | |
| .gte("task_date", start_date) | |
| .lte("task_date", end_date) | |
| .order("task_date") | |
| .execute() | |
| ) | |
| return result.data or [] | |
| except Exception: | |
| return [] | |
| def _fetch_citation_type_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]: | |
| """Cached fetch for citation type trend data.""" | |
| client = _get_client() | |
| try: | |
| result = ( | |
| client.table("report_source_daily") | |
| .select("task_date, source_host_type, citation_count") | |
| .eq("campaign_id", campaign_id) | |
| .eq("agg_level", "host") | |
| .gte("task_date", start_date) | |
| .lte("task_date", end_date) | |
| .order("task_date") | |
| .execute() | |
| ) | |
| return result.data or [] | |
| except Exception: | |
| return [] | |
| def _fetch_negative_rate_data(campaign_id: int, start_date: str, end_date: str) -> list[dict]: | |
| """Cached fetch for negative sentiment rate data.""" | |
| client = _get_client() | |
| try: | |
| result = client.rpc("get_nudge_export_data", { | |
| "p_campaign_id": campaign_id, | |
| "p_in_house_only": False, | |
| "p_date_from": f"{start_date}T00:00:00+00:00", | |
| "p_date_to": f"{_next_day(end_date)}T00:00:00+00:00", | |
| "p_limit": 10000, | |
| }).execute() | |
| data = result.data or {} | |
| return data.get("rows", []) if isinstance(data, dict) else [] | |
| except Exception: | |
| return [] | |
| def _fetch_action_items_history(campaign_id: int) -> list[dict]: | |
| """Cached fetch for action items history data.""" | |
| client = _get_client() | |
| try: | |
| result = ( | |
| client.table("action_items") | |
| .select("created_at, completed_at, status") | |
| .eq("campaign_id", campaign_id) | |
| .order("created_at") | |
| .limit(5000) | |
| .execute() | |
| ) | |
| return result.data or [] | |
| except Exception: | |
| return [] | |
| # ============================================================================ | |
| # Chart 1: Visibility trend (own brand vs competitor average) | |
| # ============================================================================ | |
| def render_visibility_trend(campaign_id: int, start_date: str, end_date: str): | |
| """Show daily own-brand vs competitor average visibility.""" | |
| rows = _fetch_visibility_data(campaign_id, start_date, end_date) | |
| if not rows: | |
| st.info("๊ฐ์์ฑ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.") | |
| return | |
| # Group by date: own avg, competitor avg | |
| own_by_date: dict[str, list[float]] = defaultdict(list) | |
| comp_by_date: dict[str, list[float]] = defaultdict(list) | |
| for r in rows: | |
| d = r["task_date"] | |
| pct = r.get("visibility_pct", 0) | |
| if r.get("brand_type") == "PRIMARY": | |
| own_by_date[d].append(pct) | |
| else: | |
| comp_by_date[d].append(pct) | |
| dates = sorted(set(list(own_by_date.keys()) + list(comp_by_date.keys()))) | |
| own_avgs = [ | |
| sum(own_by_date[d]) / len(own_by_date[d]) if own_by_date.get(d) else None | |
| for d in dates | |
| ] | |
| comp_avgs = [ | |
| sum(comp_by_date[d]) / len(comp_by_date[d]) if comp_by_date.get(d) else None | |
| for d in dates | |
| ] | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=dates, y=own_avgs, | |
| mode="lines+markers", name="์์ฌ", | |
| line=dict(color="#3B82F6", width=2), | |
| marker=dict(size=5), | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=dates, y=comp_avgs, | |
| mode="lines+markers", name="๊ฒฝ์์ฌ ํ๊ท ", | |
| line=dict(color="#EF4444", width=2, dash="dash"), | |
| marker=dict(size=5), | |
| )) | |
| fig.update_layout( | |
| title=dict(text="์์ฌ vs ๊ฒฝ์์ฌ ๊ฐ์์ฑ", font=dict(size=13)), | |
| yaxis_title="Visibility %", | |
| **MINI_LAYOUT, | |
| ) | |
| st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) | |
| # ============================================================================ | |
| # Chart 2: Citation type trend (OFFICIAL % + top channel concentration) | |
| # ============================================================================ | |
| def render_citation_type_trend(campaign_id: int, start_date: str, end_date: str): | |
| """Show daily OFFICIAL citation % and top channel concentration.""" | |
| rows = _fetch_citation_type_data(campaign_id, start_date, end_date) | |
| if not rows: | |
| st.info("์ธ์ฉ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.") | |
| return | |
| # Group by date | |
| by_date: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) | |
| for r in rows: | |
| d = r["task_date"] | |
| ht = r.get("source_host_type") or "UNKNOWN" | |
| by_date[d][ht] += r.get("citation_count", 0) | |
| dates = sorted(by_date.keys()) | |
| official_pcts = [] | |
| top_channel_pcts = [] | |
| for d in dates: | |
| type_counts = by_date[d] | |
| total = sum(type_counts.values()) | |
| if total > 0: | |
| official_pcts.append(round(type_counts.get("OFFICIAL", 0) / total * 100, 1)) | |
| max_count = max(type_counts.values()) | |
| top_channel_pcts.append(round(max_count / total * 100, 1)) | |
| else: | |
| official_pcts.append(0) | |
| top_channel_pcts.append(0) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=dates, y=official_pcts, | |
| mode="lines+markers", name="OFFICIAL %", | |
| line=dict(color="#7C3AED", width=2), | |
| marker=dict(size=5), | |
| )) | |
| fig.add_trace(go.Scatter( | |
| x=dates, y=top_channel_pcts, | |
| mode="lines+markers", name="Top ์ฑ๋ %", | |
| line=dict(color="#F59E0B", width=2, dash="dot"), | |
| marker=dict(size=5), | |
| )) | |
| # Threshold lines | |
| fig.add_hline(y=10, line_dash="dash", line_color="#EF4444", opacity=0.5, | |
| annotation_text="OFFICIAL 10%", annotation_position="bottom right") | |
| fig.add_hline(y=50, line_dash="dash", line_color="#F97316", opacity=0.5, | |
| annotation_text="์ง์ค 50%", annotation_position="top right") | |
| fig.update_layout( | |
| title=dict(text="์ธ์ฉ ์ ํ ์ถ์ด", font=dict(size=13)), | |
| yaxis_title="%", | |
| **MINI_LAYOUT, | |
| ) | |
| st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) | |
| # ============================================================================ | |
| # Chart 3: Negative sentiment rate trend | |
| # ============================================================================ | |
| def render_negative_rate_trend(campaign_id: int, start_date: str, end_date: str): | |
| """Show daily in-house brand negative sentiment rate.""" | |
| rows = _fetch_negative_rate_data(campaign_id, start_date, end_date) | |
| if not rows: | |
| st.info("๊ฐ์ฑ ๋ถ์ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.") | |
| return | |
| if len(rows) >= 10000: | |
| st.warning("๊ฐ์ฑ ๋ฐ์ดํฐ๊ฐ 10,000๊ฑด์ ์ด๊ณผํ์ฌ ์ผ๋ถ๋ง ํ์๋ฉ๋๋ค. ๊ธฐ๊ฐ์ ์ขํ๋ณด์ธ์.") | |
| # Group by date | |
| total_by_date: dict[str, int] = defaultdict(int) | |
| neg_by_date: dict[str, int] = defaultdict(int) | |
| for r in rows: | |
| d = (r.get("analyzed_at") or "")[:10] | |
| if not d: | |
| continue | |
| total_by_date[d] += 1 | |
| if r.get("overall_polarity") == "negative": | |
| neg_by_date[d] += 1 | |
| dates = sorted(total_by_date.keys()) | |
| neg_rates = [ | |
| round(neg_by_date.get(d, 0) / total_by_date[d] * 100, 1) if total_by_date[d] > 0 else 0 | |
| for d in dates | |
| ] | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter( | |
| x=dates, y=neg_rates, | |
| mode="lines+markers", name="๋ถ์ ๋น์จ", | |
| line=dict(color="#EF4444", width=2), | |
| marker=dict(size=5), | |
| fill="tozeroy", | |
| fillcolor="rgba(239,68,68,0.1)", | |
| )) | |
| fig.add_hline(y=30, line_dash="dash", line_color="#F97316", opacity=0.5, | |
| annotation_text="๊ฒฝ๊ณ 30%", annotation_position="top right") | |
| fig.update_layout( | |
| title=dict(text="๋ถ์ ๊ฐ์ฑ ๋น์จ ์ถ์ด", font=dict(size=13)), | |
| yaxis_title="๋ถ์ %", | |
| **MINI_LAYOUT, | |
| ) | |
| st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) | |
| # ============================================================================ | |
| # Chart 4: Action items history (created vs completed per week) | |
| # ============================================================================ | |
| def render_action_items_history(campaign_id: int): | |
| """Show weekly created vs completed action items.""" | |
| rows = _fetch_action_items_history(campaign_id) | |
| if not rows: | |
| st.info("์ก์ ์์ดํ ์ด๋ ฅ์ด ์์ต๋๋ค.") | |
| return | |
| # Group by ISO week | |
| created_by_week: dict[str, int] = defaultdict(int) | |
| completed_by_week: dict[str, int] = defaultdict(int) | |
| for r in rows: | |
| c_date = (r.get("created_at") or "")[:10] | |
| if c_date: | |
| week = _iso_week_label(c_date) | |
| created_by_week[week] += 1 | |
| if r.get("status") == "completed" and r.get("completed_at"): | |
| d_date = r["completed_at"][:10] | |
| week = _iso_week_label(d_date) | |
| completed_by_week[week] += 1 | |
| weeks = sorted(set(list(created_by_week.keys()) + list(completed_by_week.keys()))) | |
| created_vals = [created_by_week.get(w, 0) for w in weeks] | |
| completed_vals = [completed_by_week.get(w, 0) for w in weeks] | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar( | |
| x=weeks, y=created_vals, name="์์ฑ", | |
| marker_color="#6366F1", | |
| )) | |
| fig.add_trace(go.Bar( | |
| x=weeks, y=completed_vals, name="์๋ฃ", | |
| marker_color="#10B981", | |
| )) | |
| fig.update_layout( | |
| title=dict(text="์ฃผ๋ณ ์ก์ ์์ดํ ์์ฑ/์๋ฃ", font=dict(size=13)), | |
| barmode="group", | |
| yaxis_title="๊ฑด์", | |
| **MINI_LAYOUT, | |
| ) | |
| st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) | |
| # ============================================================================ | |
| # Helpers | |
| # ============================================================================ | |
| def _next_day(date_str: str) -> str: | |
| """Return next day as ISO string (for half-open TIMESTAMPTZ filter).""" | |
| d = date.fromisoformat(date_str) | |
| return (d + timedelta(days=1)).isoformat() | |
| def _iso_week_label(date_str: str) -> str: | |
| """Convert date string to 'MM/DD' label of the week's Monday.""" | |
| d = date.fromisoformat(date_str) | |
| monday = d - timedelta(days=d.weekday()) | |
| return monday.strftime("%m/%d") | |