Lumea / frontend /src /components /RecommendationsPanel.tsx
Hetansh Waghela
Add code for Integrate memory and knowledge graph services with profile data synchronization and new dashboard components.
b812380
Raw
History Blame Contribute Delete
17.3 kB
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Lightbulb,
AlertTriangle,
AlertCircle,
Info,
ChevronRight,
ChevronDown,
ChevronUp,
Shield,
RefreshCw,
CheckCircle2,
Dumbbell,
Apple,
Moon,
Heart,
Droplets,
TestTube,
Stethoscope,
Sparkles,
XCircle,
} from 'lucide-react';
import { API_BASE_URL } from '../config/api';
import './RecommendationsPanel.css';
// Types
interface Action {
type: string;
text: string;
}
interface Source {
name: string;
url?: string;
}
interface Provenance {
memory?: boolean;
graph?: boolean;
metrics?: boolean;
profile?: boolean;
}
interface Recommendation {
id: string;
title: string;
severity: 'URGENT' | 'WARNING' | 'INFO';
why: string;
actions: Action[];
followup: Action[];
sources: Source[];
provenance?: Provenance;
}
interface RecommendationsResponse {
updated_at: string;
disclaimer: string;
items: Recommendation[];
total_count: number;
urgent_count: number;
warning_count: number;
}
/** Stored API returns priority as "high"|"medium"|"low" and description instead of why */
interface StoredItem {
id: string;
title: string;
description?: string;
priority: string;
actions?: string[] | { type?: string; text: string }[];
evidence?: string[];
provenance?: Provenance;
}
interface RecommendationsPanelProps {
authToken?: string;
apiBaseUrl?: string;
maxInitialDisplay?: number;
refreshTrigger?: number; // Increment this to trigger a refresh (from WebSocket events)
showGenerateButton?: boolean; // Show generate button for generating new recommendations
onRecommendationsGenerated?: () => void; // Callback after generating recommendations
variant?: 'dashboard' | 'page'; // Layout variant
}
// Action type to icon mapping
const actionIcons: Record<string, React.ReactNode> = {
EXERCISE: <Dumbbell size={14} />,
DIET: <Apple size={14} />,
HABIT: <Sparkles size={14} />,
SLEEP: <Moon size={14} />,
STRESS: <Heart size={14} />,
HYDRATION: <Droplets size={14} />,
TEST: <TestTube size={14} />,
DOCTOR: <Stethoscope size={14} />,
GENERAL: <CheckCircle2 size={14} />,
};
// Severity icons
const severityIcons: Record<string, React.ReactNode> = {
URGENT: <AlertCircle size={18} />,
WARNING: <AlertTriangle size={18} />,
INFO: <Info size={18} />,
};
function mapStoredToRecommendation(stored: StoredItem): Recommendation {
const severityMap = { high: 'URGENT' as const, medium: 'WARNING' as const, low: 'INFO' as const };
const severity = severityMap[stored.priority as keyof typeof severityMap] ?? 'INFO';
const rawActions = stored.actions ?? [];
const actions: Action[] = rawActions.map((a) => {
if (typeof a === 'string') return { type: 'GENERAL' as const, text: a };
const text = a?.text != null ? String(a.text) : '';
return { type: ((a?.type as Action['type']) ?? 'GENERAL'), text };
});
return {
id: stored.id,
title: stored.title ?? '',
severity,
why: stored.description ?? '',
actions,
followup: [],
sources: [],
provenance: stored.provenance,
};
}
// Provenance tooltip component - shows data sources
const ProvenanceTooltip: React.FC<{ provenance?: Provenance }> = ({ provenance }) => {
if (!provenance) return null;
const sources = [];
if (provenance.memory) sources.push({ label: 'Memory', icon: '🧠', desc: 'Your preferences & facts' });
if (provenance.graph) sources.push({ label: 'Graph', icon: '🕸️', desc: 'Health relationships' });
if (provenance.metrics) sources.push({ label: 'Metrics', icon: '📊', desc: 'Lab values & observations' });
if (provenance.profile) sources.push({ label: 'Profile', icon: '👤', desc: 'Your health profile' });
if (sources.length === 0) return null;
return (
<div className="provenance-badge" title="Powered by">
<span className="provenance-trigger">✨ Sources</span>
<div className="provenance-tooltip">
<span className="provenance-title">Recommendation Sources</span>
{sources.map((src, i) => (
<div key={i} className="provenance-item">
<span className="provenance-icon">{src.icon}</span>
<span className="provenance-label">{src.label}</span>
<span className="provenance-desc">{src.desc}</span>
</div>
))}
</div>
</div>
);
};
const RecommendationsPanel: React.FC<RecommendationsPanelProps> = ({
authToken,
apiBaseUrl = API_BASE_URL,
maxInitialDisplay = 3,
refreshTrigger = 0,
showGenerateButton = true,
onRecommendationsGenerated,
variant = 'dashboard',
}) => {
const [recommendations, setRecommendations] = useState<RecommendationsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
const fetchRecommendations = useCallback(async () => {
if (!authToken) {
setError('Authentication required');
setLoading(false);
return;
}
setLoading(true);
setError(null);
const headers = {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
};
try {
// Prefer stored (Grok-generated) when available; otherwise use rule-based
const [storedRes, ruleRes] = await Promise.all([
fetch(`${apiBaseUrl}/api/recommendations/stored`, { headers }),
fetch(`${apiBaseUrl}/api/recommendations`, { headers }),
]);
if (storedRes.ok) {
const storedData = await storedRes.json();
if (storedData.items?.length > 0) {
const mapped: RecommendationsResponse = {
...storedData,
items: storedData.items.map((item: StoredItem) => mapStoredToRecommendation(item)),
};
setRecommendations(mapped);
return;
}
}
if (!ruleRes.ok) {
throw new Error(`Failed to fetch recommendations: ${ruleRes.status}`);
}
const ruleData: RecommendationsResponse = await ruleRes.json();
setRecommendations(ruleData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load recommendations');
} finally {
setLoading(false);
}
}, [authToken, apiBaseUrl]);
useEffect(() => {
fetchRecommendations();
}, [fetchRecommendations]);
// Refresh when refreshTrigger changes (triggered by WebSocket events from parent)
useEffect(() => {
if (refreshTrigger > 0) {
fetchRecommendations();
}
}, [refreshTrigger, fetchRecommendations]);
// Generate new recommendations
const handleGenerateRecommendations = async () => {
if (!authToken) return;
setGenerating(true);
setError(null);
try {
const response = await fetch(`${apiBaseUrl}/api/recommendations/generate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
await fetchRecommendations();
onRecommendationsGenerated?.();
} else {
let message = `Generate failed (${response.status})`;
try {
const body = await response.json();
if (body?.error && typeof body.error === 'string') {
message = body.error;
}
} catch {
// ignore parse error
}
setError(message);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to generate recommendations';
setError(message);
console.error('Error generating recommendations:', err);
} finally {
setGenerating(false);
}
};
const toggleCardExpanded = (id: string) => {
setExpandedCards(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const displayedItems = recommendations?.items.slice(
0,
expanded ? undefined : maxInitialDisplay
) || [];
const hasMore = (recommendations?.items.length || 0) > maxInitialDisplay;
if (loading) {
return (
<div className="recommendations-panel">
<div className="recommendations-loading">
<div className="spinner" />
<p>Loading personalized recommendations...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="recommendations-panel">
<div className="recommendations-error">
<XCircle size={48} />
<p>{error}</p>
<button className="retry-button" onClick={fetchRecommendations}>
<RefreshCw size={16} />
Retry
</button>
</div>
</div>
);
}
if (!recommendations || recommendations.items.length === 0) {
return (
<div className={`recommendations-panel ${variant === 'page' ? 'recommendations-panel-page' : ''}`}>
<div className="recommendations-header">
<h2>
<Lightbulb size={22} />
Recommendations
</h2>
{showGenerateButton && (
<button
className="recommendations-generate-btn"
onClick={handleGenerateRecommendations}
disabled={generating}
>
{generating ? (
<><RefreshCw size={16} className="spinning" /> Generating...</>
) : (
<><Sparkles size={16} /> Generate</>)}
</button>
)}
</div>
<div className="recommendations-empty">
<Sparkles size={48} />
<h3>No recommendations yet</h3>
<p>
Generate personalized recommendations based on your health profile and reports.
</p>
{showGenerateButton && (
<button
className="recommendations-generate-btn recommendations-generate-btn-lg"
onClick={handleGenerateRecommendations}
disabled={generating}
>
{generating ? (
<><RefreshCw size={18} className="spinning" /> Generating...</>
) : (
<><Sparkles size={18} /> Generate Recommendations</>)}
</button>
)}
</div>
<div className="recommendations-disclaimer">
<Shield size={18} />
<p>{recommendations?.disclaimer || 'This is wellness guidance, not medical advice.'}</p>
</div>
</div>
);
}
return (
<div className={`recommendations-panel ${variant === 'page' ? 'recommendations-panel-page' : ''}`}>
<div className="recommendations-header">
<div className="recommendations-header-left">
<h2>
<Lightbulb size={22} />
Recommendations
</h2>
{showGenerateButton && (
<button
className="recommendations-generate-btn"
onClick={handleGenerateRecommendations}
disabled={generating}
>
{generating ? (
<><RefreshCw size={16} className="spinning" /> Generating...</>
) : (
<><RefreshCw size={16} /> Refresh</>)}
</button>
)}
</div>
<div className="recommendations-badges">
{recommendations.urgent_count > 0 && (
<span className="badge urgent">
<AlertCircle size={14} />
{recommendations.urgent_count} Urgent
</span>
)}
{recommendations.warning_count > 0 && (
<span className="badge warning">
<AlertTriangle size={14} />
{recommendations.warning_count} Attention
</span>
)}
{recommendations.total_count - recommendations.urgent_count - recommendations.warning_count > 0 && (
<span className="badge info">
<Info size={14} />
{recommendations.total_count - recommendations.urgent_count - recommendations.warning_count} Tips
</span>
)}
</div>
</div>
<div className="recommendations-list">
<AnimatePresence mode="popLayout">
{displayedItems.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: index * 0.05 }}
className={`recommendation-card ${(item.severity ?? 'info').toLowerCase()}`}
>
<div className="recommendation-header">
<h3 className="recommendation-title">{item.title ?? ''}</h3>
<div className="recommendation-header-right">
<ProvenanceTooltip provenance={item.provenance} />
<span className={`severity-tag ${(item.severity ?? 'info').toLowerCase()}`}>
{severityIcons[item.severity ?? 'INFO']}
{item.severity ?? 'INFO'}
</span>
</div>
</div>
<p className="recommendation-why">{item.why}</p>
<div className="recommendation-actions">
<h4>Suggested Actions</h4>
<div className="actions-list">
{(item.actions ?? []).slice(0, expandedCards.has(item.id) ? undefined : 3).map((action, idx) => (
<span key={idx} className={`action-tag ${(action.type ?? 'general').toLowerCase()}`}>
{actionIcons[action.type] || <ChevronRight size={14} />}
{action.text ?? ''}
</span>
))}
{(item.actions ?? []).length > 3 && !expandedCards.has(item.id) && (
<button
className="action-tag"
onClick={() => toggleCardExpanded(item.id)}
style={{ cursor: 'pointer', background: '#f0f0f0' }}
>
+{(item.actions ?? []).length - 3} more
</button>
)}
</div>
</div>
{(item.followup ?? []).length > 0 && (
<div className="recommendation-followup">
<h4>Follow-up</h4>
<div className="followup-list">
{(item.followup ?? []).map((follow, idx) => (
<div key={idx} className="followup-item">
{actionIcons[follow.type] || <ChevronRight size={14} />}
{follow.text ?? ''}
</div>
))}
</div>
</div>
)}
{(item.sources ?? []).length > 0 && expandedCards.has(item.id) && (
<div className="recommendation-sources">
<div className="sources-list">
{(item.sources ?? []).map((source, idx) => (
source.url ? (
<a
key={idx}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="source-link"
>
📎 {source.name ?? 'Source'}
</a>
) : (
<span key={idx} className="source-link">
📎 {source.name ?? 'Source'}
</span>
)
))}
</div>
</div>
)}
{((item.actions ?? []).length > 3 || (item.sources ?? []).length > 0) && (
<button
className="expand-toggle"
onClick={() => toggleCardExpanded(item.id)}
style={{ marginTop: '12px' }}
>
{expandedCards.has(item.id) ? (
<>
<ChevronUp size={16} />
Show less
</>
) : (
<>
<ChevronDown size={16} />
Show more details
</>
)}
</button>
)}
</motion.div>
))}
</AnimatePresence>
</div>
{hasMore && (
<button className="expand-toggle" onClick={() => setExpanded(!expanded)}>
{expanded ? (
<>
<ChevronUp size={16} />
Show fewer recommendations
</>
) : (
<>
<ChevronDown size={16} />
Show {recommendations.items.length - maxInitialDisplay} more recommendations
</>
)}
</button>
)}
<div className="recommendations-disclaimer">
<Shield size={18} />
<p>{recommendations.disclaimer}</p>
</div>
</div>
);
};
export default RecommendationsPanel;