"""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() @st.cache_data(ttl=120) 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 [] @st.cache_data(ttl=120) 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 [] @st.cache_data(ttl=120) 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 [] @st.cache_data(ttl=120) 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")