diff --git a/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts index 55dcd52..21e2760 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/activity.entity.ts @@ -18,8 +18,10 @@ export enum ActivityType { DIAPER = 'diaper', GROWTH = 'growth', MEDICATION = 'medication', + MEDICINE = 'medicine', TEMPERATURE = 'temperature', MILESTONE = 'milestone', + ACTIVITY = 'activity', } @Entity('activities') diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V013_add_medicine_activity_types.sql b/maternal-app/maternal-app-backend/src/database/migrations/V013_add_medicine_activity_types.sql new file mode 100644 index 0000000..243a6b8 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V013_add_medicine_activity_types.sql @@ -0,0 +1,9 @@ +-- V013_add_medicine_activity_types.sql +-- Migration V013: Add 'medicine' and 'activity' to allowed activity types + +-- Drop the existing constraint +ALTER TABLE activities DROP CONSTRAINT IF EXISTS activities_type_check; + +-- Add the new constraint with medicine and activity included +ALTER TABLE activities ADD CONSTRAINT activities_type_check + CHECK (type IN ('feeding', 'sleep', 'diaper', 'growth', 'medication', 'medicine', 'temperature', 'milestone', 'activity')); diff --git a/maternal-app/maternal-app-backend/src/modules/voice/voice.service.ts b/maternal-app/maternal-app-backend/src/modules/voice/voice.service.ts index 0ddd311..60c35c9 100644 --- a/maternal-app/maternal-app-backend/src/modules/voice/voice.service.ts +++ b/maternal-app/maternal-app-backend/src/modules/voice/voice.service.ts @@ -16,7 +16,7 @@ export interface TranscriptionResult { } export interface ActivityExtractionResult { - type: string; // 'feeding', 'sleep', 'diaper', 'medicine', 'milestone' + type: string; // 'feeding', 'sleep', 'diaper', 'medicine', 'milestone', 'activity' timestamp?: Date; details: Record; confidence: number; @@ -172,12 +172,15 @@ Extract activity details from the user's text and respond ONLY with valid JSON ( 4. **medicine** - Any mention of medication, vitamin, supplement, drops, dose - Extract: name, dosage, unit, notes -5. **milestone** - Any mention of first time events, developmental progress, achievements +5. **activity** - Any mention of play, exercise, walk, music, reading, tummy time, outdoor activities + - Extract: activityType (play/walk/exercise/music/reading/tummy_time/outdoor/other), duration (minutes), description, notes + +6. **milestone** - Any mention of first time events, developmental progress, achievements, new skills - Extract: description, category (motor/social/cognitive/language), notes **Response Format:** { - "type": "feeding|sleep|diaper|medicine|milestone|unknown", + "type": "feeding|sleep|diaper|medicine|activity|milestone|unknown", "timestamp": "ISO 8601 datetime if mentioned (e.g., '3pm', '30 minutes ago'), otherwise use current time", "details": { // For feeding: @@ -202,9 +205,17 @@ Extract activity details from the user's text and respond ONLY with valid JSON ( "notes": string or null // For medicine: - "name": string, + "medicineName": string, "dosage": number or string, "unit": string or null, + "route": "oral|topical|injection|other" or null, + "reason": string or null, + "notes": string or null + + // For activity: + "activityType": "play|walk|exercise|music|reading|tummy_time|outdoor|other", + "duration": number (minutes) or null, + "description": string or null, "notes": string or null // For milestone: diff --git a/maternal-web/app/track/activity/page.tsx b/maternal-web/app/track/activity/page.tsx new file mode 100644 index 0000000..783b771 --- /dev/null +++ b/maternal-web/app/track/activity/page.tsx @@ -0,0 +1,528 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Box, + Typography, + Button, + Paper, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + IconButton, + Alert, + CircularProgress, + Card, + CardContent, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Chip, + Snackbar, +} from '@mui/material'; +import { + ArrowBack, + Save, + ChildCare, + Delete, + Refresh, + Add, + FitnessCenter, + DirectionsWalk, + Toys, + MusicNote, +} from '@mui/icons-material'; +import { useRouter } from 'next/navigation'; +import { AppShell } from '@/components/layouts/AppShell/AppShell'; +import { ProtectedRoute } from '@/components/common/ProtectedRoute'; +import { withErrorBoundary } from '@/components/common/ErrorFallbacks'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { trackingApi, Activity } from '@/lib/api/tracking'; +import { childrenApi, Child } from '@/lib/api/children'; +import { VoiceInputButton } from '@/components/voice/VoiceInputButton'; +import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons'; +import { motion } from 'framer-motion'; +import { formatDistanceToNow } from 'date-fns'; + +interface ActivityData { + activityType: string; + duration?: number; + description?: string; +} + +function ActivityTrackPage() { + const router = useRouter(); + const { user } = useAuth(); + const [children, setChildren] = useState([]); + const [selectedChild, setSelectedChild] = useState(''); + + // Activity state + const [activityType, setActivityType] = useState('play'); + const [duration, setDuration] = useState(''); + const [description, setDescription] = useState(''); + + // Common state + const [notes, setNotes] = useState(''); + const [recentActivities, setRecentActivities] = useState([]); + const [loading, setLoading] = useState(false); + const [childrenLoading, setChildrenLoading] = useState(true); + const [activitiesLoading, setActivitiesLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Delete confirmation dialog + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [activityToDelete, setActivityToDelete] = useState(null); + + const familyId = user?.families?.[0]?.familyId; + + // Load children + useEffect(() => { + if (familyId) { + loadChildren(); + } + }, [familyId]); + + // Load recent activities when child is selected + useEffect(() => { + if (selectedChild) { + loadRecentActivities(); + } + }, [selectedChild]); + + const loadChildren = async () => { + if (!familyId) return; + + try { + setChildrenLoading(true); + const childrenData = await childrenApi.getChildren(familyId); + setChildren(childrenData); + if (childrenData.length > 0) { + setSelectedChild(childrenData[0].id); + } + } catch (err: any) { + console.error('Failed to load children:', err); + setError(err.response?.data?.message || 'Failed to load children'); + } finally { + setChildrenLoading(false); + } + }; + + const loadRecentActivities = async () => { + if (!selectedChild) return; + + try { + setActivitiesLoading(true); + const activities = await trackingApi.getActivities(selectedChild, 'activity'); + // Sort by timestamp descending and take last 10 + const sorted = activities.sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ).slice(0, 10); + setRecentActivities(sorted); + } catch (err: any) { + console.error('Failed to load recent activities:', err); + } finally { + setActivitiesLoading(false); + } + }; + + const handleSubmit = async () => { + if (!selectedChild) { + setError('Please select a child'); + return; + } + + // Validation + if (!activityType) { + setError('Please select activity type'); + return; + } + + try { + setLoading(true); + setError(null); + + const data: ActivityData = { + activityType, + duration: duration ? parseInt(duration) : undefined, + description: description || undefined, + }; + + await trackingApi.createActivity(selectedChild, { + type: 'activity', + timestamp: new Date().toISOString(), + data, + notes: notes || undefined, + }); + + setSuccessMessage('Activity logged successfully!'); + + // Reset form + resetForm(); + + // Reload recent activities + await loadRecentActivities(); + } catch (err: any) { + console.error('Failed to save activity:', err); + setError(err.response?.data?.message || 'Failed to save activity'); + } finally { + setLoading(false); + } + }; + + const resetForm = () => { + setActivityType('play'); + setDuration(''); + setDescription(''); + setNotes(''); + }; + + const handleDeleteClick = (activityId: string) => { + setActivityToDelete(activityId); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!activityToDelete) return; + + try { + setLoading(true); + await trackingApi.deleteActivity(activityToDelete); + setSuccessMessage('Activity deleted successfully'); + setDeleteDialogOpen(false); + setActivityToDelete(null); + await loadRecentActivities(); + } catch (err: any) { + console.error('Failed to delete activity:', err); + setError(err.response?.data?.message || 'Failed to delete activity'); + } finally { + setLoading(false); + } + }; + + const getActivityTypeIcon = (type: string) => { + switch (type) { + case 'play': + return ; + case 'walk': + return ; + case 'exercise': + return ; + case 'music': + return ; + default: + return ; + } + }; + + const getActivityDetails = (activity: Activity) => { + const data = activity.data as ActivityData; + let details = data.activityType.charAt(0).toUpperCase() + data.activityType.slice(1); + if (data.duration) { + details += ` - ${data.duration} min`; + } + if (data.description) { + details += ` - ${data.description}`; + } + return details; + }; + + if (childrenLoading) { + return ( + + + + + Track Activity + + + + + + Recent Activities + + + + + + ); + } + + if (!familyId || children.length === 0) { + return ( + + + + + + + No Children Added + + + You need to add a child before you can track activities + + + + + + + ); + } + + return ( + + + + + router.back()} sx={{ mr: 2 }}> + + + + Track Activity + + { + console.log('[Activity] Voice transcript:', transcript); + }} + onClassifiedIntent={(result) => { + if (result.intent === 'activity' && result.structuredData) { + const data = result.structuredData; + // Auto-fill form with voice data + if (data.activityType) setActivityType(data.activityType); + if (data.duration) setDuration(data.duration.toString()); + if (data.description) setDescription(data.description); + } + }} + size="medium" + /> + + + {error && ( + setError(null)}> + {error} + + )} + + + {/* Child Selector */} + {children.length > 1 && ( + + + Select Child + + + + )} + + {/* Main Form */} + + + + + Activity Information + + + + + Activity Type + + + + setDuration(e.target.value)} + sx={{ mb: 3 }} + placeholder="e.g., 30" + /> + + setDescription(e.target.value)} + sx={{ mb: 3 }} + placeholder="e.g., Playing with blocks, Reading stories" + /> + + setNotes(e.target.value)} + sx={{ mb: 3 }} + placeholder="Any additional notes..." + /> + + + + + {/* Recent Activities */} + + + + Recent Activities + + + + + + + {activitiesLoading ? ( + + + + ) : recentActivities.length === 0 ? ( + + + No activities yet + + + ) : ( + + {recentActivities.map((activity, index) => { + const data = activity.data as ActivityData; + if (!data || !data.activityType) { + console.warn('[Activity] Activity missing activityType:', activity); + return null; + } + return ( + + + + + + {getActivityTypeIcon(data.activityType)} + + + + + {data.activityType.charAt(0).toUpperCase() + data.activityType.slice(1).replace('_', ' ')} + + + + + {getActivityDetails(activity)} + + {activity.notes && ( + + {activity.notes} + + )} + + + handleDeleteClick(activity.id)} + disabled={loading} + > + + + + + + + + ); + })} + + )} + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + > + Delete Activity? + + + Are you sure you want to delete this activity? This action cannot be undone. + + + + + + + + + {/* Success Snackbar */} + setSuccessMessage(null)} + message={successMessage} + /> + + + ); +} + +export default withErrorBoundary(ActivityTrackPage, 'form'); diff --git a/maternal-web/app/track/medicine/page.tsx b/maternal-web/app/track/medicine/page.tsx new file mode 100644 index 0000000..a231382 --- /dev/null +++ b/maternal-web/app/track/medicine/page.tsx @@ -0,0 +1,548 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Box, + Typography, + Button, + Paper, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + IconButton, + Alert, + CircularProgress, + Card, + CardContent, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Chip, + Snackbar, +} from '@mui/material'; +import { + ArrowBack, + Save, + MedicalServices, + Delete, + Refresh, + ChildCare, + Add, +} from '@mui/icons-material'; +import { useRouter } from 'next/navigation'; +import { AppShell } from '@/components/layouts/AppShell/AppShell'; +import { ProtectedRoute } from '@/components/common/ProtectedRoute'; +import { withErrorBoundary } from '@/components/common/ErrorFallbacks'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { trackingApi, Activity } from '@/lib/api/tracking'; +import { childrenApi, Child } from '@/lib/api/children'; +import { VoiceInputButton } from '@/components/voice/VoiceInputButton'; +import { FormSkeleton, ActivityListSkeleton } from '@/components/common/LoadingSkeletons'; +import { motion } from 'framer-motion'; +import { formatDistanceToNow } from 'date-fns'; + +interface MedicineData { + medicineName: string; + dosage: string; + unit?: string; + route?: 'oral' | 'topical' | 'injection' | 'other'; + reason?: string; +} + +function MedicineTrackPage() { + const router = useRouter(); + const { user } = useAuth(); + const [children, setChildren] = useState([]); + const [selectedChild, setSelectedChild] = useState(''); + + // Medicine state + const [medicineName, setMedicineName] = useState(''); + const [dosage, setDosage] = useState(''); + const [unit, setUnit] = useState('ml'); + const [route, setRoute] = useState<'oral' | 'topical' | 'injection' | 'other'>('oral'); + const [reason, setReason] = useState(''); + + // Common state + const [notes, setNotes] = useState(''); + const [recentMedicines, setRecentMedicines] = useState([]); + const [loading, setLoading] = useState(false); + const [childrenLoading, setChildrenLoading] = useState(true); + const [medicinesLoading, setMedicinesLoading] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Delete confirmation dialog + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [activityToDelete, setActivityToDelete] = useState(null); + + const familyId = user?.families?.[0]?.familyId; + + // Load children + useEffect(() => { + if (familyId) { + loadChildren(); + } + }, [familyId]); + + // Load recent medicines when child is selected + useEffect(() => { + if (selectedChild) { + loadRecentMedicines(); + } + }, [selectedChild]); + + const loadChildren = async () => { + if (!familyId) return; + + try { + setChildrenLoading(true); + const childrenData = await childrenApi.getChildren(familyId); + setChildren(childrenData); + if (childrenData.length > 0) { + setSelectedChild(childrenData[0].id); + } + } catch (err: any) { + console.error('Failed to load children:', err); + setError(err.response?.data?.message || 'Failed to load children'); + } finally { + setChildrenLoading(false); + } + }; + + const loadRecentMedicines = async () => { + if (!selectedChild) return; + + try { + setMedicinesLoading(true); + const activities = await trackingApi.getActivities(selectedChild, 'medicine'); + // Sort by timestamp descending and take last 10 + const sorted = activities.sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ).slice(0, 10); + setRecentMedicines(sorted); + } catch (err: any) { + console.error('Failed to load recent medicines:', err); + } finally { + setMedicinesLoading(false); + } + }; + + const handleSubmit = async () => { + if (!selectedChild) { + setError('Please select a child'); + return; + } + + // Validation + if (!medicineName) { + setError('Please enter medicine name'); + return; + } + + if (!dosage) { + setError('Please enter dosage'); + return; + } + + try { + setLoading(true); + setError(null); + + const data: MedicineData = { + medicineName, + dosage, + unit, + route, + reason: reason || undefined, + }; + + await trackingApi.createActivity(selectedChild, { + type: 'medicine', + timestamp: new Date().toISOString(), + data, + notes: notes || undefined, + }); + + setSuccessMessage('Medicine logged successfully!'); + + // Reset form + resetForm(); + + // Reload recent medicines + await loadRecentMedicines(); + } catch (err: any) { + console.error('Failed to save medicine:', err); + setError(err.response?.data?.message || 'Failed to save medicine'); + } finally { + setLoading(false); + } + }; + + const resetForm = () => { + setMedicineName(''); + setDosage(''); + setUnit('ml'); + setRoute('oral'); + setReason(''); + setNotes(''); + }; + + const handleDeleteClick = (activityId: string) => { + setActivityToDelete(activityId); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!activityToDelete) return; + + try { + setLoading(true); + await trackingApi.deleteActivity(activityToDelete); + setSuccessMessage('Medicine deleted successfully'); + setDeleteDialogOpen(false); + setActivityToDelete(null); + await loadRecentMedicines(); + } catch (err: any) { + console.error('Failed to delete medicine:', err); + setError(err.response?.data?.message || 'Failed to delete medicine'); + } finally { + setLoading(false); + } + }; + + const getMedicineDetails = (activity: Activity) => { + const data = activity.data as MedicineData; + let details = `${data.dosage} ${data.unit || ''}`; + if (data.route) { + details += ` - ${data.route.charAt(0).toUpperCase() + data.route.slice(1)}`; + } + if (data.reason) { + details += ` - ${data.reason}`; + } + return details; + }; + + if (childrenLoading) { + return ( + + + + + Track Medicine + + + + + + Recent Medicines + + + + + + ); + } + + if (!familyId || children.length === 0) { + return ( + + + + + + + No Children Added + + + You need to add a child before you can track medicine activities + + + + + + + ); + } + + return ( + + + + + router.back()} sx={{ mr: 2 }}> + + + + Track Medicine + + { + console.log('[Medicine] Voice transcript:', transcript); + }} + onClassifiedIntent={(result) => { + if (result.intent === 'medicine' && result.structuredData) { + const data = result.structuredData; + // Auto-fill form with voice data + if (data.medicineName) setMedicineName(data.medicineName); + if (data.dosage) setDosage(data.dosage.toString()); + if (data.unit) setUnit(data.unit); + if (data.route) setRoute(data.route); + if (data.reason) setReason(data.reason); + } + }} + size="medium" + /> + + + {error && ( + setError(null)}> + {error} + + )} + + + {/* Child Selector */} + {children.length > 1 && ( + + + Select Child + + + + )} + + {/* Main Form */} + + + + + Medicine Information + + + + setMedicineName(e.target.value)} + sx={{ mb: 3 }} + placeholder="e.g., Acetaminophen, Ibuprofen" + required + /> + + + setDosage(e.target.value)} + placeholder="e.g., 5, 2.5" + required + /> + + + Unit + + + + + + Route + + + + setReason(e.target.value)} + sx={{ mb: 3 }} + placeholder="e.g., Fever, Pain, Allergy" + /> + + setNotes(e.target.value)} + sx={{ mb: 3 }} + placeholder="Any additional notes..." + /> + + + + + {/* Recent Medicines */} + + + + Recent Medicines + + + + + + + {medicinesLoading ? ( + + + + ) : recentMedicines.length === 0 ? ( + + + No medicine activities yet + + + ) : ( + + {recentMedicines.map((activity, index) => { + const data = activity.data as MedicineData; + if (!data || !data.medicineName) { + console.warn('[Medicine] Activity missing medicineName:', activity); + return null; + } + return ( + + + + + + + + + + + {data.medicineName} + + + + + {getMedicineDetails(activity)} + + {activity.notes && ( + + {activity.notes} + + )} + + + handleDeleteClick(activity.id)} + disabled={loading} + > + + + + + + + + ); + })} + + )} + + + + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + > + Delete Medicine Activity? + + + Are you sure you want to delete this medicine activity? This action cannot be undone. + + + + + + + + + {/* Success Snackbar */} + setSuccessMessage(null)} + message={successMessage} + /> + + + ); +} + +export default withErrorBoundary(MedicineTrackPage, 'form'); diff --git a/maternal-web/app/track/page.tsx b/maternal-web/app/track/page.tsx index 4d4a013..d4d5383 100644 --- a/maternal-web/app/track/page.tsx +++ b/maternal-web/app/track/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { Box, Typography, Grid, Card, CardContent, CardActionArea } from '@mui/material'; -import { Restaurant, Hotel, BabyChangingStation, ChildCare } from '@mui/icons-material'; +import { Restaurant, Hotel, BabyChangingStation, ChildCare, MedicalServices } from '@mui/icons-material'; import { useRouter } from 'next/navigation'; import { AppShell } from '@/components/layouts/AppShell/AppShell'; import { ProtectedRoute } from '@/components/common/ProtectedRoute'; @@ -28,6 +28,12 @@ export default function TrackPage() { path: '/track/diaper', color: '#FFF4E1', }, + { + title: 'Medicine', + icon: , + path: '/track/medicine', + color: '#FFE8E8', + }, { title: 'Activity', icon: , diff --git a/maternal-web/components/voice/VoiceFloatingButton.tsx b/maternal-web/components/voice/VoiceFloatingButton.tsx index 3b5821d..d7ac584 100644 --- a/maternal-web/components/voice/VoiceFloatingButton.tsx +++ b/maternal-web/components/voice/VoiceFloatingButton.tsx @@ -16,9 +16,14 @@ import { CircularProgress, Chip, IconButton, + Select, + MenuItem, + FormControl, + InputLabel, } from '@mui/material'; import MicIcon from '@mui/icons-material/Mic'; import MicOffIcon from '@mui/icons-material/MicOff'; +import AddIcon from '@mui/icons-material/Add'; import { useRouter } from 'next/navigation'; import { useVoiceInput } from '@/hooks/useVoiceInput'; import { useAuth } from '@/lib/auth/AuthContext'; @@ -43,6 +48,8 @@ export function VoiceFloatingButton() { const [classificationResult, setClassificationResult] = useState(null); const [processedClassificationId, setProcessedClassificationId] = useState(null); const [showReview, setShowReview] = useState(false); + const [showUnknownDialog, setShowUnknownDialog] = useState(false); + const [manualTrackingType, setManualTrackingType] = useState('feeding'); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; @@ -91,14 +98,10 @@ export function VoiceFloatingButton() { setProcessingStatus(null); setShowReview(true); } else { - // For unknown or low confidence, show error and close dialog + // For unknown or low confidence, show unknown dialog setProcessingStatus(null); setOpen(false); - setSnackbar({ - open: true, - message: 'Could not understand the command. Please try again or use manual entry.', - severity: 'warning', - }); + setShowUnknownDialog(true); } } }, [classification, isListening, isProcessing, open, transcript, processedClassificationId]); @@ -295,6 +298,24 @@ export function VoiceFloatingButton() { setSnackbar(prev => ({ ...prev, open: false })); }; + const handleRetry = () => { + setShowUnknownDialog(false); + setOpen(true); + reset(); + setClassificationResult(null); + setProcessingStatus(null); + setProcessedClassificationId(null); + // Auto-start listening + setTimeout(() => { + startListening(); + }, 300); + }; + + const handleManualTracking = () => { + setShowUnknownDialog(false); + router.push(`/track/${manualTrackingType}`); + }; + return ( <> {/* Floating button positioned in bottom-right */} @@ -439,6 +460,44 @@ export function VoiceFloatingButton() { /> )} + {/* Unknown Intent Dialog */} + setShowUnknownDialog(false)} maxWidth="sm" fullWidth> + Could Not Understand Command + + + + You said: "{transcript}" + + + I couldn't identify a specific activity from your command. You can either try again or manually add an activity. + + + + + Activity Type + + + + + + + + + + {/* Snackbar for feedback */}