GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
11.4 kB
"""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")