feat(briefing): date picker + 카드별 읽음/하이라이트 액션
사용자 요청 (2026-05-13):
- 오늘 briefing 만 보여주고 과거 못 보는 게 아쉬움 → 날짜 선택 UI
- 시간대 별 나열은 오히려 불편 → date dropdown 1단계 선택
- 각 카드에 읽음/하이라이트 토글
Schema (migrations 263~266, 단일 statement):
- briefing_topics.is_read BOOL NOT NULL DEFAULT false
- briefing_topics.read_at TIMESTAMPTZ
- briefing_topics.highlighted BOOL NOT NULL DEFAULT false
- briefing_topics.highlighted_at TIMESTAMPTZ
API (app/api/briefing.py):
- TopicResponse 에 id / is_read / read_at / highlighted / highlighted_at 추가
- GET /api/briefing/dates → 사용 가능 날짜 목록 (60일 cap)
· briefing_date / total_topics / total_articles / status / read_count / highlighted_count
- PATCH /api/briefing/topics/{id}/read body {value: bool} → 읽음 토글
- PATCH /api/briefing/topics/{id}/highlight body {value: bool} → 하이라이트 토글
- 토글 시 *_at 컬럼 자동 설정/NULL
UI (frontend/src/routes/news/+page.svelte):
- 헤더 우측 <select> date dropdown — 최신 + N일치 (highlighted_count 별 표시)
- 선택 시 /api/briefing?date=… 로 해당 날짜 briefing 로드
- 카드 우측 상단 ★ (하이라이트) + 읽음 버튼
- 하이라이트 = Card class ring-2 ring-yellow-400
- 읽음 = 외부 div class opacity-60 (시각 차분화, 펴기 가능)
- 토글 즉시 PATCH 호출 + 로컬 state 갱신
each key topic.topic_rank → topic.id 변경 (이미 unique).
This commit is contained in:
@@ -43,6 +43,7 @@ class KeyQuote(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TopicResponse(BaseModel):
|
class TopicResponse(BaseModel):
|
||||||
|
id: int # 2026-05-13 카드 액션 (read/highlight) 호출용 식별자
|
||||||
topic_rank: int
|
topic_rank: int
|
||||||
topic_label: str
|
topic_label: str
|
||||||
headline: str
|
headline: str
|
||||||
@@ -56,6 +57,11 @@ class TopicResponse(BaseModel):
|
|||||||
country_count: int
|
country_count: int
|
||||||
importance_score: float
|
importance_score: float
|
||||||
llm_fallback_used: bool
|
llm_fallback_used: bool
|
||||||
|
# 2026-05-13 사용자 액션 — UI 의 카드별 토글
|
||||||
|
is_read: bool = False
|
||||||
|
read_at: datetime | None = None
|
||||||
|
highlighted: bool = False
|
||||||
|
highlighted_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class BriefingResponse(BaseModel):
|
class BriefingResponse(BaseModel):
|
||||||
@@ -94,6 +100,7 @@ def _build_response(b: MorningBriefing) -> BriefingResponse:
|
|||||||
for t in sorted(b.topics, key=lambda x: x.topic_rank):
|
for t in sorted(b.topics, key=lambda x: x.topic_rank):
|
||||||
topics.append(
|
topics.append(
|
||||||
TopicResponse(
|
TopicResponse(
|
||||||
|
id=t.id,
|
||||||
topic_rank=t.topic_rank,
|
topic_rank=t.topic_rank,
|
||||||
topic_label=t.topic_label,
|
topic_label=t.topic_label,
|
||||||
headline=t.headline,
|
headline=t.headline,
|
||||||
@@ -109,6 +116,10 @@ def _build_response(b: MorningBriefing) -> BriefingResponse:
|
|||||||
country_count=t.country_count,
|
country_count=t.country_count,
|
||||||
importance_score=t.importance_score,
|
importance_score=t.importance_score,
|
||||||
llm_fallback_used=t.llm_fallback_used,
|
llm_fallback_used=t.llm_fallback_used,
|
||||||
|
is_read=t.is_read,
|
||||||
|
read_at=t.read_at,
|
||||||
|
highlighted=t.highlighted,
|
||||||
|
highlighted_at=t.highlighted_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,3 +212,112 @@ async def regenerate(
|
|||||||
generation_ms=result["generation_ms"],
|
generation_ms=result["generation_ms"],
|
||||||
regenerated=result.get("regenerated", True),
|
regenerated=result.get("regenerated", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 2026-05-13 신규: 날짜 선택 + 카드 액션 ───
|
||||||
|
|
||||||
|
|
||||||
|
class BriefingDateSummary(BaseModel):
|
||||||
|
briefing_date: date_type
|
||||||
|
total_topics: int
|
||||||
|
total_articles: int
|
||||||
|
status: str
|
||||||
|
read_count: int # 사용자가 읽음 처리한 토픽 수
|
||||||
|
highlighted_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class TopicActionRequest(BaseModel):
|
||||||
|
value: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TopicActionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
is_read: bool
|
||||||
|
read_at: datetime | None
|
||||||
|
highlighted: bool
|
||||||
|
highlighted_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dates", response_model=list[BriefingDateSummary])
|
||||||
|
async def list_dates(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
limit: int = Query(default=60, ge=1, le=365),
|
||||||
|
):
|
||||||
|
"""사용 가능한 briefing 날짜 목록 (최신 desc). UI date picker 의 데이터 소스."""
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
MorningBriefing.briefing_date,
|
||||||
|
MorningBriefing.total_topics,
|
||||||
|
MorningBriefing.total_articles,
|
||||||
|
MorningBriefing.status,
|
||||||
|
func.count(case((BriefingTopic.is_read.is_(True), 1))).label("read_count"),
|
||||||
|
func.count(case((BriefingTopic.highlighted.is_(True), 1))).label("highlighted_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(BriefingTopic, BriefingTopic.briefing_id == MorningBriefing.id)
|
||||||
|
.group_by(MorningBriefing.id)
|
||||||
|
.order_by(MorningBriefing.briefing_date.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt)).all()
|
||||||
|
return [
|
||||||
|
BriefingDateSummary(
|
||||||
|
briefing_date=r.briefing_date,
|
||||||
|
total_topics=r.total_topics,
|
||||||
|
total_articles=r.total_articles,
|
||||||
|
status=r.status,
|
||||||
|
read_count=r.read_count or 0,
|
||||||
|
highlighted_count=r.highlighted_count or 0,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/topics/{topic_id}/read", response_model=TopicActionResponse)
|
||||||
|
async def set_topic_read(
|
||||||
|
topic_id: int,
|
||||||
|
body: TopicActionRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
):
|
||||||
|
"""토픽 카드 읽음 토글. value=true → 읽음 + read_at=now / false → 해제 + read_at=NULL."""
|
||||||
|
topic = await session.get(BriefingTopic, topic_id)
|
||||||
|
if topic is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"topic 없음 id={topic_id}")
|
||||||
|
topic.is_read = body.value
|
||||||
|
topic.read_at = datetime.now() if body.value else None
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(topic)
|
||||||
|
return TopicActionResponse(
|
||||||
|
id=topic.id,
|
||||||
|
is_read=topic.is_read,
|
||||||
|
read_at=topic.read_at,
|
||||||
|
highlighted=topic.highlighted,
|
||||||
|
highlighted_at=topic.highlighted_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/topics/{topic_id}/highlight", response_model=TopicActionResponse)
|
||||||
|
async def set_topic_highlight(
|
||||||
|
topic_id: int,
|
||||||
|
body: TopicActionRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
):
|
||||||
|
"""토픽 카드 하이라이트 토글. value=true → highlighted + highlighted_at=now / false → 해제."""
|
||||||
|
topic = await session.get(BriefingTopic, topic_id)
|
||||||
|
if topic is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"topic 없음 id={topic_id}")
|
||||||
|
topic.highlighted = body.value
|
||||||
|
topic.highlighted_at = datetime.now() if body.value else None
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(topic)
|
||||||
|
return TopicActionResponse(
|
||||||
|
id=topic.id,
|
||||||
|
is_read=topic.is_read,
|
||||||
|
read_at=topic.read_at,
|
||||||
|
highlighted=topic.highlighted,
|
||||||
|
highlighted_at=topic.highlighted_at,
|
||||||
|
)
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ class BriefingTopic(Base):
|
|||||||
llm_model: Mapped[str | None] = mapped_column(String(100))
|
llm_model: Mapped[str | None] = mapped_column(String(100))
|
||||||
llm_fallback_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
llm_fallback_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# 2026-05-13 카드별 사용자 액션 (date picker 와 동반).
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
highlighted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
highlighted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, default=datetime.now
|
DateTime(timezone=True), nullable=False, default=datetime.now
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
type BriefingTopic = {
|
type BriefingTopic = {
|
||||||
|
id: number;
|
||||||
topic_rank: number;
|
topic_rank: number;
|
||||||
topic_label: string;
|
topic_label: string;
|
||||||
headline: string;
|
headline: string;
|
||||||
@@ -32,6 +33,19 @@
|
|||||||
country_count: number;
|
country_count: number;
|
||||||
importance_score: number;
|
importance_score: number;
|
||||||
llm_fallback_used: boolean;
|
llm_fallback_used: boolean;
|
||||||
|
is_read: boolean;
|
||||||
|
read_at: string | null;
|
||||||
|
highlighted: boolean;
|
||||||
|
highlighted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BriefingDateSummary = {
|
||||||
|
briefing_date: string;
|
||||||
|
total_topics: number;
|
||||||
|
total_articles: number;
|
||||||
|
status: string;
|
||||||
|
read_count: number;
|
||||||
|
highlighted_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Briefing = {
|
type Briefing = {
|
||||||
@@ -75,18 +89,71 @@
|
|||||||
let briefing = $state<Briefing | null>(null);
|
let briefing = $state<Briefing | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let errorMsg = $state<string | null>(null);
|
let errorMsg = $state<string | null>(null);
|
||||||
|
// 2026-05-13 추가 — 날짜 선택 + 카드 액션
|
||||||
|
let availableDates = $state<BriefingDateSummary[]>([]);
|
||||||
|
let selectedDate = $state<string>(''); // YYYY-MM-DD ('' = 최신)
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadBriefing(dateStr: string) {
|
||||||
|
loading = true;
|
||||||
|
errorMsg = null;
|
||||||
try {
|
try {
|
||||||
briefing = await api<Briefing>('/briefing/latest');
|
const path = dateStr ? `/briefing?date=${dateStr}` : '/briefing/latest';
|
||||||
|
briefing = await api<Briefing>(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as ApiError;
|
const err = e as ApiError;
|
||||||
|
briefing = null;
|
||||||
errorMsg = err?.status === 404
|
errorMsg = err?.status === 404
|
||||||
? '아직 생성된 브리핑이 없습니다. 매일 새벽 05:10 KST 에 자동 생성됩니다.'
|
? (dateStr ? `${dateStr} 자에는 briefing 이 없습니다.` : '아직 생성된 브리핑이 없습니다. 매일 새벽 05:10 KST 에 자동 생성됩니다.')
|
||||||
: (err?.detail || '브리핑을 불러오지 못했습니다.');
|
: (err?.detail || '브리핑을 불러오지 못했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDates() {
|
||||||
|
try {
|
||||||
|
availableDates = await api<BriefingDateSummary[]>('/briefing/dates');
|
||||||
|
} catch {
|
||||||
|
availableDates = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateChange() {
|
||||||
|
loadBriefing(selectedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRead(topic: BriefingTopic) {
|
||||||
|
if (!briefing) return;
|
||||||
|
const next = !topic.is_read;
|
||||||
|
try {
|
||||||
|
const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>(
|
||||||
|
`/briefing/topics/${topic.id}/read`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ value: next }) }
|
||||||
|
);
|
||||||
|
topic.is_read = r.is_read;
|
||||||
|
topic.read_at = r.read_at;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('toggleRead failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleHighlight(topic: BriefingTopic) {
|
||||||
|
if (!briefing) return;
|
||||||
|
const next = !topic.highlighted;
|
||||||
|
try {
|
||||||
|
const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>(
|
||||||
|
`/briefing/topics/${topic.id}/highlight`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ value: next }) }
|
||||||
|
);
|
||||||
|
topic.highlighted = r.highlighted;
|
||||||
|
topic.highlighted_at = r.highlighted_at;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('toggleHighlight failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([loadDates(), loadBriefing('')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const fallbackPct = $derived(
|
const fallbackPct = $derived(
|
||||||
@@ -97,8 +164,29 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
|
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
|
||||||
<header class="space-y-1">
|
<header class="space-y-2">
|
||||||
<h1 class="text-xl font-semibold">야간 뉴스 브리핑</h1>
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<h1 class="text-xl font-semibold">야간 뉴스 브리핑</h1>
|
||||||
|
{#if availableDates.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="briefing-date" class="text-xs text-dim">날짜</label>
|
||||||
|
<select
|
||||||
|
id="briefing-date"
|
||||||
|
bind:value={selectedDate}
|
||||||
|
onchange={onDateChange}
|
||||||
|
class="text-sm border border-default rounded-md px-2 py-1 bg-surface"
|
||||||
|
>
|
||||||
|
<option value="">최신</option>
|
||||||
|
{#each availableDates as d}
|
||||||
|
<option value={d.briefing_date}>
|
||||||
|
{d.briefing_date} · {d.total_topics}토픽
|
||||||
|
{#if d.highlighted_count > 0}⭐{d.highlighted_count}{/if}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<p class="text-sm text-dim">
|
<p class="text-sm text-dim">
|
||||||
{#if briefing}
|
{#if briefing}
|
||||||
{briefing.briefing_date} 새벽 수집 · 총 {briefing.total_articles}건 / {briefing.total_countries}개국 / {briefing.total_topics}개 토픽
|
{briefing.briefing_date} 새벽 수집 · 총 {briefing.total_articles}건 / {briefing.total_countries}개국 / {briefing.total_topics}개 토픽
|
||||||
@@ -137,8 +225,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each briefing.topics as topic (topic.topic_rank)}
|
{#each briefing.topics as topic (topic.id)}
|
||||||
<Card>
|
<div class:opacity-60={topic.is_read}>
|
||||||
|
<Card class={topic.highlighted ? "ring-2 ring-yellow-400" : ""}>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-xs text-faint shrink-0 pt-1">#{topic.topic_rank}</span>
|
<span class="text-xs text-faint shrink-0 pt-1">#{topic.topic_rank}</span>
|
||||||
@@ -154,6 +243,24 @@
|
|||||||
{topic.country_count}개국 · {topic.article_count}건
|
{topic.country_count}개국 · {topic.article_count}건
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleHighlight(topic)}
|
||||||
|
class="text-base leading-none px-1.5 py-0.5 rounded hover:bg-surface"
|
||||||
|
class:text-yellow-500={topic.highlighted}
|
||||||
|
class:text-faint={!topic.highlighted}
|
||||||
|
title={topic.highlighted ? '하이라이트 해제' : '하이라이트'}
|
||||||
|
aria-label="하이라이트 토글"
|
||||||
|
>★</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => toggleRead(topic)}
|
||||||
|
class="text-xs px-1.5 py-0.5 rounded border border-default hover:bg-surface"
|
||||||
|
title={topic.is_read ? '읽지 않음으로' : '읽음 처리'}
|
||||||
|
aria-label="읽음 토글"
|
||||||
|
>{topic.is_read ? '✓읽음' : '읽음'}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if topic.country_perspectives.length > 0}
|
{#if topic.country_perspectives.length > 0}
|
||||||
@@ -210,6 +317,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 2026-05-13 briefing topic 읽음 표시 — UI 의 카드별 액션.
|
||||||
|
ALTER TABLE briefing_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS is_read BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 2026-05-13 briefing topic 읽음 시각 — read 토글 시 now() 설정 / 해제 시 NULL.
|
||||||
|
ALTER TABLE briefing_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 2026-05-13 briefing topic 하이라이트 — UI 의 카드별 액션.
|
||||||
|
ALTER TABLE briefing_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS highlighted BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 2026-05-13 briefing topic 하이라이트 시각 — highlight 토글 시 now() 설정 / 해제 시 NULL.
|
||||||
|
ALTER TABLE briefing_topics
|
||||||
|
ADD COLUMN IF NOT EXISTS highlighted_at TIMESTAMPTZ;
|
||||||
Reference in New Issue
Block a user