feat: implement reading progress tracking system

Database & API:
- Enhanced ReadingHistory model with versionId field and unique constraint per user/version
- Created /api/user/reading-progress endpoint (GET/POST) for saving and retrieving progress
- Upsert operation ensures one reading position per user per Bible version

Bible Reader Features:
- Auto-save reading position after 2 seconds of inactivity
- Auto-restore last reading position on page load (respects URL parameters)
- Visual progress bar showing completion percentage based on chapters read
- Calculate progress across entire Bible (current chapter / total chapters)
- Client-side only loading to prevent hydration mismatches

Bug Fixes:
- Remove 200 version limit when loading "all versions" - now loads ALL versions
- Fix version selection resetting to favorite when user manually selects different version
- Transform books API response to include chaptersCount property
- Update service worker cache version to force client updates
- Add comprehensive SEO URL logging for debugging 404 issues

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-30 11:42:39 +00:00
parent 2ae2f029ec
commit 2a031cdf76
8 changed files with 300 additions and 14 deletions

View File

@@ -49,7 +49,8 @@ import {
InputLabel,
Select,
Container,
Autocomplete
Autocomplete,
LinearProgress
} from '@mui/material'
import {
Menu as MenuIcon,
@@ -110,6 +111,7 @@ interface BibleBook {
orderNum: number
bookKey: string
chapters: BibleChapter[]
chaptersCount?: number
}
interface ReadingPreferences {
@@ -203,6 +205,10 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
const [bookmarkLoading, setBookmarkLoading] = useState(false)
// Reading progress state
const [readingProgress, setReadingProgress] = useState<any>(null)
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
// Note dialog state
const [noteDialog, setNoteDialog] = useState<{
open: boolean
@@ -334,7 +340,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
const loadVersions = async () => {
setVersionsLoading(true)
const url = showAllVersions
? '/api/bible/versions?all=true&limit=200' // Limit to first 200 for performance
? '/api/bible/versions?all=true' // Load ALL versions, no limit
: `/api/bible/versions?language=${locale}`
try {
@@ -344,9 +350,8 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
if (data.success && data.versions) {
setVersions(data.versions)
// Keep current selection if it exists in new list, otherwise select default/first
const currentVersionExists = data.versions.some((v: BibleVersion) => v.id === selectedVersion)
if (!currentVersionExists || !selectedVersion) {
// Only auto-select if there's NO current selection
if (!selectedVersion) {
// Try to load user's favorite version first
let versionToSelect = null
@@ -389,7 +394,8 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
}
loadVersions()
}, [locale, showAllVersions, selectedVersion, user])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [locale, showAllVersions, user]) // Removed selectedVersion from dependencies to prevent infinite loop
// Handle URL parameters for bookmark navigation
useEffect(() => {
@@ -490,6 +496,41 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
}
}, [selectedVersion, debouncedVersion])
// Load reading progress when version changes
useEffect(() => {
// Only run on client side to avoid hydration mismatch
if (typeof window === 'undefined') return
const loadProgress = async () => {
if (debouncedVersion && user && !hasLoadedInitialProgress) {
const progress = await loadReadingProgress(debouncedVersion)
if (progress) {
setReadingProgress(progress)
// Only restore position if we haven't loaded from URL params
if (!effectiveParams.get('book') && !effectiveParams.get('chapter')) {
setSelectedBook(progress.bookId)
setSelectedChapter(progress.chapterNum)
}
}
setHasLoadedInitialProgress(true)
}
}
loadProgress()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedVersion, user, hasLoadedInitialProgress, effectiveParams])
// Save reading progress when chapter changes
useEffect(() => {
if (selectedVersion && selectedBook && selectedChapter && user) {
// Debounce saving to avoid too many API calls
const timer = setTimeout(() => {
saveReadingProgress(selectedVersion, selectedBook, selectedChapter)
}, 2000) // Save after 2 seconds of no changes
return () => clearTimeout(timer)
}
}, [selectedVersion, selectedBook, selectedChapter, user])
// Fetch books when debounced version changes
useEffect(() => {
if (debouncedVersion) {
@@ -540,9 +581,14 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
// Only update state if the request wasn't aborted
if (!abortController.signal.aborted) {
setBooks(data.books || [])
if (data.books && data.books.length > 0 && !initialBook) {
setSelectedBook(data.books[0].id)
// Transform books to include chaptersCount
const transformedBooks = (data.books || []).map((book: any) => ({
...book,
chaptersCount: book.chapters?.length || 0
}))
setBooks(transformedBooks)
if (transformedBooks.length > 0 && !initialBook) {
setSelectedBook(transformedBooks[0].id)
}
setLoading(false)
}
@@ -1069,6 +1115,84 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
}
}
// Save reading progress
const saveReadingProgress = async (versionId: string, bookId: string, chapterNum: number) => {
if (!user) return
const token = localStorage.getItem('authToken')
if (!token) return
try {
await fetch('/api/user/reading-progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
versionId,
bookId,
chapterNum,
verseNum: null
})
})
} catch (error) {
console.error('Error saving reading progress:', error)
}
}
// Load reading progress for current version
const loadReadingProgress = async (versionId: string) => {
if (!user) return null
const token = localStorage.getItem('authToken')
if (!token) return null
try {
const response = await fetch(`/api/user/reading-progress?versionId=${versionId}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await response.json()
if (data.success && data.progress) {
return data.progress
}
} catch (error) {
console.error('Error loading reading progress:', error)
}
return null
}
// Calculate reading progress percentage
const calculateProgress = () => {
if (!books.length || !selectedBook || !selectedChapter) return 0
// Find current book index and total chapters before current position
let totalChaptersBefore = 0
let foundCurrentBook = false
let currentBookChapters = 0
for (const book of books) {
if (book.id === selectedBook) {
foundCurrentBook = true
currentBookChapters = book.chaptersCount || 0
// Add chapters from current book up to current chapter
totalChaptersBefore += selectedChapter
break
}
if (!foundCurrentBook) {
totalChaptersBefore += book.chaptersCount || 0
}
}
// Calculate total chapters in entire Bible
const totalChapters = books.reduce((sum, book) => sum + (book.chaptersCount || 0), 0)
if (totalChapters === 0) return 0
const percentage = (totalChaptersBefore / totalChapters) * 100
return Math.min(Math.round(percentage), 100)
}
const getThemeStyles = () => {
switch (preferences.theme) {
case 'dark':
@@ -1432,6 +1556,33 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
</Tooltip>
)}
</Box>
{/* Reading Progress Bar */}
{user && books.length > 0 && (
<Box sx={{ mt: 2, px: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Reading Progress
</Typography>
<Typography variant="caption" color="primary" sx={{ fontWeight: 'bold' }}>
{calculateProgress()}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={calculateProgress()}
sx={{
height: 6,
borderRadius: 3,
backgroundColor: 'action.hover',
'& .MuiLinearProgress-bar': {
borderRadius: 3,
backgroundColor: 'primary.main'
}
}}
/>
</Box>
)}
</Paper>
)