Bible Reader Integration: - Fetch and display active reading plan progress in Bible reader - Progress bar shows plan completion percentage when user has active plan - Progress bar color changes to green for active plans - Display "Plan Name Progress" with days completed below bar Reading Plan Navigation: - Add "Read the Bible" button to active reading plan detail page - Button shows current/next reading selection (Day X: Book Chapter) - Navigate directly to Bible reader with correct book and chapter - Smart selection: current day if incomplete, next incomplete day if current is done - Only shown for ACTIVE plans Technical: - Load active plans via /api/user/reading-plans endpoint - Calculate progress from completedDays vs plan duration - Use getCurrentReading() helper to determine next reading - URL encoding for book names with spaces 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
536 lines
16 KiB
TypeScript
536 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import { useLocale } from 'next-intl'
|
|
import { useAuth } from '@/hooks/use-auth'
|
|
import { ProtectedRoute } from '@/components/auth/protected-route'
|
|
import {
|
|
Container,
|
|
Box,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Alert,
|
|
LinearProgress,
|
|
IconButton,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
ListItemIcon,
|
|
Checkbox,
|
|
Paper,
|
|
Divider,
|
|
TextField,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions
|
|
} from '@mui/material'
|
|
import Grid from '@mui/material/Grid'
|
|
import {
|
|
ArrowBack,
|
|
CheckCircle,
|
|
RadioButtonUnchecked,
|
|
CalendarToday,
|
|
LocalFireDepartment,
|
|
EmojiEvents,
|
|
TrendingUp,
|
|
Edit,
|
|
Save,
|
|
MenuBook
|
|
} from '@mui/icons-material'
|
|
import Link from 'next/link'
|
|
|
|
interface UserPlan {
|
|
id: string
|
|
name: string
|
|
startDate: string
|
|
targetEndDate: string
|
|
status: string
|
|
currentDay: number
|
|
completedDays: number
|
|
streak: number
|
|
longestStreak: number
|
|
plan?: {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
duration: number
|
|
schedule: any
|
|
}
|
|
customSchedule?: any
|
|
progress: ProgressEntry[]
|
|
}
|
|
|
|
interface ProgressEntry {
|
|
id: string
|
|
planDay: number
|
|
bookId: string
|
|
chapterNum: number
|
|
versesRead: string | null
|
|
completed: boolean
|
|
notes: string | null
|
|
date: string
|
|
}
|
|
|
|
export default function ReadingPlanDetailPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const locale = useLocale()
|
|
const { user } = useAuth()
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
const [plan, setPlan] = useState<UserPlan | null>(null)
|
|
const [error, setError] = useState('')
|
|
const [notesDialog, setNotesDialog] = useState<{ open: boolean; day: number; notes: string }>({
|
|
open: false,
|
|
day: 0,
|
|
notes: ''
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadPlan()
|
|
}, [params.id])
|
|
|
|
const loadPlan = async () => {
|
|
setLoading(true)
|
|
setError('')
|
|
|
|
try {
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) {
|
|
router.push(`/${locale}/login`)
|
|
return
|
|
}
|
|
|
|
const response = await fetch(`/api/user/reading-plans/${params.id}`, {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
setPlan(data.plan)
|
|
} else {
|
|
setError(data.error || 'Failed to load reading plan')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading plan:', err)
|
|
setError('Failed to load reading plan')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const markDayComplete = async (day: number, reading: any) => {
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) return
|
|
|
|
try {
|
|
const response = await fetch(`/api/user/reading-plans/${params.id}/progress`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
planDay: day,
|
|
bookId: reading.book,
|
|
chapterNum: reading.chapter,
|
|
versesRead: reading.verses || null,
|
|
completed: true
|
|
})
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
loadPlan() // Reload to get updated progress
|
|
} else {
|
|
setError(data.error || 'Failed to mark reading complete')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error marking reading complete:', err)
|
|
setError('Failed to mark reading complete')
|
|
}
|
|
}
|
|
|
|
const saveNotes = async () => {
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) return
|
|
|
|
const schedule = plan?.plan?.schedule || plan?.customSchedule
|
|
if (!schedule || !Array.isArray(schedule)) return
|
|
|
|
const daySchedule = schedule[notesDialog.day - 1]
|
|
if (!daySchedule || !daySchedule.readings || daySchedule.readings.length === 0) return
|
|
|
|
const reading = daySchedule.readings[0]
|
|
|
|
try {
|
|
const response = await fetch(`/api/user/reading-plans/${params.id}/progress`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
planDay: notesDialog.day,
|
|
bookId: reading.book,
|
|
chapterNum: reading.chapter,
|
|
notes: notesDialog.notes
|
|
})
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success) {
|
|
setNotesDialog({ open: false, day: 0, notes: '' })
|
|
loadPlan()
|
|
} else {
|
|
setError(data.error || 'Failed to save notes')
|
|
}
|
|
} catch (err) {
|
|
console.error('Error saving notes:', err)
|
|
setError('Failed to save notes')
|
|
}
|
|
}
|
|
|
|
const isDayCompleted = (day: number) => {
|
|
if (!plan) return false
|
|
return plan.progress.some(p => p.planDay === day && p.completed)
|
|
}
|
|
|
|
const getDayNotes = (day: number) => {
|
|
if (!plan) return ''
|
|
const entry = plan.progress.find(p => p.planDay === day)
|
|
return entry?.notes || ''
|
|
}
|
|
|
|
const getCurrentReading = () => {
|
|
if (!plan) return null
|
|
|
|
const schedule = plan.plan?.schedule || plan.customSchedule
|
|
if (!schedule || !Array.isArray(schedule)) return null
|
|
|
|
// Get the current day's reading (or first incomplete day)
|
|
let dayToRead = plan.currentDay
|
|
|
|
// If current day is completed, find the next incomplete day
|
|
if (isDayCompleted(dayToRead)) {
|
|
for (let i = dayToRead; i <= schedule.length; i++) {
|
|
if (!isDayCompleted(i)) {
|
|
dayToRead = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
const daySchedule = schedule[dayToRead - 1]
|
|
if (!daySchedule || !daySchedule.readings || daySchedule.readings.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const reading = daySchedule.readings[0]
|
|
return {
|
|
day: dayToRead,
|
|
book: reading.book,
|
|
chapter: reading.chapter,
|
|
verses: reading.verses
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<ProtectedRoute>
|
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
|
<CircularProgress />
|
|
</Box>
|
|
</Container>
|
|
</ProtectedRoute>
|
|
)
|
|
}
|
|
|
|
if (!plan) {
|
|
return (
|
|
<ProtectedRoute>
|
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
<Alert severity="error">Reading plan not found</Alert>
|
|
<Button
|
|
component={Link}
|
|
href={`/${locale}/reading-plans`}
|
|
startIcon={<ArrowBack />}
|
|
sx={{ mt: 2 }}
|
|
>
|
|
Back to Reading Plans
|
|
</Button>
|
|
</Container>
|
|
</ProtectedRoute>
|
|
)
|
|
}
|
|
|
|
const schedule = plan.plan?.schedule || plan.customSchedule
|
|
const duration = plan.plan?.duration || (Array.isArray(schedule) ? schedule.length : 365)
|
|
const progressPercentage = (plan.completedDays / duration) * 100
|
|
|
|
return (
|
|
<ProtectedRoute>
|
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
{/* Header */}
|
|
<Box mb={4}>
|
|
<Button
|
|
component={Link}
|
|
href={`/${locale}/reading-plans`}
|
|
startIcon={<ArrowBack />}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
Back to Reading Plans
|
|
</Button>
|
|
|
|
<Box display="flex" justifyContent="space-between" alignItems="start" mb={2}>
|
|
<Typography variant="h4" fontWeight="700">
|
|
{plan.name}
|
|
</Typography>
|
|
<Chip label={plan.status} color="primary" />
|
|
</Box>
|
|
|
|
{plan.plan?.description && (
|
|
<Typography variant="body1" color="text.secondary" paragraph>
|
|
{plan.plan.description}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Statistics Cards */}
|
|
<Grid container spacing={3} sx={{ mb: 4 }}>
|
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
<Card>
|
|
<CardContent>
|
|
<Box display="flex" alignItems="center" mb={1}>
|
|
<TrendingUp sx={{ mr: 1, color: 'primary.main' }} />
|
|
<Typography variant="body2" color="text.secondary">
|
|
Progress
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="h5" fontWeight="700">
|
|
{Math.round(progressPercentage)}%
|
|
</Typography>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={progressPercentage}
|
|
sx={{ mt: 1, height: 6, borderRadius: 1 }}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{plan.completedDays} / {duration} days
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
<Card>
|
|
<CardContent>
|
|
<Box display="flex" alignItems="center" mb={1}>
|
|
<LocalFireDepartment sx={{ mr: 1, color: 'error.main' }} />
|
|
<Typography variant="body2" color="text.secondary">
|
|
Current Streak
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="h5" fontWeight="700" color="error.main">
|
|
{plan.streak}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
days in a row
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
<Card>
|
|
<CardContent>
|
|
<Box display="flex" alignItems="center" mb={1}>
|
|
<EmojiEvents sx={{ mr: 1, color: 'warning.main' }} />
|
|
<Typography variant="body2" color="text.secondary">
|
|
Best Streak
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="h5" fontWeight="700" color="warning.main">
|
|
{plan.longestStreak}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
days record
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
|
<Card>
|
|
<CardContent>
|
|
<Box display="flex" alignItems="center" mb={1}>
|
|
<CalendarToday sx={{ mr: 1, color: 'success.main' }} />
|
|
<Typography variant="body2" color="text.secondary">
|
|
Target Date
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="body1" fontWeight="600">
|
|
{new Date(plan.targetEndDate).toLocaleDateString(locale, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})}
|
|
</Typography>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* Read the Bible Button */}
|
|
{plan.status === 'ACTIVE' && (() => {
|
|
const currentReading = getCurrentReading()
|
|
return currentReading ? (
|
|
<Box sx={{ mb: 4, textAlign: 'center' }}>
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
startIcon={<MenuBook />}
|
|
component={Link}
|
|
href={`/${locale}/bible?book=${encodeURIComponent(currentReading.book)}&chapter=${currentReading.chapter}`}
|
|
sx={{
|
|
py: 2,
|
|
px: 4,
|
|
fontSize: '1.1rem',
|
|
fontWeight: 600,
|
|
boxShadow: 3,
|
|
'&:hover': {
|
|
boxShadow: 6
|
|
}
|
|
}}
|
|
>
|
|
Read Today's Selection: {currentReading.book} {currentReading.chapter}
|
|
{currentReading.verses && `:${currentReading.verses}`}
|
|
</Button>
|
|
<Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 1 }}>
|
|
Day {currentReading.day} of {duration}
|
|
</Typography>
|
|
</Box>
|
|
) : null
|
|
})()}
|
|
|
|
{/* Reading Schedule */}
|
|
<Paper elevation={2} sx={{ p: 3 }}>
|
|
<Typography variant="h6" gutterBottom fontWeight="600">
|
|
Reading Schedule
|
|
</Typography>
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
<List>
|
|
{Array.isArray(schedule) && schedule.map((daySchedule: any, index: number) => {
|
|
const day = index + 1
|
|
const isCompleted = isDayCompleted(day)
|
|
const isCurrent = day === plan.currentDay
|
|
const notes = getDayNotes(day)
|
|
|
|
return (
|
|
<ListItem
|
|
key={day}
|
|
sx={{
|
|
bgcolor: isCurrent ? 'primary.light' : isCompleted ? 'success.light' : 'inherit',
|
|
borderRadius: 1,
|
|
mb: 1,
|
|
opacity: isCompleted ? 0.8 : 1
|
|
}}
|
|
secondaryAction={
|
|
<Box display="flex" gap={1}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setNotesDialog({ open: true, day, notes })}
|
|
>
|
|
<Edit />
|
|
</IconButton>
|
|
<Checkbox
|
|
checked={isCompleted}
|
|
onChange={() => {
|
|
if (!isCompleted && daySchedule.readings && daySchedule.readings.length > 0) {
|
|
markDayComplete(day, daySchedule.readings[0])
|
|
}
|
|
}}
|
|
icon={<RadioButtonUnchecked />}
|
|
checkedIcon={<CheckCircle color="success" />}
|
|
/>
|
|
</Box>
|
|
}
|
|
>
|
|
<ListItemIcon>
|
|
<Chip
|
|
label={`Day ${day}`}
|
|
size="small"
|
|
color={isCurrent ? 'primary' : 'default'}
|
|
/>
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={
|
|
<Box>
|
|
{daySchedule.readings?.map((reading: any, i: number) => (
|
|
<Typography key={i} variant="body1" component="span">
|
|
{reading.book} {reading.chapter}
|
|
{reading.verses && `:${reading.verses}`}
|
|
{i < daySchedule.readings.length - 1 && ', '}
|
|
</Typography>
|
|
))}
|
|
</Box>
|
|
}
|
|
secondary={notes && `Notes: ${notes}`}
|
|
/>
|
|
</ListItem>
|
|
)
|
|
})}
|
|
</List>
|
|
|
|
{(!schedule || !Array.isArray(schedule)) && (
|
|
<Typography color="text.secondary" textAlign="center" py={2}>
|
|
No schedule available for this plan
|
|
</Typography>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Notes Dialog */}
|
|
<Dialog open={notesDialog.open} onClose={() => setNotesDialog({ open: false, day: 0, notes: '' })} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Add Notes - Day {notesDialog.day}</DialogTitle>
|
|
<DialogContent>
|
|
<TextField
|
|
fullWidth
|
|
multiline
|
|
rows={4}
|
|
value={notesDialog.notes}
|
|
onChange={(e) => setNotesDialog({ ...notesDialog, notes: e.target.value })}
|
|
placeholder="Add your thoughts, insights, or reflections..."
|
|
sx={{ mt: 2 }}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setNotesDialog({ open: false, day: 0, notes: '' })}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="contained" startIcon={<Save />} onClick={saveNotes}>
|
|
Save
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Container>
|
|
</ProtectedRoute>
|
|
)
|
|
}
|