Skip to content

Commit 5863c59

Browse files
brucezyclaude
andcommitted
feat: add SignalDetail component
Implements SignalDetail.tsx (Task 3) with Why Fired metrics, Competitor Breakdown table, and Recommended Actions with expandable QueryDrillDown toggle. Also adds QueryDrillDown stub to unblock TypeScript compilation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 036cfa9 commit 5863c59

File tree

2 files changed

+336
-0
lines changed

2 files changed

+336
-0
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Stub — will be fully implemented in Task 4
2+
interface Props {
3+
signal_type: string
4+
brand_id: number
5+
segment: string
6+
date: string
7+
action_type: string
8+
}
9+
10+
export default function QueryDrillDown(_props: Props) {
11+
return null
12+
}
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { useQuery } from "@tanstack/react-query"
2+
import { useState } from "react"
3+
import { getAuthToken } from "@/clients/auth-helper"
4+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
5+
import QueryDrillDown from "./QueryDrillDown"
6+
7+
// ── Types ────────────────────────────────────────────────────────────────────
8+
9+
interface SignalDetailResponse {
10+
signal: {
11+
type: string
12+
severity: string
13+
score: number
14+
created_date: string
15+
}
16+
why_fired: {
17+
explanation: string
18+
metrics: Array<{
19+
label: string
20+
value: string
21+
from?: string
22+
to?: string
23+
status: string
24+
}>
25+
}
26+
competitors: Array<{
27+
name: string
28+
sov: number
29+
ssi: number
30+
position_strength: number
31+
trend_7d: number
32+
is_target?: boolean
33+
is_top_threat?: boolean
34+
}>
35+
recommendations: Array<{
36+
priority: number
37+
title: string
38+
detail: string
39+
action_type: string
40+
}>
41+
}
42+
43+
interface Props {
44+
selectedSignal: { signal_type: string; segment: string; date: string } | null
45+
brandId: number
46+
}
47+
48+
// ── API helpers ──────────────────────────────────────────────────────────────
49+
50+
const API_BASE_URL = import.meta.env.VITE_API_URL ?? ""
51+
const API_PREFIX = "/api/v1"
52+
53+
async function fetchSignalDetail(
54+
signal_type: string,
55+
brand_id: number,
56+
segment: string,
57+
date: string,
58+
): Promise<SignalDetailResponse> {
59+
const token = getAuthToken()
60+
const params = new URLSearchParams({
61+
brand_id: String(brand_id),
62+
segment,
63+
date,
64+
})
65+
const url = `${API_BASE_URL}${API_PREFIX}/insights/signal/${encodeURIComponent(signal_type)}?${params.toString()}`
66+
const res = await fetch(url, {
67+
headers: {
68+
...(token ? { Authorization: `Bearer ${token}` } : {}),
69+
"Content-Type": "application/json",
70+
},
71+
})
72+
if (!res.ok) {
73+
throw new Error(`Failed to fetch signal detail: ${res.status}`)
74+
}
75+
return res.json() as Promise<SignalDetailResponse>
76+
}
77+
78+
// ── Status color helpers ─────────────────────────────────────────────────────
79+
80+
function metricValueColor(status: string): string {
81+
switch (status) {
82+
case "critical":
83+
return "text-red-400"
84+
case "warning":
85+
case "fragile":
86+
return "text-amber-400"
87+
default:
88+
return "text-slate-300"
89+
}
90+
}
91+
92+
// ── Sub-components ───────────────────────────────────────────────────────────
93+
94+
function SkeletonDetail() {
95+
return (
96+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
97+
{[1, 2, 3].map((i) => (
98+
<div key={i} className="rounded-lg bg-slate-900 border border-slate-700 p-4 space-y-3">
99+
<div className="h-4 w-40 bg-slate-700 rounded animate-pulse" />
100+
<div className="h-3 w-full bg-slate-800 rounded animate-pulse" />
101+
<div className="h-3 w-5/6 bg-slate-800 rounded animate-pulse" />
102+
<div className="flex gap-3 mt-2">
103+
{[1, 2, 3, 4].map((j) => (
104+
<div key={j} className="flex-1 h-14 bg-slate-800 rounded animate-pulse" />
105+
))}
106+
</div>
107+
</div>
108+
))}
109+
</div>
110+
)
111+
}
112+
113+
interface MetricTileProps {
114+
label: string
115+
value: string
116+
status: string
117+
}
118+
119+
function MetricTile({ label, value, status }: MetricTileProps) {
120+
return (
121+
<div className="flex-1 min-w-0 bg-slate-800 rounded-lg p-3 flex flex-col gap-1">
122+
<span className={`text-lg font-bold leading-tight ${metricValueColor(status)}`}>
123+
{value}
124+
</span>
125+
<span className="text-[11px] text-slate-500 leading-tight">{label}</span>
126+
</div>
127+
)
128+
}
129+
130+
function TrendValue({ value }: { value: number }) {
131+
if (value > 0) {
132+
return (
133+
<span className="text-green-400 text-xs">
134+
+{value.toFixed(1)}%
135+
</span>
136+
)
137+
}
138+
if (value < 0) {
139+
return (
140+
<span className="text-red-400 text-xs">
141+
{value.toFixed(1)}%
142+
</span>
143+
)
144+
}
145+
return <span className="text-slate-500 text-xs">0.0%</span>
146+
}
147+
148+
interface ActionItemProps {
149+
rec: SignalDetailResponse["recommendations"][number]
150+
index: number
151+
brandId: number
152+
selectedSignal: NonNullable<Props["selectedSignal"]>
153+
}
154+
155+
function ActionItem({ rec, index, brandId, selectedSignal }: ActionItemProps) {
156+
const [expanded, setExpanded] = useState(false)
157+
158+
return (
159+
<div className="py-3 border-b border-slate-800 last:border-0">
160+
<div className="flex gap-3">
161+
<span className="shrink-0 w-5 h-5 rounded-full bg-slate-700 text-slate-300 text-[11px] font-bold flex items-center justify-center mt-0.5">
162+
{index + 1}
163+
</span>
164+
<div className="flex-1 min-w-0">
165+
<p className="text-sm font-semibold text-slate-200 leading-snug">{rec.title}</p>
166+
<p className="text-xs text-slate-500 mt-0.5 leading-snug">{rec.detail}</p>
167+
<button
168+
type="button"
169+
onClick={() => setExpanded((v) => !v)}
170+
className="mt-1.5 text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
171+
>
172+
{expanded ? "Hide ▴" : "View queries ▾"}
173+
</button>
174+
{expanded && (
175+
<div className="mt-3">
176+
<QueryDrillDown
177+
signal_type={selectedSignal.signal_type}
178+
brand_id={brandId}
179+
segment={selectedSignal.segment}
180+
date={selectedSignal.date}
181+
action_type={rec.action_type}
182+
/>
183+
</div>
184+
)}
185+
</div>
186+
</div>
187+
</div>
188+
)
189+
}
190+
191+
// ── Main component ───────────────────────────────────────────────────────────
192+
193+
export function SignalDetail({ selectedSignal, brandId }: Props) {
194+
const { data, isLoading } = useQuery<SignalDetailResponse>({
195+
queryKey: ["signal-detail", selectedSignal],
196+
queryFn: () => {
197+
if (!selectedSignal) throw new Error("No signal selected")
198+
return fetchSignalDetail(
199+
selectedSignal.signal_type,
200+
brandId,
201+
selectedSignal.segment,
202+
selectedSignal.date,
203+
)
204+
},
205+
enabled: selectedSignal !== null,
206+
})
207+
208+
if (!selectedSignal) {
209+
return (
210+
<div className="flex-1 flex items-center justify-center text-slate-500 text-sm">
211+
Select a signal to see details.
212+
</div>
213+
)
214+
}
215+
216+
if (isLoading || !data) {
217+
return <SkeletonDetail />
218+
}
219+
220+
const { why_fired, competitors, recommendations } = data
221+
222+
return (
223+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
224+
{/* Section 1: Why Fired */}
225+
<Card className="bg-slate-900 border-slate-700 text-slate-200">
226+
<CardHeader className="pb-2">
227+
<CardTitle className="text-sm font-semibold text-slate-300">
228+
Why This Signal Fired
229+
</CardTitle>
230+
</CardHeader>
231+
<CardContent className="space-y-4">
232+
<p className="text-sm text-slate-400 leading-relaxed">{why_fired.explanation}</p>
233+
{why_fired.metrics.length > 0 && (
234+
<div className="flex gap-3 flex-wrap">
235+
{why_fired.metrics.map((m) => (
236+
<MetricTile
237+
key={m.label}
238+
label={m.label}
239+
value={m.value}
240+
status={m.status}
241+
/>
242+
))}
243+
</div>
244+
)}
245+
</CardContent>
246+
</Card>
247+
248+
{/* Section 2: Competitor Breakdown */}
249+
{competitors.length > 0 && (
250+
<Card className="bg-slate-900 border-slate-700 text-slate-200">
251+
<CardHeader className="pb-2">
252+
<CardTitle className="text-sm font-semibold text-slate-300">
253+
Competitor Breakdown
254+
</CardTitle>
255+
</CardHeader>
256+
<CardContent className="p-0">
257+
<table className="w-full text-xs">
258+
<thead>
259+
<tr className="border-b border-slate-700">
260+
<th className="px-4 py-2 text-left text-slate-500 font-medium">Brand</th>
261+
<th className="px-3 py-2 text-right text-slate-500 font-medium">SOV</th>
262+
<th className="px-3 py-2 text-right text-slate-500 font-medium">SSI</th>
263+
<th className="px-3 py-2 text-right text-slate-500 font-medium">Pos. Strength</th>
264+
<th className="px-3 py-2 text-right text-slate-500 font-medium">7d Trend</th>
265+
</tr>
266+
</thead>
267+
<tbody>
268+
{competitors.map((c) => {
269+
const rowBorder = c.is_target
270+
? "border-l-2 border-blue-500"
271+
: c.is_top_threat
272+
? "border-l-2 border-red-500"
273+
: ""
274+
return (
275+
<tr
276+
key={c.name}
277+
className={`border-b border-slate-800 last:border-0 ${rowBorder}`}
278+
>
279+
<td className="px-4 py-2 text-slate-200 font-medium">{c.name}</td>
280+
<td className="px-3 py-2 text-right text-slate-400">
281+
{c.sov.toFixed(1)}%
282+
</td>
283+
<td className="px-3 py-2 text-right text-slate-400">
284+
{c.ssi.toFixed(1)}%
285+
</td>
286+
<td className="px-3 py-2 text-right text-slate-400">
287+
{c.position_strength.toFixed(1)}%
288+
</td>
289+
<td className="px-3 py-2 text-right">
290+
<TrendValue value={c.trend_7d} />
291+
</td>
292+
</tr>
293+
)
294+
})}
295+
</tbody>
296+
</table>
297+
</CardContent>
298+
</Card>
299+
)}
300+
301+
{/* Section 3: Recommended Actions */}
302+
{recommendations.length > 0 && (
303+
<Card className="bg-slate-900 border-slate-700 text-slate-200">
304+
<CardHeader className="pb-2">
305+
<CardTitle className="text-sm font-semibold text-slate-300">
306+
Recommended Actions
307+
</CardTitle>
308+
</CardHeader>
309+
<CardContent className="pt-0">
310+
{recommendations.map((rec, i) => (
311+
<ActionItem
312+
key={rec.action_type}
313+
rec={rec}
314+
index={i}
315+
brandId={brandId}
316+
selectedSignal={selectedSignal}
317+
/>
318+
))}
319+
</CardContent>
320+
</Card>
321+
)}
322+
</div>
323+
)
324+
}

0 commit comments

Comments
 (0)