feat(web): Phase 3b — medication/health/interactions feature UIs
Build the three core feature UIs on top of the now-wired stores + corrected API client from Phase 3a, turning the runnable shell into a usable app. Dashboard (pages/Dashboard.tsx): AppBar (Normogen + username + logout) over an MUI Tabs container (Medications | Health | Interactions), widened to lg. User loads on mount (needed for profile_id in medication create). MedicationManager (components/medication/): full CRUD. * List of medication cards (name, dosage·frequency, active chip, instructions) with edit/delete actions and an empty state. * Create dialog: name/dosage/frequency/route(select)/instructions. profile_id sourced from user.profile_id with a 'default' fallback (TODO: real profiles). Payload typed to CreateMedicationRequest. * Edit dialog (UpdateMedicationRequest shape) + delete confirm. Guards the optional medication_id field. HealthStats (components/health/): trend cards + chart + add + table. * Trend summary cards from /health-stats/trends (avg/min/max + trend arrow). * recharts LineChart of a selected stat type over measured_at, with a type selector. Timestamp field is measured_at (not timestamp). * Record dialog (stat_type/value/unit/measured_at/notes) + recent-readings table (date-fns formatted). InteractionsChecker (components/interactions/): multi-select medication names (chips) -> POST /interactions/check. Results rendered as cards with a SeverityChip, description, and disclaimer. Success/empty states. Shared SeverityChip (components/common/): maps InteractionSeverity -> MUI Chip color (severe=error, moderate=warning, mild=success, unknown=default). Added @mui/icons-material@7 (pinned to match @mui/material 7; npm tried to grab v9 which needs material ^9). Verified: npm run build clean (TS5 strict), dev server serves the app + all component modules transform. Solaria round-trip confirmed every contract the UIs call: medication create/list (200), interactions warfarin+aspirin -> severe, health-stat create (201).
This commit is contained in:
parent
ef1bb9f4be
commit
1515923996
7 changed files with 886 additions and 60 deletions
309
web/normogen-web/src/components/health/HealthStats.tsx
Normal file
309
web/normogen-web/src/components/health/HealthStats.tsx
Normal file
|
|
@ -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, string> = {
|
||||
[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 <TrendUpIcon color="error" fontSize="small" />;
|
||||
if (trend === 'down') return <TrendDownIcon color="success" fontSize="small" />;
|
||||
return <TrendFlatIcon color="action" fontSize="small" />;
|
||||
};
|
||||
|
||||
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>(HealthStatType.Weight);
|
||||
|
||||
const [form, setForm] = useState<CreateHealthStatRequest>({
|
||||
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 (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Health statistics</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>
|
||||
Record
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={clearError}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{/* Trend summary cards */}
|
||||
{typedTrends.length > 0 && (
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 3, flexWrap: 'wrap', gap: 1 }}>
|
||||
{typedTrends.map((t) => (
|
||||
<Card key={t.stat_type} sx={{ minWidth: 160 }}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t.stat_type}
|
||||
</Typography>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
<Typography variant="h6">{t.average.toFixed(1)}</Typography>
|
||||
{trendIcon(t.trend)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t.min} – {t.max}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Chart with type selector */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Chart"
|
||||
value={chartType}
|
||||
onChange={(e) => setChartType(e.target.value as HealthStatType)}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
{STAT_TYPES.map((t) => (
|
||||
<MenuItem key={t} value={t}>
|
||||
{t}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Stack>
|
||||
{chartData.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||
No {chartType} readings recorded yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" fontSize={12} />
|
||||
<YAxis fontSize={12} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="value" stroke="#1976d2" strokeWidth={2} dot />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent readings table */}
|
||||
{stats.length > 0 && (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
<TableCell>When</TableCell>
|
||||
<TableCell>Notes</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{[...stats]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.measured_at).getTime() - new Date(a.measured_at).getTime(),
|
||||
)
|
||||
.slice(0, 15)
|
||||
.map((s) => (
|
||||
<TableRow key={s.stat_id}>
|
||||
<TableCell>{s.stat_type}</TableCell>
|
||||
<TableCell>
|
||||
{s.value} {s.unit}
|
||||
</TableCell>
|
||||
<TableCell>{format(new Date(s.measured_at), 'MMM d, HH:mm')}</TableCell>
|
||||
<TableCell>{s.notes ?? ''}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{stats.length === 0 && typedTrends.length === 0 && (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
No health readings yet — record one.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add dialog */}
|
||||
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Record a measurement</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Type"
|
||||
fullWidth
|
||||
value={form.stat_type}
|
||||
onChange={(e) => {
|
||||
const t = e.target.value as HealthStatType;
|
||||
setForm({ ...form, stat_type: t, unit: UNIT_DEFAULTS[t] });
|
||||
}}
|
||||
>
|
||||
{STAT_TYPES.map((t) => (
|
||||
<MenuItem key={t} value={t}>
|
||||
{t}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
label="Value"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={form.value === 0 ? '' : form.value}
|
||||
onChange={(e) => setForm({ ...form, value: Number(e.target.value) })}
|
||||
/>
|
||||
<TextField
|
||||
label="Unit"
|
||||
fullWidth
|
||||
value={form.unit}
|
||||
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Measured at"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={toLocalInput(new Date(form.measured_at))}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, measured_at: new Date(e.target.value).toISOString() })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Notes"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value || undefined })}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={submitAdd}
|
||||
disabled={saving || !form.value || !form.unit}
|
||||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthStats;
|
||||
Loading…
Add table
Add a link
Reference in a new issue