import { useEffect, useRef, useState } from 'react'; import { apiClient, errorMessage, InsightMeta, TrendInsight as Insight, } from '../services/api'; import './TrendInsight.css'; /** * Metric ids the backend's feature engineering knows, keyed by the id this app * uses. Two names differ (the sleep shares), the rest are identical. * * Kept as an explicit list rather than sent optimistically: the endpoint 400s * on an unknown metric, and a button that reliably fails is worse than no * button on the metrics this cannot explain. */ const BACKEND_METRIC: Record = { steps: 'steps', intensityMinutes: 'intensityMinutes', heartRate: 'heartRate', heartRateVariability: 'heartRateVariability', stress: 'stress', bodyBatteryHigh: 'bodyBatteryHigh', respirationAvg: 'respirationAvg', sleepDuration: 'sleepDuration', sleepQuality: 'sleepQuality', deepShare: 'sleepDeepPct', remShare: 'sleepRemPct', trainingReadiness: 'trainingReadiness', enduranceScore: 'enduranceScore', }; export function supportsInsight(metricId: string) { return metricId in BACKEND_METRIC; } const CONFIDENCE_LABEL: Record = { high: '证据充分', medium: '证据一般', low: '证据薄弱', }; interface Props { /** This app's metric id, e.g. `heartRateVariability`. */ metricId: string; startDate: string; endDate: string; } /** * AI attribution for the span currently on the chart. * * The product spec asks for this on a brush selection over a desktop chart. * On a phone the equivalent gesture is the range selector that is already * there, so the panel explains whatever window the user has selected rather * than adding a drag interaction that fights the page's own scrolling. * * On demand, never on load: one generation takes minutes on the gateway, so * running it for every metric a user browses past would spend that on nothing. */ function TrendInsight({ metricId, startDate, endDate }: Props) { const [insight, setInsight] = useState(null); const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const live = useRef(true); useEffect(() => { live.current = true; return () => { live.current = false; }; }, []); // A new window is a different question; clear the old answer rather than // leaving it under a chart it no longer describes. useEffect(() => { setInsight(null); setMeta(null); setError(''); }, [metricId, startDate, endDate]); const run = async (refresh?: boolean) => { const backend = BACKEND_METRIC[metricId]; if (!backend || loading) return; setLoading(true); setError(''); try { const data = await apiClient.getTrendInsight(backend, startDate, endDate, refresh); if (!live.current) return; setInsight(data.insight); setMeta(data.meta); } catch (err: any) { if (live.current) setError(errorMessage(err, '归因分析失败')); } finally { if (live.current) setLoading(false); } }; if (!supportsInsight(metricId)) return null; return (

AI 归因

{insight && !loading && ( )}
{!insight && !loading && !error && ( <>

分析 {startDate} ~ {endDate} 这段区间内该指标的变化及其关联因素。

)} {loading && (
)} {error &&

{error}

} {insight && (
{insight.summary &&

{insight.summary}

} {insight.drivers.map((d) => (
{d.factor}

{d.detail}

))} {insight.caution &&

{insight.caution}

}
{meta?.source === 'ai' ? `AI 生成${meta.upstream ? ` · ${meta.upstream}` : ''}` : '规则引擎'} {CONFIDENCE_LABEL[insight.confidence]}
)}
); } export default TrendInsight;