diff --git a/web/normogen-web/package-lock.json b/web/normogen-web/package-lock.json index 8cafad0..c189c7c 100644 --- a/web/normogen-web/package-lock.json +++ b/web/normogen-web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.11", "@mui/material": "^7.3.9", "@mui/x-charts": "^8.27.4", "axios": "^1.13.6", @@ -1127,26 +1128,52 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.9.tgz", - "integrity": "sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==", + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", + "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, + "node_modules/@mui/icons-material": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.11.tgz", + "integrity": "sha512-+hz5ilwHZ3djd5es3sCErLioqe/NhZcYTsV/TNXZAMdJdb23F4xzJjqnnZdnurc3S1+ietcssRNqieOhPQLZ7Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.11", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.9.tgz", - "integrity": "sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==", + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.11.tgz", + "integrity": "sha512-yq8bPc3LxOwKRWpcjRgDkYFmpM6aKlARfESTmOQcvLYFeJwtHte2tw6hJDrb8sk8wcvpDprHEHVaoUU0MslIkw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", - "@mui/core-downloads-tracker": "^7.3.9", - "@mui/system": "^7.3.9", + "@mui/core-downloads-tracker": "^7.3.11", + "@mui/system": "^7.3.11", "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.9", + "@mui/utils": "^7.3.11", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", @@ -1165,7 +1192,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.3.9", + "@mui/material-pigment-css": "^7.3.11", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1192,13 +1219,13 @@ "license": "MIT" }, "node_modules/@mui/private-theming": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.9.tgz", - "integrity": "sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==", + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", - "@mui/utils": "^7.3.9", + "@mui/utils": "^7.3.11", "prop-types": "^15.8.1" }, "engines": { @@ -1219,9 +1246,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.9.tgz", - "integrity": "sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==", + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", @@ -1253,16 +1280,16 @@ } }, "node_modules/@mui/system": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.9.tgz", - "integrity": "sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==", + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", - "@mui/private-theming": "^7.3.9", - "@mui/styled-engine": "^7.3.9", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.9", + "@mui/utils": "^7.3.11", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" @@ -1310,9 +1337,9 @@ } }, "node_modules/@mui/utils": { - "version": "7.3.9", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", - "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.28.6", diff --git a/web/normogen-web/package.json b/web/normogen-web/package.json index f9501e2..fd4c3cc 100644 --- a/web/normogen-web/package.json +++ b/web/normogen-web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.11", "@mui/material": "^7.3.9", "@mui/x-charts": "^8.27.4", "axios": "^1.13.6", diff --git a/web/normogen-web/src/components/common/SeverityChip.tsx b/web/normogen-web/src/components/common/SeverityChip.tsx new file mode 100644 index 0000000..32e9e29 --- /dev/null +++ b/web/normogen-web/src/components/common/SeverityChip.tsx @@ -0,0 +1,17 @@ +import { Chip } from '@mui/material'; +import { InteractionSeverity } from '../../types/api'; + +const CONFIG: Record< + InteractionSeverity, + { color: 'error' | 'warning' | 'success' | 'default'; label: string } +> = { + [InteractionSeverity.Severe]: { color: 'error', label: 'Severe' }, + [InteractionSeverity.Moderate]: { color: 'warning', label: 'Moderate' }, + [InteractionSeverity.Mild]: { color: 'success', label: 'Mild' }, + [InteractionSeverity.Unknown]: { color: 'default', label: 'Unknown' }, +}; + +export const SeverityChip = ({ severity }: { severity: InteractionSeverity }) => { + const cfg = CONFIG[severity] ?? CONFIG[InteractionSeverity.Unknown]; + return ; +}; diff --git a/web/normogen-web/src/components/health/HealthStats.tsx b/web/normogen-web/src/components/health/HealthStats.tsx new file mode 100644 index 0000000..a76284d --- /dev/null +++ b/web/normogen-web/src/components/health/HealthStats.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState, type FC } from 'react'; +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Alert, + MenuItem, + Stack, + TextField, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import TrendUpIcon from '@mui/icons-material/TrendingUp'; +import TrendDownIcon from '@mui/icons-material/TrendingDown'; +import TrendFlatIcon from '@mui/icons-material/TrendingFlat'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import { format } from 'date-fns'; +import { useHealthStore } from '../../store/useStore'; +import { HealthStatType, type CreateHealthStatRequest, type TrendData } from '../../types/api'; + +const STAT_TYPES = Object.values(HealthStatType); + +const UNIT_DEFAULTS: Record = { + [HealthStatType.Weight]: 'kg', + [HealthStatType.Height]: 'cm', + [HealthStatType.BloodPressure]: 'mmHg', + [HealthStatType.HeartRate]: 'bpm', + [HealthStatType.BloodSugar]: 'mg/dL', + [HealthStatType.Temperature]: '°C', + [HealthStatType.OxygenSaturation]: '%', + [HealthStatType.Cholesterol]: 'mg/dL', + [HealthStatType.Other]: '', +}; + +const trendIcon = (trend: TrendData['trend']) => { + if (trend === 'up') return ; + if (trend === 'down') return ; + return ; +}; + +const toLocalInput = (d: Date) => { + // datetime-local expects YYYY-MM-DDTHH:mm in local time. + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +}; + +export const HealthStats: FC = () => { + const { stats, trends, isLoading, error, loadStats, loadTrends, createStat, clearError } = + useHealthStore(); + + const [addOpen, setAddOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [chartType, setChartType] = useState(HealthStatType.Weight); + + const [form, setForm] = useState({ + stat_type: HealthStatType.Weight, + value: 0, + unit: UNIT_DEFAULTS[HealthStatType.Weight], + measured_at: new Date().toISOString(), + }); + + useEffect(() => { + loadStats(); + loadTrends(); + }, [loadStats, loadTrends]); + + const openAdd = () => { + const t = HealthStatType.Weight; + setForm({ + stat_type: t, + value: 0, + unit: UNIT_DEFAULTS[t], + measured_at: new Date().toISOString(), + }); + setAddOpen(true); + }; + + const submitAdd = async () => { + setSaving(true); + try { + await createStat(form); + setAddOpen(false); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + const typedTrends = trends as TrendData[]; + const chartData = stats + .filter((s) => s.stat_type === chartType) + .sort((a, b) => new Date(a.measured_at).getTime() - new Date(b.measured_at).getTime()) + .map((s) => ({ time: format(new Date(s.measured_at), 'MMM d'), value: s.value })); + + return ( + + + Health statistics + + + + {error && ( + + {error} + + )} + + {isLoading ? ( + + + + ) : ( + <> + {/* Trend summary cards */} + {typedTrends.length > 0 && ( + + {typedTrends.map((t) => ( + + + + {t.stat_type} + + + {t.average.toFixed(1)} + {trendIcon(t.trend)} + + + {t.min} – {t.max} + + + + ))} + + )} + + {/* Chart with type selector */} + + + + setChartType(e.target.value as HealthStatType)} + sx={{ minWidth: 180 }} + > + {STAT_TYPES.map((t) => ( + + {t} + + ))} + + + {chartData.length === 0 ? ( + + No {chartType} readings recorded yet. + + ) : ( + + + + + + + + + + )} + + + + {/* Recent readings table */} + {stats.length > 0 && ( + + + + + Type + Value + When + Notes + + + + {[...stats] + .sort( + (a, b) => + new Date(b.measured_at).getTime() - new Date(a.measured_at).getTime(), + ) + .slice(0, 15) + .map((s) => ( + + {s.stat_type} + + {s.value} {s.unit} + + {format(new Date(s.measured_at), 'MMM d, HH:mm')} + {s.notes ?? ''} + + ))} + +
+
+ )} + + {stats.length === 0 && typedTrends.length === 0 && ( + + No health readings yet — record one. + + )} + + )} + + {/* Add dialog */} + setAddOpen(false)} fullWidth maxWidth="sm"> + Record a measurement + + + { + const t = e.target.value as HealthStatType; + setForm({ ...form, stat_type: t, unit: UNIT_DEFAULTS[t] }); + }} + > + {STAT_TYPES.map((t) => ( + + {t} + + ))} + + + setForm({ ...form, value: Number(e.target.value) })} + /> + setForm({ ...form, unit: e.target.value })} + /> + + + setForm({ ...form, measured_at: new Date(e.target.value).toISOString() }) + } + /> + setForm({ ...form, notes: e.target.value || undefined })} + /> + + + + + + + +
+ ); +}; + +export default HealthStats; diff --git a/web/normogen-web/src/components/interactions/InteractionsChecker.tsx b/web/normogen-web/src/components/interactions/InteractionsChecker.tsx new file mode 100644 index 0000000..aeee0b1 --- /dev/null +++ b/web/normogen-web/src/components/interactions/InteractionsChecker.tsx @@ -0,0 +1,130 @@ +import { useState, type FC } from 'react'; +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + Alert, + Stack, + Typography, + Chip, +} from '@mui/material'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { useInteractionStore, useMedicationStore } from '../../store/useStore'; +import { SeverityChip } from '../common/SeverityChip'; + +export const InteractionsChecker: FC = () => { + const { medications } = useMedicationStore(); + const { interactions, isChecking, error, checkInteractions, clearError } = + useInteractionStore(); + + const [selected, setSelected] = useState([]); + + const toggle = (name: string) => { + setSelected((prev) => + prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], + ); + }; + + const runCheck = async () => { + if (selected.length < 2) return; + await checkInteractions(selected); + }; + + const hasChecked = interactions.length > 0 || !isChecking; + + return ( + + + Drug interactions + + + Select two or more of your medications to check for known interactions. + + + {error && ( + + {error} + + )} + + {medications.length === 0 ? ( + + Add medications first (Medications tab), then check them for interactions. + + ) : ( + <> + + {medications.map((m) => { + const on = selected.includes(m.name); + return ( + toggle(m.name)} + /> + ); + })} + + + + + {/* Results */} + {hasChecked && !isChecking && selected.length >= 2 && ( + <> + {interactions.length === 0 ? ( + } severity="success"> + No known interactions found between the selected medications. + + ) : ( + + } severity="warning"> + {interactions.length} interaction{interactions.length > 1 ? 's' : ''} found. + + {interactions.map((ix, i) => ( + + + + + {ix.medications.join(' + ')} + + + + + {ix.description} + + {ix.disclaimer && ( + + {ix.disclaimer} + + )} + + + ))} + + )} + + )} + + )} + + ); +}; + +export default InteractionsChecker; diff --git a/web/normogen-web/src/components/medication/MedicationManager.tsx b/web/normogen-web/src/components/medication/MedicationManager.tsx new file mode 100644 index 0000000..8c9dc62 --- /dev/null +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -0,0 +1,348 @@ +import { useEffect, useState, type FC } from 'react'; +import { + Box, + Button, + Card, + CardContent, + CardActions, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Alert, + IconButton, + List, + ListItem, + ListItemText, + MenuItem, + Stack, + TextField, + Typography, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import { useMedicationStore, useAuthStore } from '../../store/useStore'; +import type { + Medication, + CreateMedicationRequest, + UpdateMedicationRequest, +} from '../../types/api'; + +const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const; + +const emptyCreate: CreateMedicationRequest = { + name: '', + dosage: '', + frequency: '', + route: 'oral', + profile_id: 'default', +}; + +export const MedicationManager: FC = () => { + const { + medications, + isLoading, + error, + loadMedications, + createMedication, + updateMedication, + deleteMedication, + clearError, + } = useMedicationStore(); + const user = useAuthStore((s) => s.user); + + const [createOpen, setCreateOpen] = useState(false); + const [createForm, setCreateForm] = useState(emptyCreate); + const [editTarget, setEditTarget] = useState(null); + const [editForm, setEditForm] = useState({}); + const [deleteTarget, setDeleteTarget] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + loadMedications(); + }, [loadMedications]); + + const openCreate = () => { + // TODO: real profile management — for now, source profile_id from the user, + // falling back to 'default' (the backend accepts any string). + setCreateForm({ + ...emptyCreate, + profile_id: user?.profile_id ?? 'default', + }); + setCreateOpen(true); + }; + + const submitCreate = async () => { + setSaving(true); + try { + await createMedication(createForm); + setCreateOpen(false); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + const openEdit = (med: Medication) => { + setEditTarget(med); + setEditForm({ + name: med.name, + dosage: med.dosage, + frequency: med.frequency, + instructions: med.instructions, + active: med.active, + }); + }; + + const submitEdit = async () => { + if (!editTarget?.medication_id) return; + setSaving(true); + try { + await updateMedication(editTarget.medication_id, editForm); + setEditTarget(null); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleteTarget?.medication_id) return; + setSaving(true); + try { + await deleteMedication(deleteTarget.medication_id); + setDeleteTarget(null); + } catch { + /* error surfaced via store */ + } finally { + setSaving(false); + } + }; + + return ( + + + Medications + + + + {error && ( + + {error} + + )} + + {isLoading ? ( + + + + ) : medications.length === 0 ? ( + + No medications yet — add one. + + ) : ( + + {medications.map((med) => ( + + + + + + + {med.name} + {' '} + + {med.dosage} · {med.frequency} + + + + + {med.instructions && ( + + {med.instructions} + + )} + + + + + openEdit(med)} aria-label="edit"> + + + setDeleteTarget(med)} + aria-label="delete" + color="error" + > + + + + + + ))} + + )} + + {/* Create dialog */} + setCreateOpen(false)} fullWidth maxWidth="sm"> + Add medication + + + setCreateForm({ ...createForm, name: e.target.value })} + /> + + setCreateForm({ ...createForm, dosage: e.target.value })} + /> + setCreateForm({ ...createForm, frequency: e.target.value })} + /> + + + setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] }) + } + > + {ROUTES.map((r) => ( + + {r} + + ))} + + + setCreateForm({ ...createForm, instructions: e.target.value || undefined }) + } + /> + + + + + + + + + {/* Edit dialog */} + setEditTarget(null)} + fullWidth + maxWidth="sm" + > + Edit medication + + + setEditForm({ ...editForm, name: e.target.value })} + /> + + setEditForm({ ...editForm, dosage: e.target.value })} + /> + setEditForm({ ...editForm, frequency: e.target.value })} + /> + + setEditForm({ ...editForm, instructions: e.target.value })} + /> + setEditForm({ ...editForm, active: e.target.value === 'active' })} + > + active + inactive + + + + + + + + + + {/* Delete confirm */} + setDeleteTarget(null)}> + Delete medication? + + + Remove {deleteTarget?.name}? This cannot be undone. + + + + + + + + + ); +}; + +export default MedicationManager; diff --git a/web/normogen-web/src/pages/Dashboard.tsx b/web/normogen-web/src/pages/Dashboard.tsx index 7e85b83..fb139f7 100644 --- a/web/normogen-web/src/pages/Dashboard.tsx +++ b/web/normogen-web/src/pages/Dashboard.tsx @@ -1,24 +1,20 @@ -import { useEffect, type FC } from 'react'; +import { useEffect, useState, type FC } from 'react'; import { useNavigate } from 'react-router-dom'; -import { - AppBar, - Toolbar, - Typography, - Button, - Container, - Card, - CardContent, - Box, - CircularProgress, -} from '@mui/material'; +import { AppBar, Toolbar, Typography, Button, Container, Box, Tabs, Tab } from '@mui/material'; import { useAuthStore } from '../store/useStore'; +import { MedicationManager } from '../components/medication/MedicationManager'; +import { HealthStats } from '../components/health/HealthStats'; +import { InteractionsChecker } from '../components/interactions/InteractionsChecker'; + +type TabIndex = 0 | 1 | 2; export const Dashboard: FC = () => { const navigate = useNavigate(); - const { user, isAuthenticated, isLoading, loadUser, logout } = useAuthStore(); + const { user, isAuthenticated, loadUser, logout } = useAuthStore(); + const [tab, setTab] = useState(0); // On mount, refresh the user record from the backend (confirms the token is - // still valid and pulls the latest profile). + // still valid, and medication-create needs user.profile_id). useEffect(() => { if (isAuthenticated && !user) { loadUser(); @@ -37,34 +33,32 @@ export const Dashboard: FC = () => { Normogen + {user && ( + + {user.username} + + )} - - {isLoading && ( - - - - )} + + setTab(v as TabIndex)} sx={{ mb: 3 }}> + + + + - - - - Welcome{user ? `, ${user.username}` : ''} - - - You're signed in to Normogen. - - - This is the dashboard shell. Medication management, health statistics, - and drug-interaction checking UIs will be built here. - - - + + {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + ); }; + +export default Dashboard;