Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 79 additions & 24 deletions frontend/src/features/history/ui/ScoreTrend.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import type { UserStats } from '../api/historyApi'

// 최근 종합 점수 추이를 라이브러리 없이 inline 막대로. recent 는 최신순이라 뒤집어 시간순으로.
const W = 320
const H = 140
const PAD = { l: 26, r: 10, t: 18, b: 10 }
const IW = W - PAD.l - PAD.r
const IH = H - PAD.t - PAD.b
const GRID = [0, 50, 100]

const clamp = (v: number) => Math.max(0, Math.min(100, v))

// 종합 점수 추이를 라이브러리 없이 SVG 추세선(라인+영역)으로. recent 는 최신순이라 뒤집어 시간순으로.
export function ScoreTrend({ stats }: { stats: UserStats }) {
const points = [...(stats.recent ?? [])]
.reverse()
.filter((r) => typeof r.overall === 'number')
.map((r) => ({ sessionId: r.sessionId, score: clamp(r.overall as number) }))

if (points.length === 0) {
return (
Expand All @@ -15,36 +25,81 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
)
}

const n = points.length
const sx = (i: number) => (n <= 1 ? PAD.l + IW / 2 : PAD.l + (IW * i) / (n - 1))
const sy = (s: number) => PAD.t + IH * (1 - s / 100)
const data = points.map((p, i) => ({ ...p, x: sx(i), y: sy(p.score) }))
const linePts = data.map((d) => `${d.x.toFixed(1)},${d.y.toFixed(1)}`).join(' ')
const areaPts = `${data[0].x.toFixed(1)},${PAD.t + IH} ${linePts} ${data[n - 1].x.toFixed(1)},${PAD.t + IH}`

return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-surface-raised p-5 shadow-sm">
<span className="text-caption text-fg-muted">종합 점수 추이 (최근 {points.length}회)</span>
<div
className="flex h-32 items-end gap-2"
<span className="text-caption text-fg-muted">종합 점수 추이 (최근 {n}회)</span>
<svg
viewBox={`0 0 ${W} ${H}`}
className="h-36 w-full"
preserveAspectRatio="none"
role="img"
aria-label={`종합 점수 추이, 최근 ${points.length}회: ${points
.map((r) => `${Math.round(Math.max(0, Math.min(100, r.overall as number)))}점`)
.join(', ')}`}
aria-label={`종합 점수 추이, 최근 ${n}회: ${data.map((d) => `${d.score}점`).join(', ')}`}
>
{points.map((r) => {
const score = Math.max(0, Math.min(100, r.overall as number))
{/* y축 가이드라인 + 눈금(0/50/100) */}
{GRID.map((g) => {
const y = sy(g)
return (
<div
key={r.sessionId}
className="flex flex-1 flex-col items-center gap-1"
aria-hidden
>
<div className="flex w-full flex-1 items-end">
<div
className="w-full rounded-t bg-primary"
style={{ height: `${Math.max(score, 2)}%` }}
title={`${Math.round(score)}점`}
/>
</div>
<span className="text-caption text-fg-muted">{Math.round(score)}</span>
</div>
<g key={g}>
<line
x1={PAD.l}
y1={y}
x2={W - PAD.r}
y2={y}
style={{ stroke: 'var(--color-border)' }}
strokeWidth={1}
/>
<text
x={PAD.l - 5}
y={y + 3}
textAnchor="end"
style={{ fill: 'var(--color-fg-muted)' }}
fontSize={9}
>
{g}
</text>
</g>
)
})}
</div>

{/* 영역 + 추세선 (점 2개 이상일 때) */}
{n >= 2 && (
<>
<polygon points={areaPts} style={{ fill: 'var(--color-primary)' }} fillOpacity={0.12} />
<polyline
points={linePts}
fill="none"
style={{ stroke: 'var(--color-primary)' }}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
/>
</>
)}

{/* 데이터 포인트 + 값 라벨 */}
{data.map((d) => (
<g key={d.sessionId}>
<circle cx={d.x} cy={d.y} r={3} style={{ fill: 'var(--color-primary)' }} />
<text
x={d.x}
y={d.y - 7}
textAnchor="middle"
style={{ fill: 'var(--color-fg)' }}
fontSize={10}
fontWeight={600}
>
{d.score}
</text>
</g>
))}
</svg>
</section>
)
}
3 changes: 3 additions & 0 deletions frontend/src/features/interview/model/useLiveInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export function useLiveInterview(sessionId: number, deliveryMode: DeliveryMode =
const action = interviewEventAction(frame.event)
if (action.kind === 'refetch-messages') {
void queryClient.invalidateQueries({ queryKey: messageKeys.list(sessionId) })
// 헤더 질문 카운트(질문 N/M)는 session.totalQuestionCount 기반이라,
// 새 메시지(질문)마다 세션도 갱신해 진행도가 라이브로 반영되게 한다.
void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
} else if (action.kind === 'refetch-session') {
void queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
} else if (action.kind === 'redirect-feedback') {
Expand Down