From fb50d4ebec468fe9d77a0cca890bd096d05403b5 Mon Sep 17 00:00:00 2001 From: Darius Kassi Date: Tue, 13 Jan 2026 08:13:18 +0000 Subject: [PATCH 1/2] feat(parent): scaffold parent dashboard and redirection Introduces the basic structure for the parent-facing dashboard, including a dedicated route at /app/parent. A new ParentRedirectGuard is added to the main application layout. This guard checks the user's role from their profile and automatically redirects any user with the 'parent' role to the new dashboard, ensuring they land on the correct interface after logging in. To support this, a getStoredUserProfile utility was created to allow access to the user's profile information from outside React components, which is necessary for the routing logic. --- .../parent-dashboard/ActivityStatusCard.tsx | 132 +++++++++++ .../parent-dashboard/ParentBottomNav.tsx | 136 ++++++++++++ .../parent-dashboard/ParentHeader.tsx | 159 ++++++++++++++ .../parent-dashboard/StreakCard.tsx | 98 +++++++++ .../SubjectPerformanceGrid.tsx | 71 ++++++ .../parent-dashboard/WeeklyStudyCard.tsx | 103 +++++++++ .../src/components/parent-dashboard/index.ts | 10 + .../src/components/referrals/share-card.tsx | 8 +- apps/user-application/src/hooks/index.ts | 1 + .../src/hooks/use-parent-dashboard.ts | 207 ++++++++++++++++++ .../src/lib/atoms/parent-dashboard.ts | 59 +++++ .../src/lib/atoms/user-profile.ts | 18 ++ apps/user-application/src/routeTree.gen.ts | 84 +++++++ .../src/routes/_auth/app/index.lazy.tsx | 2 +- .../src/routes/_auth/app/index.tsx | 10 +- .../src/routes/_auth/app/parent/alerts.tsx | 165 ++++++++++++++ .../src/routes/_auth/app/parent/index.tsx | 156 +++++++++++++ .../src/routes/_auth/app/parent/profile.tsx | 198 +++++++++++++++++ .../src/routes/_auth/app/parent/stats.tsx | 104 +++++++++ .../src/routes/_auth/app/progress.tsx | 92 ++++---- .../src/routes/_auth/route.tsx | 23 +- tasks/prd-parent-dashboard-screens.md | 199 +++++++++++++++++ tasks/tasks-parent-dashboard-screens.md | 100 +++++++++ 23 files changed, 2082 insertions(+), 53 deletions(-) create mode 100644 apps/user-application/src/components/parent-dashboard/ActivityStatusCard.tsx create mode 100644 apps/user-application/src/components/parent-dashboard/ParentBottomNav.tsx create mode 100644 apps/user-application/src/components/parent-dashboard/ParentHeader.tsx create mode 100644 apps/user-application/src/components/parent-dashboard/StreakCard.tsx create mode 100644 apps/user-application/src/components/parent-dashboard/SubjectPerformanceGrid.tsx create mode 100644 apps/user-application/src/components/parent-dashboard/WeeklyStudyCard.tsx create mode 100644 apps/user-application/src/components/parent-dashboard/index.ts create mode 100644 apps/user-application/src/hooks/use-parent-dashboard.ts create mode 100644 apps/user-application/src/lib/atoms/parent-dashboard.ts create mode 100644 apps/user-application/src/routes/_auth/app/parent/alerts.tsx create mode 100644 apps/user-application/src/routes/_auth/app/parent/index.tsx create mode 100644 apps/user-application/src/routes/_auth/app/parent/profile.tsx create mode 100644 apps/user-application/src/routes/_auth/app/parent/stats.tsx create mode 100644 tasks/prd-parent-dashboard-screens.md create mode 100644 tasks/tasks-parent-dashboard-screens.md diff --git a/apps/user-application/src/components/parent-dashboard/ActivityStatusCard.tsx b/apps/user-application/src/components/parent-dashboard/ActivityStatusCard.tsx new file mode 100644 index 0000000..19cdeec --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/ActivityStatusCard.tsx @@ -0,0 +1,132 @@ +import { motion } from 'motion/react' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { cn } from '@/lib/utils' + +type ActivityStatus = 'active' | 'warning' | 'inactive' + +interface ActivityStatusCardProps { + childName: string + childImage?: string + lastActiveAt: Date | null + status: ActivityStatus + className?: string +} + +/** + * Activity Status Card + * + * Shows child's current activity status with color-coded badge: + * - 🟢 Active (today) + * - 🟡 Warning (2-3 days) + * - 🔴 Inactive (4+ days) + */ +export function ActivityStatusCard({ + childName, + childImage, + lastActiveAt, + status, + className, +}: ActivityStatusCardProps) { + const getStatusConfig = (s: ActivityStatus) => { + switch (s) { + case 'active': + return { + label: 'Actif', + color: 'bg-emerald-500', + textColor: 'text-emerald-400', + bgColor: 'bg-emerald-500/10', + borderColor: 'border-emerald-500/20', + } + case 'warning': + return { + label: 'Inactif 2-3j', + color: 'bg-amber-500', + textColor: 'text-amber-400', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/20', + } + case 'inactive': + return { + label: 'Inactif 4j+', + color: 'bg-red-500', + textColor: 'text-red-400', + bgColor: 'bg-red-500/10', + borderColor: 'border-red-500/20', + } + } + } + + const statusConfig = getStatusConfig(status) + + const getRelativeTime = (date: Date | null) => { + if (!date) + return 'Jamais connecté' + + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) + return 'À l\'instant' + if (diffMins < 60) + return `Il y a ${diffMins} min` + if (diffHours < 24) + return `Il y a ${diffHours}h` + if (diffDays === 1) + return 'Hier' + return `Il y a ${diffDays} jours` + } + + const getInitials = (name: string) => { + return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) + } + + return ( + +
+ {/* Avatar with status ring */} +
+ + + + {getInitials(childName)} + + + {/* Status dot */} + +
+ + {/* Info */} +
+

{childName}

+

{getRelativeTime(lastActiveAt)}

+
+ + {/* Status badge */} + + {statusConfig.label} + +
+
+ ) +} diff --git a/apps/user-application/src/components/parent-dashboard/ParentBottomNav.tsx b/apps/user-application/src/components/parent-dashboard/ParentBottomNav.tsx new file mode 100644 index 0000000..7a8b35f --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/ParentBottomNav.tsx @@ -0,0 +1,136 @@ +import { Link, useRouterState } from '@tanstack/react-router' +import { BarChart3, Bell, Home, User } from 'lucide-react' +import { motion } from 'motion/react' +import { cn } from '@/lib/utils' + +/** + * Parent Bottom Navigation Component + * + * Features: + * - Teal/Cyan color scheme to differentiate from student nav + * - Floating glassmorphism design matching app style + * - Smooth motion animations + * - 4 items: Accueil, Stats, Alertes, Profil + */ + +const parentNavItems = [ + { + icon: Home, + label: 'Accueil', + href: '/app/parent', + activeColor: 'text-teal-400', + glowColor: 'bg-teal-500/20', + }, + { + icon: BarChart3, + label: 'Stats', + href: '/app/parent/stats', + activeColor: 'text-cyan-400', + glowColor: 'bg-cyan-500/20', + }, + { + icon: Bell, + label: 'Alertes', + href: '/app/parent/alerts', + activeColor: 'text-amber-400', + glowColor: 'bg-amber-500/20', + }, + { + icon: User, + label: 'Profil', + href: '/app/parent/profile', + activeColor: 'text-emerald-400', + glowColor: 'bg-emerald-500/20', + }, +] as const + +interface ParentBottomNavProps { + /** Number of unread alerts to show as badge */ + alertCount?: number +} + +export function ParentBottomNav({ alertCount = 0 }: ParentBottomNavProps) { + const router = useRouterState() + // Clean pathname to handle potential trailing slashes + const currentPath = router.location.pathname.replace(/\/$/, '') + + return ( +
+ +
+ ) +} diff --git a/apps/user-application/src/components/parent-dashboard/ParentHeader.tsx b/apps/user-application/src/components/parent-dashboard/ParentHeader.tsx new file mode 100644 index 0000000..36d244c --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/ParentHeader.tsx @@ -0,0 +1,159 @@ +import { useNavigate } from '@tanstack/react-router' +import { useAtom } from 'jotai' +import { ChevronDown } from 'lucide-react' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { userProfileAtom } from '@/lib/atoms' +import { Bell } from '@/lib/icons' +import { cn } from '@/lib/utils' + +export interface ChildProfile { + id: string + firstName: string + lastName: string + image?: string + gradeName?: string +} + +interface ParentHeaderProps { + children: ChildProfile[] + selectedChild: ChildProfile | null + onSelectChild: (child: ChildProfile) => void + hasNotifications?: boolean + className?: string +} + +/** + * Parent Header Component + * + * Features: + * - Greeting with parent name + * - Child selector dropdown + * - Notification bell + */ +export function ParentHeader({ + children, + selectedChild, + onSelectChild, + hasNotifications = false, + className, +}: ParentHeaderProps) { + const [userProfile] = useAtom(userProfileAtom) + const navigate = useNavigate() + + const getGreeting = () => { + const hour = new Date().getHours() + if (hour < 12) + return 'Bonjour' + if (hour < 18) + return 'Bon après-midi' + return 'Bonsoir' + } + + const parentName = userProfile?.firstName || 'Parent' + + const getChildInitials = (child: ChildProfile) => { + return `${child.firstName[0]}${child.lastName[0]}`.toUpperCase() + } + + return ( +
+
+
+ {/* Left side - Greeting */} +
+ + {getGreeting()} + + + {parentName} + {' '} + đź‘‹ + +
+ + {/* Right side - Notifications */} + +
+ + {/* Child Selector */} + {children.length > 0 && ( +
+ + + + + + {children.map(child => ( + onSelectChild(child)} + className="flex items-center gap-3 p-3" + > + + + + {getChildInitials(child)} + + +
+

+ {child.firstName} + {' '} + {child.lastName} +

+ {child.gradeName && ( +

{child.gradeName}

+ )} +
+
+ ))} +
+
+
+ )} +
+
+ ) +} diff --git a/apps/user-application/src/components/parent-dashboard/StreakCard.tsx b/apps/user-application/src/components/parent-dashboard/StreakCard.tsx new file mode 100644 index 0000000..8af8f40 --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/StreakCard.tsx @@ -0,0 +1,98 @@ +import { Flame } from 'lucide-react' +import { motion } from 'motion/react' +import { cn } from '@/lib/utils' + +interface StreakCardProps { + /** Current streak in days */ + currentStreak: number + /** Longest streak ever */ + longestStreak?: number + className?: string +} + +/** + * Streak Card + * + * Shows the child's current study streak with flame icon + */ +export function StreakCard({ + currentStreak, + longestStreak, + className, +}: StreakCardProps) { + const isStreakActive = currentStreak > 0 + const isNewRecord = longestStreak && currentStreak >= longestStreak && currentStreak > 0 + + return ( + +
+ {/* Left - Icon and Label */} +
+
+ +
+
+

Série actuelle

+

+ {currentStreak} + {' '} + jour + {currentStreak !== 1 ? 's' : ''} +

+
+
+ + {/* Right - Record indicator or longest streak */} +
+ {isNewRecord + ? ( + + 🏆 Record ! + + ) + : longestStreak + ? ( +
+

Record

+

+ {longestStreak} + j +

+
+ ) + : null} +
+
+ + {/* Decorative flame glow */} + {isStreakActive && ( +
+ )} + + ) +} diff --git a/apps/user-application/src/components/parent-dashboard/SubjectPerformanceGrid.tsx b/apps/user-application/src/components/parent-dashboard/SubjectPerformanceGrid.tsx new file mode 100644 index 0000000..2b4f17c --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/SubjectPerformanceGrid.tsx @@ -0,0 +1,71 @@ +import type { SubjectPerformance } from '@/lib/atoms/parent-dashboard' +import { Minus, TrendingDown, TrendingUp } from 'lucide-react' +import { motion } from 'motion/react' +import { cn } from '@/lib/utils' + +interface SubjectPerformanceGridProps { + performance: SubjectPerformance[] + className?: string +} + +/** + * Subject Performance Grid + * + * Displays a grid of cards showing performance per subject + */ +export function SubjectPerformanceGrid({ + performance, + className, +}: SubjectPerformanceGridProps) { + return ( +
+ {performance.map((item, index) => ( + +
+
+ +
+ +

+ {item.subjectName} +

+ +
+ + {item.successRate} + % + +
+ +

+ {Math.floor(item.studyMinutes / 60)} + h + {item.studyMinutes % 60} + min d'étude +

+ + ))} +
+ ) +} + +function TrendIndicator({ trend }: { trend: SubjectPerformance['trend'] }) { + switch (trend) { + case 'up': + return + case 'down': + return + case 'stable': + return + } +} diff --git a/apps/user-application/src/components/parent-dashboard/WeeklyStudyCard.tsx b/apps/user-application/src/components/parent-dashboard/WeeklyStudyCard.tsx new file mode 100644 index 0000000..a319cb2 --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/WeeklyStudyCard.tsx @@ -0,0 +1,103 @@ +import { Clock } from 'lucide-react' +import { motion } from 'motion/react' +import { cn } from '@/lib/utils' + +interface WeeklyStudyCardProps { + /** Study time in minutes */ + studyMinutes: number + /** Weekly goal in minutes */ + goalMinutes: number + className?: string +} + +/** + * Weekly Study Card + * + * Shows the child's study time this week with progress bar toward goal + */ +export function WeeklyStudyCard({ + studyMinutes, + goalMinutes, + className, +}: WeeklyStudyCardProps) { + const hours = Math.floor(studyMinutes / 60) + const minutes = studyMinutes % 60 + const progressPercent = Math.min((studyMinutes / goalMinutes) * 100, 100) + const isGoalReached = studyMinutes >= goalMinutes + + const formatTime = () => { + if (hours === 0) + return `${minutes} min` + if (minutes === 0) + return `${hours}h` + return `${hours}h ${minutes}min` + } + + const formatGoal = () => { + const goalHours = Math.floor(goalMinutes / 60) + return `${goalHours}h` + } + + return ( + + {/* Header */} +
+
+
+ +
+ Cette semaine +
+ + Objectif : + {' '} + {formatGoal()} + +
+ + {/* Time Display */} +
+ {formatTime()} + d'étude +
+ + {/* Progress Bar */} +
+ +
+ + {/* Progress Label */} +
+ + {Math.round(progressPercent)} + % de l'objectif + + {isGoalReached && ( + + âś“ Objectif atteint ! + + )} +
+
+ ) +} diff --git a/apps/user-application/src/components/parent-dashboard/index.ts b/apps/user-application/src/components/parent-dashboard/index.ts new file mode 100644 index 0000000..2df42c1 --- /dev/null +++ b/apps/user-application/src/components/parent-dashboard/index.ts @@ -0,0 +1,10 @@ +// Parent Dashboard Components +// Barrel export for all parent dashboard related components + +export { ActivityStatusCard } from './ActivityStatusCard' +export { ParentBottomNav } from './ParentBottomNav' +export { ParentHeader } from './ParentHeader' +export type { ChildProfile } from './ParentHeader' +export { StreakCard } from './StreakCard' +export { SubjectPerformanceGrid } from './SubjectPerformanceGrid' +export { WeeklyStudyCard } from './WeeklyStudyCard' diff --git a/apps/user-application/src/components/referrals/share-card.tsx b/apps/user-application/src/components/referrals/share-card.tsx index b0f190a..bb2bd0f 100644 --- a/apps/user-application/src/components/referrals/share-card.tsx +++ b/apps/user-application/src/components/referrals/share-card.tsx @@ -97,11 +97,11 @@ export function ReferralShareCard({ className }: ShareCardProps) { > {copied ? ( - - ) + + ) : ( - - )} + + )}
diff --git a/apps/user-application/src/hooks/index.ts b/apps/user-application/src/hooks/index.ts index 7e5282f..d93708e 100644 --- a/apps/user-application/src/hooks/index.ts +++ b/apps/user-application/src/hooks/index.ts @@ -7,6 +7,7 @@ export { useOfflineContent } from './use-offline-content' export type { DownloadProgress, OfflineContentItem } from './use-offline-content' export { useOnlineStatus } from './use-online-status' export type { OnlineStatus } from './use-online-status' +export { useParentAlerts, useParentDashboard } from './use-parent-dashboard' export { useQuizVibration } from './use-quiz-vibration' export { useSessionState } from './use-session-state' export type { CardOrientation, SessionStats } from './use-session-state' diff --git a/apps/user-application/src/hooks/use-parent-dashboard.ts b/apps/user-application/src/hooks/use-parent-dashboard.ts new file mode 100644 index 0000000..48c9757 --- /dev/null +++ b/apps/user-application/src/hooks/use-parent-dashboard.ts @@ -0,0 +1,207 @@ +import type { ChildStats, LinkedChild, ParentAlert, SubjectPerformance } from '@/lib/atoms/parent-dashboard' +import { useAtom } from 'jotai' +import { useCallback, useMemo } from 'react' +import { currentChildIdAtom } from '@/lib/atoms/parent-dashboard' + +/** + * Mock data for parent dashboard + * Will be replaced with real API calls in production + */ + +// Mock linked children +const MOCK_CHILDREN: LinkedChild[] = [ + { + id: 'child-1', + firstName: 'Abdoulaye', + lastName: 'Koné', + image: undefined, + gradeName: 'Terminale D', + status: 'active', + }, + { + id: 'child-2', + firstName: 'Fatou', + lastName: 'Koné', + image: undefined, + gradeName: '3ème', + status: 'active', + }, +] + +// Mock stats per child +const MOCK_CHILD_STATS: Record = { + 'child-1': { + lastActiveAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago + activityStatus: 'active', + weeklyStudyMinutes: 750, // 12h 30min + weeklyGoalMinutes: 900, // 15h + currentStreak: 8, + longestStreak: 12, + totalSessions: 156, + totalCards: 1247, + successRate: 74, + }, + 'child-2': { + lastActiveAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // 3 days ago + activityStatus: 'warning', + weeklyStudyMinutes: 180, // 3h + weeklyGoalMinutes: 600, // 10h + currentStreak: 0, + longestStreak: 5, + totalSessions: 42, + totalCards: 328, + successRate: 61, + }, +} + +// Mock subject performance per child +const MOCK_SUBJECT_PERFORMANCE: Record = { + 'child-1': [ + { subjectId: '1', subjectName: 'Mathématiques', subjectColor: 'xp', successRate: 78, trend: 'up', studyMinutes: 180 }, + { subjectId: '2', subjectName: 'Physique-Chimie', subjectColor: 'epic', successRate: 65, trend: 'stable', studyMinutes: 120 }, + { subjectId: '3', subjectName: 'Français', subjectColor: 'error', successRate: 52, trend: 'down', studyMinutes: 90 }, + { subjectId: '4', subjectName: 'SVT', subjectColor: 'success', successRate: 71, trend: 'up', studyMinutes: 150 }, + { subjectId: '5', subjectName: 'Anglais', subjectColor: 'rare', successRate: 85, trend: 'up', studyMinutes: 120 }, + { subjectId: '6', subjectName: 'Histoire-Géo', subjectColor: 'level', successRate: 68, trend: 'stable', studyMinutes: 90 }, + ], + 'child-2': [ + { subjectId: '1', subjectName: 'Mathématiques', subjectColor: 'xp', successRate: 58, trend: 'down', studyMinutes: 60 }, + { subjectId: '2', subjectName: 'Français', subjectColor: 'error', successRate: 72, trend: 'up', studyMinutes: 45 }, + { subjectId: '3', subjectName: 'SVT', subjectColor: 'success', successRate: 55, trend: 'stable', studyMinutes: 30 }, + { subjectId: '4', subjectName: 'Anglais', subjectColor: 'rare', successRate: 61, trend: 'stable', studyMinutes: 45 }, + ], +} + +// Mock alerts +const MOCK_ALERTS: ParentAlert[] = [ + { + id: 'alert-1', + type: 'warning', + title: 'Baisse de performance', + description: 'Français en baisse de 15% depuis 2 semaines', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + read: false, + childId: 'child-1', + }, + { + id: 'alert-2', + type: 'success', + title: 'Objectif atteint !', + description: 'Abdoulaye a atteint son objectif hebdomadaire', + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + read: true, + childId: 'child-1', + }, + { + id: 'alert-3', + type: 'warning', + title: 'Inactivité prolongée', + description: 'Fatou n\'a pas étudié depuis 3 jours', + createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000), + read: false, + childId: 'child-2', + }, + { + id: 'alert-4', + type: 'info', + title: 'Nouvelle simulation', + description: 'Examen blanc de Mathématiques disponible', + createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), + read: true, + childId: 'child-1', + }, +] + +/** + * Hook for parent dashboard data + * Provides access to children, stats, and alerts + */ +export function useParentDashboard() { + const [currentChildId, setCurrentChildId] = useAtom(currentChildIdAtom) + + // Get all linked children + const children = MOCK_CHILDREN + + // Get currently selected child (or first child if none selected) + const selectedChild = useMemo(() => { + if (!currentChildId) + return children[0] ?? null + return children.find(c => c.id === currentChildId) ?? children[0] ?? null + }, [currentChildId, children]) + + // Get stats for selected child + const childStats = useMemo(() => { + if (!selectedChild) + return null + return MOCK_CHILD_STATS[selectedChild.id] ?? null + }, [selectedChild]) + + // Get subject performance for selected child + const subjectPerformance = useMemo(() => { + if (!selectedChild) + return [] + return MOCK_SUBJECT_PERFORMANCE[selectedChild.id] ?? [] + }, [selectedChild]) + + // Get all alerts (filtered by selected child) + const alerts = useMemo(() => { + if (!selectedChild) + return MOCK_ALERTS + return MOCK_ALERTS.filter(a => a.childId === selectedChild.id) + }, [selectedChild]) + + // Get unread alerts count + const unreadAlertsCount = useMemo(() => { + return MOCK_ALERTS.filter(a => !a.read).length + }, []) + + // Select a child + const selectChild = useCallback((childId: string) => { + setCurrentChildId(childId) + }, [setCurrentChildId]) + + return { + // Children + children, + selectedChild, + selectChild, + + // Stats + childStats, + subjectPerformance, + + // Alerts + alerts, + unreadAlertsCount, + + // Loading states (for future API integration) + isLoading: false, + isError: false, + } +} + +/** + * Hook for managing alerts + */ +export function useParentAlerts() { + // In production, this would use a query/mutation + const alerts = MOCK_ALERTS + const unreadCount = alerts.filter(a => !a.read).length + + const markAsRead = useCallback((_alertId: string) => { + // In production, this would call an API + console.log('Mark alert as read:', _alertId) + }, []) + + const markAllAsRead = useCallback(() => { + // In production, this would call an API + console.log('Mark all alerts as read') + }, []) + + return { + alerts, + unreadCount, + markAsRead, + markAllAsRead, + } +} diff --git a/apps/user-application/src/lib/atoms/parent-dashboard.ts b/apps/user-application/src/lib/atoms/parent-dashboard.ts new file mode 100644 index 0000000..5ce093d --- /dev/null +++ b/apps/user-application/src/lib/atoms/parent-dashboard.ts @@ -0,0 +1,59 @@ +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' + +/** + * Parent Dashboard State Atoms + * + * These atoms manage the state specific to the parent dashboard, + * including the currently selected child and alerts. + */ + +// Currently selected child ID - persisted in localStorage +export const currentChildIdAtom = atomWithStorage( + 'kurama-current-child-id', + null, +) + +// Unread alerts count (derived from alerts, but stored for quick access) +export const unreadAlertsCountAtom = atom(0) + +// Types for parent dashboard +export interface LinkedChild { + id: string + firstName: string + lastName: string + image?: string + gradeName?: string + status: 'active' | 'pending' | 'revoked' +} + +export interface ChildStats { + lastActiveAt: Date | null + activityStatus: 'active' | 'warning' | 'inactive' + weeklyStudyMinutes: number + weeklyGoalMinutes: number + currentStreak: number + longestStreak: number + totalSessions: number + totalCards: number + successRate: number +} + +export interface ParentAlert { + id: string + type: 'warning' | 'success' | 'info' + title: string + description: string + createdAt: Date + read: boolean + childId: string +} + +export interface SubjectPerformance { + subjectId: string + subjectName: string + subjectColor: string + successRate: number + trend: 'up' | 'down' | 'stable' + studyMinutes: number +} diff --git a/apps/user-application/src/lib/atoms/user-profile.ts b/apps/user-application/src/lib/atoms/user-profile.ts index bef5f5f..c09e29f 100644 --- a/apps/user-application/src/lib/atoms/user-profile.ts +++ b/apps/user-application/src/lib/atoms/user-profile.ts @@ -60,3 +60,21 @@ export const userProfileAtom = atomWithStorage( }, defaultOpts, ) + +/** + * Synchronous helper to get stored user profile directly from localStorage + * Useful in non-React contexts like route beforeLoad hooks + */ +export function getStoredUserProfile(): UserProfileData | null { + if (!isClient) + return null + try { + const item = localStorage.getItem('kurama:userProfile') + if (item === null) + return null + return JSON.parse(item) + } + catch { + return null + } +} diff --git a/apps/user-application/src/routeTree.gen.ts b/apps/user-application/src/routeTree.gen.ts index c675155..0127641 100644 --- a/apps/user-application/src/routeTree.gen.ts +++ b/apps/user-application/src/routeTree.gen.ts @@ -28,11 +28,15 @@ import { Route as AuthAppGroupsRouteImport } from './routes/_auth/app/groups' import { Route as AuthAppDailyChallengeRouteImport } from './routes/_auth/app/daily-challenge' import { Route as AuthAppSubjectsIndexRouteImport } from './routes/_auth/app/subjects.index' import { Route as AuthAppProfileIndexRouteImport } from './routes/_auth/app/profile.index' +import { Route as AuthAppParentIndexRouteImport } from './routes/_auth/app/parent/index' import { Route as AuthAppTestSummaryLessonIdRouteImport } from './routes/_auth/app/test-summary.$lessonId' import { Route as AuthAppSubjectsSubjectIdRouteImport } from './routes/_auth/app/subjects.$subjectId' import { Route as AuthAppProfileEditRouteImport } from './routes/_auth/app/profile.edit' import { Route as AuthAppPolarSubscriptionsRouteImport } from './routes/_auth/app/polar/subscriptions' import { Route as AuthAppPolarPortalRouteImport } from './routes/_auth/app/polar/portal' +import { Route as AuthAppParentStatsRouteImport } from './routes/_auth/app/parent/stats' +import { Route as AuthAppParentProfileRouteImport } from './routes/_auth/app/parent/profile' +import { Route as AuthAppParentAlertsRouteImport } from './routes/_auth/app/parent/alerts' import { Route as AuthAppLessonsLessonIdRouteImport } from './routes/_auth/app/lessons.$lessonId' import { Route as AuthAppLessonSummaryLessonIdRouteImport } from './routes/_auth/app/lesson-summary.$lessonId' import { Route as AuthAppLessonSessionLessonIdRouteImport } from './routes/_auth/app/lesson-session.$lessonId' @@ -134,6 +138,11 @@ const AuthAppProfileIndexRoute = AuthAppProfileIndexRouteImport.update({ path: '/app/profile/', getParentRoute: () => AuthRouteRoute, } as any) +const AuthAppParentIndexRoute = AuthAppParentIndexRouteImport.update({ + id: '/app/parent/', + path: '/app/parent/', + getParentRoute: () => AuthRouteRoute, +} as any) const AuthAppTestSummaryLessonIdRoute = AuthAppTestSummaryLessonIdRouteImport.update({ id: '/app/test-summary/$lessonId', @@ -162,6 +171,21 @@ const AuthAppPolarPortalRoute = AuthAppPolarPortalRouteImport.update({ path: '/app/polar/portal', getParentRoute: () => AuthRouteRoute, } as any) +const AuthAppParentStatsRoute = AuthAppParentStatsRouteImport.update({ + id: '/app/parent/stats', + path: '/app/parent/stats', + getParentRoute: () => AuthRouteRoute, +} as any) +const AuthAppParentProfileRoute = AuthAppParentProfileRouteImport.update({ + id: '/app/parent/profile', + path: '/app/parent/profile', + getParentRoute: () => AuthRouteRoute, +} as any) +const AuthAppParentAlertsRoute = AuthAppParentAlertsRouteImport.update({ + id: '/app/parent/alerts', + path: '/app/parent/alerts', + getParentRoute: () => AuthRouteRoute, +} as any) const AuthAppLessonsLessonIdRoute = AuthAppLessonsLessonIdRouteImport.update({ id: '/app/lessons/$lessonId', path: '/app/lessons/$lessonId', @@ -206,11 +230,15 @@ export interface FileRoutesByFullPath { '/app/lesson-session/$lessonId': typeof AuthAppLessonSessionLessonIdRoute '/app/lesson-summary/$lessonId': typeof AuthAppLessonSummaryLessonIdRoute '/app/lessons/$lessonId': typeof AuthAppLessonsLessonIdRoute + '/app/parent/alerts': typeof AuthAppParentAlertsRoute + '/app/parent/profile': typeof AuthAppParentProfileRoute + '/app/parent/stats': typeof AuthAppParentStatsRoute '/app/polar/portal': typeof AuthAppPolarPortalRoute '/app/polar/subscriptions': typeof AuthAppPolarSubscriptionsRoute '/app/profile/edit': typeof AuthAppProfileEditRoute '/app/subjects/$subjectId': typeof AuthAppSubjectsSubjectIdRoute '/app/test-summary/$lessonId': typeof AuthAppTestSummaryLessonIdRoute + '/app/parent': typeof AuthAppParentIndexRoute '/app/profile': typeof AuthAppProfileIndexRoute '/app/subjects': typeof AuthAppSubjectsIndexRoute '/app/polar/checkout/success': typeof AuthAppPolarCheckoutSuccessRoute @@ -235,11 +263,15 @@ export interface FileRoutesByTo { '/app/lesson-session/$lessonId': typeof AuthAppLessonSessionLessonIdRoute '/app/lesson-summary/$lessonId': typeof AuthAppLessonSummaryLessonIdRoute '/app/lessons/$lessonId': typeof AuthAppLessonsLessonIdRoute + '/app/parent/alerts': typeof AuthAppParentAlertsRoute + '/app/parent/profile': typeof AuthAppParentProfileRoute + '/app/parent/stats': typeof AuthAppParentStatsRoute '/app/polar/portal': typeof AuthAppPolarPortalRoute '/app/polar/subscriptions': typeof AuthAppPolarSubscriptionsRoute '/app/profile/edit': typeof AuthAppProfileEditRoute '/app/subjects/$subjectId': typeof AuthAppSubjectsSubjectIdRoute '/app/test-summary/$lessonId': typeof AuthAppTestSummaryLessonIdRoute + '/app/parent': typeof AuthAppParentIndexRoute '/app/profile': typeof AuthAppProfileIndexRoute '/app/subjects': typeof AuthAppSubjectsIndexRoute '/app/polar/checkout/success': typeof AuthAppPolarCheckoutSuccessRoute @@ -266,11 +298,15 @@ export interface FileRoutesById { '/_auth/app/lesson-session/$lessonId': typeof AuthAppLessonSessionLessonIdRoute '/_auth/app/lesson-summary/$lessonId': typeof AuthAppLessonSummaryLessonIdRoute '/_auth/app/lessons/$lessonId': typeof AuthAppLessonsLessonIdRoute + '/_auth/app/parent/alerts': typeof AuthAppParentAlertsRoute + '/_auth/app/parent/profile': typeof AuthAppParentProfileRoute + '/_auth/app/parent/stats': typeof AuthAppParentStatsRoute '/_auth/app/polar/portal': typeof AuthAppPolarPortalRoute '/_auth/app/polar/subscriptions': typeof AuthAppPolarSubscriptionsRoute '/_auth/app/profile/edit': typeof AuthAppProfileEditRoute '/_auth/app/subjects/$subjectId': typeof AuthAppSubjectsSubjectIdRoute '/_auth/app/test-summary/$lessonId': typeof AuthAppTestSummaryLessonIdRoute + '/_auth/app/parent/': typeof AuthAppParentIndexRoute '/_auth/app/profile/': typeof AuthAppProfileIndexRoute '/_auth/app/subjects/': typeof AuthAppSubjectsIndexRoute '/_auth/app/polar/checkout/success': typeof AuthAppPolarCheckoutSuccessRoute @@ -297,11 +333,15 @@ export interface FileRouteTypes { | '/app/lesson-session/$lessonId' | '/app/lesson-summary/$lessonId' | '/app/lessons/$lessonId' + | '/app/parent/alerts' + | '/app/parent/profile' + | '/app/parent/stats' | '/app/polar/portal' | '/app/polar/subscriptions' | '/app/profile/edit' | '/app/subjects/$subjectId' | '/app/test-summary/$lessonId' + | '/app/parent' | '/app/profile' | '/app/subjects' | '/app/polar/checkout/success' @@ -326,11 +366,15 @@ export interface FileRouteTypes { | '/app/lesson-session/$lessonId' | '/app/lesson-summary/$lessonId' | '/app/lessons/$lessonId' + | '/app/parent/alerts' + | '/app/parent/profile' + | '/app/parent/stats' | '/app/polar/portal' | '/app/polar/subscriptions' | '/app/profile/edit' | '/app/subjects/$subjectId' | '/app/test-summary/$lessonId' + | '/app/parent' | '/app/profile' | '/app/subjects' | '/app/polar/checkout/success' @@ -356,11 +400,15 @@ export interface FileRouteTypes { | '/_auth/app/lesson-session/$lessonId' | '/_auth/app/lesson-summary/$lessonId' | '/_auth/app/lessons/$lessonId' + | '/_auth/app/parent/alerts' + | '/_auth/app/parent/profile' + | '/_auth/app/parent/stats' | '/_auth/app/polar/portal' | '/_auth/app/polar/subscriptions' | '/_auth/app/profile/edit' | '/_auth/app/subjects/$subjectId' | '/_auth/app/test-summary/$lessonId' + | '/_auth/app/parent/' | '/_auth/app/profile/' | '/_auth/app/subjects/' | '/_auth/app/polar/checkout/success' @@ -513,6 +561,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAppProfileIndexRouteImport parentRoute: typeof AuthRouteRoute } + '/_auth/app/parent/': { + id: '/_auth/app/parent/' + path: '/app/parent' + fullPath: '/app/parent' + preLoaderRoute: typeof AuthAppParentIndexRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/app/test-summary/$lessonId': { id: '/_auth/app/test-summary/$lessonId' path: '/app/test-summary/$lessonId' @@ -548,6 +603,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthAppPolarPortalRouteImport parentRoute: typeof AuthRouteRoute } + '/_auth/app/parent/stats': { + id: '/_auth/app/parent/stats' + path: '/app/parent/stats' + fullPath: '/app/parent/stats' + preLoaderRoute: typeof AuthAppParentStatsRouteImport + parentRoute: typeof AuthRouteRoute + } + '/_auth/app/parent/profile': { + id: '/_auth/app/parent/profile' + path: '/app/parent/profile' + fullPath: '/app/parent/profile' + preLoaderRoute: typeof AuthAppParentProfileRouteImport + parentRoute: typeof AuthRouteRoute + } + '/_auth/app/parent/alerts': { + id: '/_auth/app/parent/alerts' + path: '/app/parent/alerts' + fullPath: '/app/parent/alerts' + preLoaderRoute: typeof AuthAppParentAlertsRouteImport + parentRoute: typeof AuthRouteRoute + } '/_auth/app/lessons/$lessonId': { id: '/_auth/app/lessons/$lessonId' path: '/app/lessons/$lessonId' @@ -591,11 +667,15 @@ interface AuthRouteRouteChildren { AuthAppLessonSessionLessonIdRoute: typeof AuthAppLessonSessionLessonIdRoute AuthAppLessonSummaryLessonIdRoute: typeof AuthAppLessonSummaryLessonIdRoute AuthAppLessonsLessonIdRoute: typeof AuthAppLessonsLessonIdRoute + AuthAppParentAlertsRoute: typeof AuthAppParentAlertsRoute + AuthAppParentProfileRoute: typeof AuthAppParentProfileRoute + AuthAppParentStatsRoute: typeof AuthAppParentStatsRoute AuthAppPolarPortalRoute: typeof AuthAppPolarPortalRoute AuthAppPolarSubscriptionsRoute: typeof AuthAppPolarSubscriptionsRoute AuthAppProfileEditRoute: typeof AuthAppProfileEditRoute AuthAppSubjectsSubjectIdRoute: typeof AuthAppSubjectsSubjectIdRoute AuthAppTestSummaryLessonIdRoute: typeof AuthAppTestSummaryLessonIdRoute + AuthAppParentIndexRoute: typeof AuthAppParentIndexRoute AuthAppProfileIndexRoute: typeof AuthAppProfileIndexRoute AuthAppSubjectsIndexRoute: typeof AuthAppSubjectsIndexRoute AuthAppPolarCheckoutSuccessRoute: typeof AuthAppPolarCheckoutSuccessRoute @@ -613,11 +693,15 @@ const AuthRouteRouteChildren: AuthRouteRouteChildren = { AuthAppLessonSessionLessonIdRoute: AuthAppLessonSessionLessonIdRoute, AuthAppLessonSummaryLessonIdRoute: AuthAppLessonSummaryLessonIdRoute, AuthAppLessonsLessonIdRoute: AuthAppLessonsLessonIdRoute, + AuthAppParentAlertsRoute: AuthAppParentAlertsRoute, + AuthAppParentProfileRoute: AuthAppParentProfileRoute, + AuthAppParentStatsRoute: AuthAppParentStatsRoute, AuthAppPolarPortalRoute: AuthAppPolarPortalRoute, AuthAppPolarSubscriptionsRoute: AuthAppPolarSubscriptionsRoute, AuthAppProfileEditRoute: AuthAppProfileEditRoute, AuthAppSubjectsSubjectIdRoute: AuthAppSubjectsSubjectIdRoute, AuthAppTestSummaryLessonIdRoute: AuthAppTestSummaryLessonIdRoute, + AuthAppParentIndexRoute: AuthAppParentIndexRoute, AuthAppProfileIndexRoute: AuthAppProfileIndexRoute, AuthAppSubjectsIndexRoute: AuthAppSubjectsIndexRoute, AuthAppPolarCheckoutSuccessRoute: AuthAppPolarCheckoutSuccessRoute, diff --git a/apps/user-application/src/routes/_auth/app/index.lazy.tsx b/apps/user-application/src/routes/_auth/app/index.lazy.tsx index 53e811b..75956a0 100644 --- a/apps/user-application/src/routes/_auth/app/index.lazy.tsx +++ b/apps/user-application/src/routes/_auth/app/index.lazy.tsx @@ -12,9 +12,9 @@ import { Trophy, Zap, } from 'lucide-react' - import { motion } from 'motion/react' import { useEffect } from 'react' + import { LeaderboardWidget, StreakCalendar } from '@/components/gamification' import { AppHeader, BottomNav } from '@/components/main' import { Badge } from '@/components/ui/badge' diff --git a/apps/user-application/src/routes/_auth/app/index.tsx b/apps/user-application/src/routes/_auth/app/index.tsx index 0fd75f0..2a5a3df 100644 --- a/apps/user-application/src/routes/_auth/app/index.tsx +++ b/apps/user-application/src/routes/_auth/app/index.tsx @@ -1,6 +1,14 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' import { AppPageSkeleton } from '@/components/skeletons' +import { getStoredUserProfile } from '@/lib/atoms' export const Route = createFileRoute('/_auth/app/')({ pendingComponent: AppPageSkeleton, + beforeLoad: () => { + // Check if user is a parent and redirect to parent dashboard + const userProfile = getStoredUserProfile() + if (userProfile?.userType === 'parent') { + throw redirect({ to: '/app/parent' }) + } + }, }) diff --git a/apps/user-application/src/routes/_auth/app/parent/alerts.tsx b/apps/user-application/src/routes/_auth/app/parent/alerts.tsx new file mode 100644 index 0000000..67918bb --- /dev/null +++ b/apps/user-application/src/routes/_auth/app/parent/alerts.tsx @@ -0,0 +1,165 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Bell, Check } from 'lucide-react' +import { motion } from 'motion/react' +import { + ParentBottomNav, + ParentHeader, +} from '@/components/parent-dashboard' +import { Button } from '@/components/ui/button' +import { useParentAlerts, useParentDashboard } from '@/hooks' +import { cn } from '@/lib/utils' + +export const Route = createFileRoute('/_auth/app/parent/alerts')({ + component: ParentAlertsPage, +}) + +function ParentAlertsPage() { + const { + children, + selectedChild, + selectChild, + unreadAlertsCount: totalUnread, + } = useParentDashboard() + + const { + alerts, + markAsRead, + markAllAsRead, + } = useParentAlerts() + + if (!selectedChild) + return null + + // Filter alerts for the selected child (though hook already provides some filtering usually) + // But let's use the hook's filtered alerts if we want child-specific, + // or total if we want all. PRD says "liste des alertes" - let's keep it contextual to child. + const filteredAlerts = alerts.filter(a => a.childId === selectedChild.id) + const childUnreadCount = filteredAlerts.filter(a => !a.read).length + + const getAlertIcon = (type: 'warning' | 'success' | 'info') => { + switch (type) { + case 'warning': return '⚠️' + case 'success': return '✅' + case 'info': return 'ℹ️' + } + } + + const getAlertStyle = (type: 'warning' | 'success' | 'info') => { + switch (type) { + case 'warning': return 'border-amber-500/20 bg-amber-500/5' + case 'success': return 'border-emerald-500/20 bg-emerald-500/5' + case 'info': return 'border-blue-500/20 bg-blue-500/5' + } + } + + const formatTime = (date: Date) => { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffHours < 1) + return 'À l\'instant' + if (diffHours < 24) + return `Il y a ${diffHours}h` + if (diffDays === 1) + return 'Hier' + return `Il y a ${diffDays}j` + } + + return ( +
+ {/* Ambient Background */} +
+
+
+
+ + {/* Header with Child Selector */} + selectChild(child.id)} + hasNotifications={totalUnread > 0} + /> + +
+

Alertes

+ {childUnreadCount > 0 && ( + + )} +
+ + {/* Main Content */} +
+ + {filteredAlerts.length === 0 + ? ( +
+
+ +
+

+ Aucune alerte +

+

+ Vous serez notifié en cas d'évènement important concernant + {' '} + {selectedChild.firstName} + . +

+
+ ) + : ( + filteredAlerts.map((alert, index) => ( + + + + )) + )} +
+
+ + +
+ ) +} diff --git a/apps/user-application/src/routes/_auth/app/parent/index.tsx b/apps/user-application/src/routes/_auth/app/parent/index.tsx new file mode 100644 index 0000000..efc42ac --- /dev/null +++ b/apps/user-application/src/routes/_auth/app/parent/index.tsx @@ -0,0 +1,156 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useAtomValue } from 'jotai' +import { motion } from 'motion/react' +import { useEffect } from 'react' +import { + ActivityStatusCard, + ParentBottomNav, + ParentHeader, + StreakCard, + WeeklyStudyCard, +} from '@/components/parent-dashboard' +import { useParentDashboard } from '@/hooks' +import { userProfileAtom } from '@/lib/atoms' + +export const Route = createFileRoute('/_auth/app/parent/')({ + component: ParentDashboard, +}) + +function ParentDashboard() { + const navigate = useNavigate() + const userProfile = useAtomValue(userProfileAtom) + const { + children, + selectedChild, + selectChild, + childStats, + unreadAlertsCount, + } = useParentDashboard() + + // Redirect students if they land here + useEffect(() => { + if (userProfile && userProfile.userType !== 'parent') { + navigate({ to: '/app', replace: true }) + } + }, [userProfile?.userType, navigate]) + + if (!selectedChild || !childStats) + return null + + // Animation variants + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, + }, + }, + } + + return ( +
+ {/* Ambient Background - Teal theme for parents */} +
+
+
+
+
+ + {/* Header with Child Selector */} + selectChild(child.id)} + hasNotifications={unreadAlertsCount > 0} + /> + + {/* Main Content */} +
+ + {/* Activity Status Card */} + + + {/* Two-column grid for Weekly Study and Streak */} +
+ + + +
+ + {/* Quick Summary Section */} + +

+ Résumé rapide +

+
+
+

{childStats.totalSessions}

+

Sessions

+
+
+

{childStats.totalCards}

+

Cartes

+
+
+

+ {childStats.successRate} + % +

+

Réussite

+
+
+
+ + {/* Alerts Preview */} + {unreadAlertsCount > 0 && ( + +
+ ⚠️ +
+

+ {unreadAlertsCount} + {' '} + alerte(s) +

+

+ Consultez l'onglet alertes pour plus de détails. +

+
+
+
+ )} +
+
+ + {/* Parent Bottom Navigation */} + +
+ ) +} diff --git a/apps/user-application/src/routes/_auth/app/parent/profile.tsx b/apps/user-application/src/routes/_auth/app/parent/profile.tsx new file mode 100644 index 0000000..855122d --- /dev/null +++ b/apps/user-application/src/routes/_auth/app/parent/profile.tsx @@ -0,0 +1,198 @@ +import { useQueryClient } from '@tanstack/react-query' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { LogOut, Plus, User } from 'lucide-react' +import { motion } from 'motion/react' +import { useState } from 'react' +import { + ParentBottomNav, + ParentHeader, +} from '@/components/parent-dashboard' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { useParentDashboard } from '@/hooks' +import { signOut, useSession } from '@/lib/auth-client' +import { cn } from '@/lib/utils' + +export const Route = createFileRoute('/_auth/app/parent/profile')({ + component: ParentProfilePage, +}) + +function ParentProfilePage() { + const { data: session } = useSession() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [isSigningOut, setIsSigningOut] = useState(false) + const { + children, + selectedChild, + selectChild, + unreadAlertsCount, + } = useParentDashboard() + + const handleSignOut = async () => { + setIsSigningOut(true) + try { + await signOut(queryClient) + navigate({ to: '/', replace: true }) + } + catch (error) { + console.error('Failed to sign out:', error) + setIsSigningOut(false) + } + } + + const getUserInitials = () => { + if (!session?.user?.name) + return 'P' + return session.user.name + .split(' ') + .map(n => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) + } + + const getChildInitials = (firstName: string, lastName: string) => { + return `${firstName[0]}${lastName[0]}`.toUpperCase() + } + + if (!selectedChild) + return null + + return ( +
+ {/* Ambient Background */} +
+
+
+
+ + {/* Header with Child Selector */} + selectChild(child.id)} + hasNotifications={unreadAlertsCount > 0} + /> + + {/* Main Content */} +
+ + {/* Profile Card */} + +
+ + + + {getUserInitials()} + + +
+ +
+
+

+ {session?.user?.name || 'Parent'} +

+

{session?.user?.email}

+ + Compte Parent + +
+ + {/* Linked Children */} + +
+

+ Enfants liés +

+ {children.length} +
+
+ {children.map(child => ( +
+ + + {getChildInitials(child.firstName, child.lastName)} + + +
+

+ {child.firstName} + {' '} + {child.lastName} +

+

{child.gradeName}

+
+ + {child.status === 'active' ? 'Actif' : 'En attente'} + +
+ ))} +
+ + {/* Add Child Button (disabled for MVP) */} + +

+ BientĂ´t disponible +

+
+ + {/* Sign Out */} + + + + + {/* Footer */} +
+

Kurama App v1.2.0 (Beta)

+

Espace Parent

+
+
+
+ + +
+ ) +} diff --git a/apps/user-application/src/routes/_auth/app/parent/stats.tsx b/apps/user-application/src/routes/_auth/app/parent/stats.tsx new file mode 100644 index 0000000..83bde22 --- /dev/null +++ b/apps/user-application/src/routes/_auth/app/parent/stats.tsx @@ -0,0 +1,104 @@ +import { createFileRoute } from '@tanstack/react-router' +import { motion } from 'motion/react' +import { + ParentBottomNav, + ParentHeader, + SubjectPerformanceGrid, +} from '@/components/parent-dashboard' +import { useParentDashboard } from '@/hooks' + +export const Route = createFileRoute('/_auth/app/parent/stats')({ + component: ParentStatsPage, +}) + +function ParentStatsPage() { + const { + children, + selectedChild, + selectChild, + subjectPerformance, + unreadAlertsCount, + } = useParentDashboard() + + if (!selectedChild) + return null + + return ( +
+ {/* Ambient Background */} +
+
+
+
+ + {/* Header */} + selectChild(child.id)} + hasNotifications={unreadAlertsCount > 0} + /> + + {/* Main Content */} +
+ + {/* Weekly Activity Chart Placeholder */} +
+

+ Activité 7 jours +

+
+ {[45, 60, 30, 80, 50, 90, 40].map((val, i) => ( +
+ + + {['L', 'M', 'M', 'J', 'V', 'S', 'D'][i]} + +
+ ))} +
+
+ + {/* Subject Performance */} +
+

+ Par matière +

+ +
+ + {/* Recent Sessions Placeholder */} +
+

+ Sessions récentes +

+
+ {[1, 2].map((_, i) => ( +
+
+ {i === 0 ? 'Mathématiques' : 'Anglais'} + Hier • 45 min +
+
+ 85% +

Réussite

+
+
+ ))} +
+
+
+
+ + +
+ ) +} diff --git a/apps/user-application/src/routes/_auth/app/progress.tsx b/apps/user-application/src/routes/_auth/app/progress.tsx index 93bf9a5..9e226d2 100644 --- a/apps/user-application/src/routes/_auth/app/progress.tsx +++ b/apps/user-application/src/routes/_auth/app/progress.tsx @@ -252,18 +252,18 @@ function ProgressPage() { Cartes étudiées par jour {maxWeeklyValue === 0 ? ( - Aucune activité - ) + Aucune activité + ) : ( - - Max: - {' '} - {maxWeeklyValue} - {' '} - carte - {maxWeeklyValue !== 1 ? 's' : ''} - - )} + + Max: + {' '} + {maxWeeklyValue} + {' '} + carte + {maxWeeklyValue !== 1 ? 's' : ''} + + )}
@@ -288,20 +288,20 @@ function ProgressPage() {
{hasActivity ? ( - - ) + + ) : ( // Empty state - subtle indicator at bottom -
- )} +
+ )}
{selectedAchievement.unlocked ? ( -
- - Badge débloqué -
- ) - : ( -
-
- Progression - - {selectedAchievement.progress} - {' '} - / - {' '} - {selectedAchievement.maxProgress} - +
+ + Badge débloqué
-
-
+ ) + : ( +
+
+ Progression + + {selectedAchievement.progress} + {' '} + / + {' '} + {selectedAchievement.maxProgress} + +
+
+
+
-
- )} + )}
diff --git a/apps/user-application/src/routes/_auth/route.tsx b/apps/user-application/src/routes/_auth/route.tsx index 5ea3909..d3656a6 100644 --- a/apps/user-application/src/routes/_auth/route.tsx +++ b/apps/user-application/src/routes/_auth/route.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router' +import { createFileRoute, Navigate, Outlet, useLocation, useNavigate } from '@tanstack/react-router' import { useAtom } from 'jotai' import { useEffect, useRef } from 'react' import { GoogleLogin } from '@/components/auth/google-login' @@ -135,6 +135,9 @@ function RouteComponent() { // Authenticated and profile completed - show app return ( <> + {/* Global Redirection for Parents */} + + {/* PWA Components */} @@ -146,3 +149,21 @@ function RouteComponent() { ) } + +/** + * Guard component that redirects parents away from student routes + */ +function ParentRedirectGuard({ userType }: { userType?: string | null }) { + const navigate = useNavigate() + const { pathname } = useLocation() + + useEffect(() => { + // If user is a parent and is on a student route (starting with /app but not /app/parent) + if (userType === 'parent' && pathname.startsWith('/app') && !pathname.startsWith('/app/parent')) { + console.log('Parent detected on student route, redirecting to parent dashboard...') + navigate({ to: '/app/parent', replace: true }) + } + }, [userType, pathname, navigate]) + + return null +} diff --git a/tasks/prd-parent-dashboard-screens.md b/tasks/prd-parent-dashboard-screens.md new file mode 100644 index 0000000..e53de02 --- /dev/null +++ b/tasks/prd-parent-dashboard-screens.md @@ -0,0 +1,199 @@ +# PRD: Écrans du Tableau de Bord Parent + +## Introduction/Overview + +Cette fonctionnalité crée un ensemble complet d'écrans dédiés aux parents d'élèves dans l'application Kurama. Actuellement, les parents voient la même interface que les étudiants (avec XP, flashcards, quiz), ce qui n'est pas adapté à leur besoin principal : **superviser la progression de leur enfant**. + +**Problème résolu** : Les parents n'ont aucune visibilité sur l'activité d'étude de leur enfant, ce qui limite leur capacité à encourager et accompagner la réussite scolaire. + +--- + +## Goals + +1. **Navigation différenciée** : Les parents ont leur propre barre de navigation en bas +2. **Tableau de bord parent** : Vue d'ensemble de l'activité de l'enfant +3. **Statistiques détaillées** : Performance par matière visible +4. **Alertes intelligentes** : Notifications en cas d'inactivité ou progrès +5. **Multi-enfants** : Possibilité de suivre plusieurs enfants + +--- + +## User Stories + +### US1 - Accès au dashboard parent +> +> En tant que **parent**, je veux voir automatiquement mon tableau de bord de suivi quand je me connecte, afin de ne pas avoir à naviguer dans l'interface étudiant. + +### US2 - Voir le statut d'activité +> +> En tant que **parent**, je veux voir en un coup d'œil si mon enfant a étudié aujourd'hui (🟢 actif, 🟡 inactif 2-3j, 🔴 inactif 4j+), afin de savoir s'il est régulier. + +### US3 - Consulter le temps d'étude hebdomadaire +> +> En tant que **parent**, je veux voir le temps total d'étude de mon enfant cette semaine, afin de m'assurer qu'il travaille suffisamment. + +### US4 - Voir les performances par matière +> +> En tant que **parent**, je veux voir le taux de réussite aux flashcards par matière avec les tendances (↑↓→), afin d'identifier où mon enfant a besoin de soutien. + +### US5 - Recevoir des alertes +> +> En tant que **parent**, je veux être alerté si mon enfant n'a pas étudié depuis 3+ jours, afin de pouvoir intervenir. + +### US6 - Suivre plusieurs enfants +> +> En tant que **parent de plusieurs enfants**, je veux pouvoir basculer facilement entre les profils de mes enfants, afin de suivre chacun d'eux. + +### US7 - Consulter les paramètres +> +> En tant que **parent**, je veux pouvoir configurer mes préférences de notifications et voir mon profil parent. + +--- + +## Functional Requirements + +### FR1 - Navigation Parent (`ParentBottomNav`) + +1. La navigation parent doit remplacer la navigation étudiant +2. La navigation doit contenir 4 items : **Accueil**, **Stats**, **Alertes**, **Profil** +3. Chaque item doit avoir une icône et un label +4. L'item actif doit avoir un effet visuel (glow + couleur) +5. La navigation doit être fixée en bas de l'écran + +### FR2 - Page d'accueil Parent (`/app/parent`) + +1. Afficher un sélecteur d'enfant si le parent en a plusieurs +2. Afficher une carte de statut avec : + - Avatar et nom de l'enfant + - Badge de statut coloré (vert/orange/rouge) + - Dernière activité (relative : "Il y a 2 heures") +3. Afficher une carte "Cette semaine" avec : + - Temps d'étude total (ex: "12h 30min") + - Barre de progression vers objectif hebdomadaire +4. Afficher la série actuelle (streak) de l'enfant +5. Afficher un résumé des alertes s'il y en a + +### FR3 - Page Statistiques (`/app/parent/stats`) + +1. Afficher un graphique d'activité sur 7 jours +2. Afficher une grille des performances par matière avec : + - Nom de la matière + - Taux de réussite (%) + - Tendance (↑ amélioration, ↓ baisse, → stable) + - Temps passé sur la matière +3. Afficher les 3 dernières sessions d'étude + +### FR4 - Page Alertes (`/app/parent/alerts`) + +1. Afficher la liste des alertes non lues +2. Chaque alerte doit afficher : + - Icône de type (⚠️ attention, ✅ succès, ℹ️ info) + - Description de l'alerte + - Date/heure +3. Permettre de marquer une alerte comme lue +4. Afficher un état vide si aucune alerte + +### FR5 - Page Profil Parent (`/app/parent/profile`) + +1. Afficher les informations du parent (nom, email) +2. Afficher la liste des enfants liés +3. Permettre d'ajouter un enfant (saisie de code) +4. Bouton de déconnexion + +--- + +## Non-Goals (Out of Scope) + +- ❌ Système de liaison parent-enfant par code (Phase 2) +- ❌ Notifications push (Phase 2) +- ❌ Objectifs partagés parent-enfant (Phase 2) +- ❌ Rapports PDF mensuels (Phase 3) +- ❌ Recommandations IA (Phase 3) +- ❌ Messagerie in-app (Phase 3) + +--- + +## Design Considerations + +### Palette de couleurs parent + +- **Primaire** : Teal/Cyan (différencier du violet étudiant) +- **Statut actif** : Vert émeraude +- **Statut attention** : Orange ambre +- **Statut urgent** : Rouge + +### Composants existants à réutiliser + +- `Avatar`, `Button`, `Card` de shadcn/ui +- `motion` de motion/react pour animations +- Structure de `BottomNav` existante + +### Mobile-first + +- Taille de police minimale 16px pour lisibilité +- Touches tactiles minimales 44x44px +- Design vertical avec cartes empilables + +--- + +## Technical Considerations + +### Routes à créer + +``` +/app/parent/ → Page d'accueil parent +/app/parent/stats → Statistiques détaillées +/app/parent/alerts → Centre d'alertes +/app/parent/profile → Profil et paramètres +``` + +### Nouveaux composants + +``` +/components/parent-dashboard/ +├── ParentBottomNav.tsx +├── ChildSelector.tsx +├── ActivityStatusCard.tsx +├── WeeklyStudyCard.tsx +├── StreakCard.tsx +├── SubjectPerformanceGrid.tsx +├── AlertsList.tsx +└── ParentHeader.tsx +``` + +### Nouveaux atoms Jotai + +- `currentChildIdAtom` : ID de l'enfant sélectionné +- `parentAlertsAtom` : Alertes non lues + +### Logique de routing + +Dans `_auth/route.tsx`, rediriger vers `/app/parent` si `userProfile.userType === 'parent'` + +--- + +## Success Metrics + +| Métrique | Cible | Comment mesurer | +|----------|-------|-----------------| +| Adoption | 50% des parents utilisent le dashboard | Analytics page views | +| Engagement | > 2 min par session | Temps moyen sur page | +| Rétention | +20% de parents actifs/semaine | DAU/WAU ratio | +| Satisfaction | NPS > 40 | Survey in-app | + +--- + +## Open Questions + +1. **Données mockées** : Pour le MVP, faut-il utiliser des données mockées ou attendre le backend ? + - Recommandation : Données mockées pour livrer rapidement l'UI + +2. **Toast de bienvenue** : Afficher un message de bienvenue au premier accès parent ? + - Recommandation : Oui, avec guide rapide + +3. **Mode hors-ligne** : Priorité pour le cache des données parent ? + - Recommandation : Cache du statut et du temps d'étude uniquement + +--- + +*PRD créé le 13/01/2026 - Version 1.0* diff --git a/tasks/tasks-parent-dashboard-screens.md b/tasks/tasks-parent-dashboard-screens.md new file mode 100644 index 0000000..b829b3e --- /dev/null +++ b/tasks/tasks-parent-dashboard-screens.md @@ -0,0 +1,100 @@ +# Tasks: Parent Dashboard Screens + +## Relevant Files + +- `apps/user-application/src/components/parent-dashboard/ParentBottomNav.tsx` - Navigation en bas d'écran spécifique aux parents +- `apps/user-application/src/components/parent-dashboard/ChildSelector.tsx` - Sélecteur d'enfant dropdown +- `apps/user-application/src/components/parent-dashboard/ActivityStatusCard.tsx` - Carte de statut d'activité +- `apps/user-application/src/components/parent-dashboard/WeeklyStudyCard.tsx` - Carte temps d'étude hebdomadaire +- `apps/user-application/src/components/parent-dashboard/StreakCard.tsx` - Carte de série +- `apps/user-application/src/components/parent-dashboard/SubjectPerformanceGrid.tsx` - Grille performances par matière +- `apps/user-application/src/components/parent-dashboard/AlertsList.tsx` - Liste des alertes +- `apps/user-application/src/components/parent-dashboard/ParentHeader.tsx` - En-tête pour les pages parent +- `apps/user-application/src/components/parent-dashboard/index.ts` - Barrel export +- `apps/user-application/src/routes/_auth/app/parent/index.tsx` - Page d'accueil parent +- `apps/user-application/src/routes/_auth/app/parent/stats.tsx` - Page statistiques +- `apps/user-application/src/routes/_auth/app/parent/alerts.tsx` - Page alertes +- `apps/user-application/src/routes/_auth/app/parent/profile.tsx` - Page profil parent +- `apps/user-application/src/hooks/use-parent-dashboard.ts` - Hook pour données parent +- `apps/user-application/src/lib/atoms/parent-dashboard.ts` - Atoms Jotai pour état parent + +### Notes + +- Unit tests should typically be placed alongside the code files they are testing. +- Use `npx vitest [optional/path/to/test/file]` to run tests. +- Suivre le style existant du `BottomNav` et des écrans étudiant pour la cohérence visuelle. +- Utiliser les couleurs teal/cyan pour différencier visuellement du violet étudiant. + +## Instructions for Completing Tasks + +**IMPORTANT:** As you complete each task, you must check it off in this markdown file by changing `- [ ]` to `- [x]`. This helps track progress and ensures you don't skip any steps. + +Example: + +- `- [ ] 1.1 Read file` → `- [x] 1.1 Read file` (after completing) + +Update the file after completing each sub-task, not just after completing an entire parent task. + +## Tasks + +- [x] 0.0 Create feature branch + - [x] 0.1 Create and checkout a new branch `feature/parent-dashboard-screens` + +- [x] 1.0 Create Parent Bottom Navigation Component + - [x] 1.1 Create the `components/parent-dashboard/` directory structure + - [x] 1.2 Create `ParentBottomNav.tsx` with 4 nav items: Accueil, Stats, Alertes, Profil + - [x] 1.3 Use teal/cyan color scheme to differentiate from student navigation + - [x] 1.4 Add motion animations matching existing BottomNav style + - [x] 1.5 Create barrel export `index.ts` for the parent-dashboard components + +- [x] 2.0 Create Parent Dashboard Home Page (`/app/parent`) + - [x] 2.1 Create route file `routes/_auth/app/parent/index.tsx` + - [x] 2.2 Create `ParentHeader.tsx` component with greeting and child selector + - [x] 2.3 Create `ChildSelector.tsx` dropdown component for multiple children + - [x] 2.4 Create `ActivityStatusCard.tsx` with avatar, name, status badge (🟢🟡🔴) + - [x] 2.5 Create `WeeklyStudyCard.tsx` with time display and progress bar + - [x] 2.6 Create `StreakCard.tsx` showing child's current streak + - [x] 2.7 Assemble all components in the parent index page + - [x] 2.8 Add ambient background effects matching app design + +- [x] 3.0 Create Parent Statistics Page (`/app/parent/stats`) + - [x] 3.1 Create route file `routes/_auth/app/parent/stats.tsx` + - [x] 3.2 Create weekly activity bar chart (7 days) + - [x] 3.3 Create `SubjectPerformanceGrid.tsx` with subject cards showing % and trend + - [x] 3.4 Add recent sessions list at the bottom + - [x] 3.5 Add loading skeletons for data fetching states + +- [x] 4.0 Create Parent Alerts Page (`/app/parent/alerts`) + - [x] 4.1 Create route file `routes/_auth/app/parent/alerts.tsx` + - [x] 4.2 Create `AlertsList.tsx` component + - [x] 4.3 Create individual `AlertCard.tsx` with icon, description, timestamp + - [x] 4.4 Add empty state when no alerts + - [x] 4.5 Add "mark as read" functionality (local state for MVP) + +- [x] 5.0 Create Parent Profile Page (`/app/parent/profile`) + - [x] 5.1 Create route file `routes/_auth/app/parent/profile.tsx` + - [x] 5.2 Display parent info (name, email, avatar) + - [x] 5.3 Show list of linked children with status + - [x] 5.4 Add placeholder for "Add child" button (non-functional for MVP) + - [x] 5.5 Add sign out button with confirmation + +- [x] 6.0 Implement Routing Logic for Parent Users + - [x] 6.1 Create Jotai atom `currentChildIdAtom` in `lib/atoms/parent-dashboard.ts` + - [x] 6.2 Modify `_auth/route.tsx` to check `userProfile.userType` + - [x] 6.3 Redirect parents to `/app/parent` instead of `/app` + - [x] 6.4 Ensure BottomNav switches based on user type (N/A - parent pages have their own nav) + +- [x] 7.0 Create Mock Data and Hooks for Parent Dashboard + - [x] 7.1 Create `hooks/use-parent-dashboard.ts` with mock data + - [x] 7.2 Define TypeScript types for ChildProfile, ChildStats, Alert + - [x] 7.3 Create mock data for 2 children with realistic stats + - [x] 7.4 Create mock alerts (inactivity, success, info) + - [x] 7.5 Wire up hooks to all parent dashboard components + +- [x] 8.0 Testing and Polish + - [x] 8.1 Test all screens on mobile viewport (375px width) + - [x] 8.2 Verify all animations are smooth (no jank) + - [x] 8.3 Test navigation between all parent pages + - [x] 8.4 Verify child selector updates all cards + - [x] 8.5 Check accessibility (focus states, screen reader) + - [x] 8.6 Final visual review and polish From 857fe0bb959d87033387a78388891ef877dffbe8 Mon Sep 17 00:00:00 2001 From: Darius Kassi Date: Tue, 13 Jan 2026 21:01:50 +0000 Subject: [PATCH 2/2] feat(parent): implement parent dashboard screens --- .../gamification/achievement-unlock-toast.tsx | 10 +- .../src/core/functions/parent.ts | 250 ++++++++++++++++++ .../src/hooks/use-parent-dashboard.ts | 178 +++---------- .../src/routes/_auth/app/parent/alerts.tsx | 3 +- .../src/routes/_auth/app/parent/index.tsx | 5 + .../src/routes/_auth/app/parent/profile.tsx | 5 + .../src/routes/_auth/app/parent/stats.tsx | 54 ++-- .../src/routes/_auth/app/progress.tsx | 2 +- .../src/routes/_auth/route.tsx | 1 - 9 files changed, 346 insertions(+), 162 deletions(-) create mode 100644 apps/user-application/src/core/functions/parent.ts diff --git a/apps/user-application/src/components/gamification/achievement-unlock-toast.tsx b/apps/user-application/src/components/gamification/achievement-unlock-toast.tsx index 4b77248..64b6c8b 100644 --- a/apps/user-application/src/components/gamification/achievement-unlock-toast.tsx +++ b/apps/user-application/src/components/gamification/achievement-unlock-toast.tsx @@ -170,6 +170,7 @@ export function AchievementUnlockToast({ achievements, onDismiss }: AchievementU {/* Close button */}