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

@@ -15,6 +15,8 @@ interface PageProps {
// Helper function to convert readable names to IDs // Helper function to convert readable names to IDs
async function getResourceIds(versionSlug: string, bookSlug: string, chapterNum: string) { async function getResourceIds(versionSlug: string, bookSlug: string, chapterNum: string) {
try { try {
console.log('[SEO URL] Looking for version:', versionSlug, 'book:', bookSlug, 'chapter:', chapterNum)
// Find version by abbreviation (slug) // Find version by abbreviation (slug)
const version = await prisma.bibleVersion.findFirst({ const version = await prisma.bibleVersion.findFirst({
where: { where: {
@@ -26,9 +28,12 @@ async function getResourceIds(versionSlug: string, bookSlug: string, chapterNum:
}) })
if (!version) { if (!version) {
console.log('[SEO URL] Version not found:', versionSlug)
return null return null
} }
console.log('[SEO URL] Found version:', version.abbreviation, version.id)
// Find book by bookKey (slug) within this version // Find book by bookKey (slug) within this version
const book = await prisma.bibleBook.findFirst({ const book = await prisma.bibleBook.findFirst({
where: { where: {
@@ -41,12 +46,23 @@ async function getResourceIds(versionSlug: string, bookSlug: string, chapterNum:
}) })
if (!book) { if (!book) {
console.log('[SEO URL] Book not found for bookKey:', bookSlug, 'in version:', version.abbreviation)
// List available books for debugging
const availableBooks = await prisma.bibleBook.findMany({
where: { versionId: version.id },
select: { bookKey: true, name: true },
take: 5
})
console.log('[SEO URL] Sample available books:', availableBooks)
return null return null
} }
console.log('[SEO URL] Found book:', book.name, book.id)
// Validate chapter number // Validate chapter number
const chapter = parseInt(chapterNum) const chapter = parseInt(chapterNum)
if (isNaN(chapter) || chapter < 1) { if (isNaN(chapter) || chapter < 1) {
console.log('[SEO URL] Invalid chapter number:', chapterNum)
return null return null
} }
@@ -59,9 +75,12 @@ async function getResourceIds(versionSlug: string, bookSlug: string, chapterNum:
}) })
if (!chapterRecord) { if (!chapterRecord) {
console.log('[SEO URL] Chapter not found:', chapter, 'in book:', book.name)
return null return null
} }
console.log('[SEO URL] Successfully resolved:', version.abbreviation, book.name, chapter)
return { return {
versionId: version.id, versionId: version.id,
bookId: book.id, bookId: book.id,
@@ -70,7 +89,7 @@ async function getResourceIds(versionSlug: string, bookSlug: string, chapterNum:
book: book book: book
} }
} catch (error) { } catch (error) {
console.error('Error resolving resource IDs:', error) console.error('[SEO URL] Error resolving resource IDs:', error)
return null return null
} }
} }

View File

@@ -49,7 +49,8 @@ import {
InputLabel, InputLabel,
Select, Select,
Container, Container,
Autocomplete Autocomplete,
LinearProgress
} from '@mui/material' } from '@mui/material'
import { import {
Menu as MenuIcon, Menu as MenuIcon,
@@ -110,6 +111,7 @@ interface BibleBook {
orderNum: number orderNum: number
bookKey: string bookKey: string
chapters: BibleChapter[] chapters: BibleChapter[]
chaptersCount?: number
} }
interface ReadingPreferences { interface ReadingPreferences {
@@ -203,6 +205,10 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({}) const [verseBookmarks, setVerseBookmarks] = useState<{[key: string]: any}>({})
const [bookmarkLoading, setBookmarkLoading] = useState(false) const [bookmarkLoading, setBookmarkLoading] = useState(false)
// Reading progress state
const [readingProgress, setReadingProgress] = useState<any>(null)
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
// Note dialog state // Note dialog state
const [noteDialog, setNoteDialog] = useState<{ const [noteDialog, setNoteDialog] = useState<{
open: boolean open: boolean
@@ -334,7 +340,7 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
const loadVersions = async () => { const loadVersions = async () => {
setVersionsLoading(true) setVersionsLoading(true)
const url = showAllVersions 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}` : `/api/bible/versions?language=${locale}`
try { try {
@@ -344,9 +350,8 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
if (data.success && data.versions) { if (data.success && data.versions) {
setVersions(data.versions) setVersions(data.versions)
// Keep current selection if it exists in new list, otherwise select default/first // Only auto-select if there's NO current selection
const currentVersionExists = data.versions.some((v: BibleVersion) => v.id === selectedVersion) if (!selectedVersion) {
if (!currentVersionExists || !selectedVersion) {
// Try to load user's favorite version first // Try to load user's favorite version first
let versionToSelect = null let versionToSelect = null
@@ -389,7 +394,8 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
} }
loadVersions() 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 // Handle URL parameters for bookmark navigation
useEffect(() => { useEffect(() => {
@@ -490,6 +496,41 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
} }
}, [selectedVersion, debouncedVersion]) }, [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 // Fetch books when debounced version changes
useEffect(() => { useEffect(() => {
if (debouncedVersion) { if (debouncedVersion) {
@@ -540,9 +581,14 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
// Only update state if the request wasn't aborted // Only update state if the request wasn't aborted
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
setBooks(data.books || []) // Transform books to include chaptersCount
if (data.books && data.books.length > 0 && !initialBook) { const transformedBooks = (data.books || []).map((book: any) => ({
setSelectedBook(data.books[0].id) ...book,
chaptersCount: book.chapters?.length || 0
}))
setBooks(transformedBooks)
if (transformedBooks.length > 0 && !initialBook) {
setSelectedBook(transformedBooks[0].id)
} }
setLoading(false) 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 = () => { const getThemeStyles = () => {
switch (preferences.theme) { switch (preferences.theme) {
case 'dark': case 'dark':
@@ -1432,6 +1556,33 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
</Tooltip> </Tooltip>
)} )}
</Box> </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> </Paper>
) )

View File

@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import jwt from 'jsonwebtoken'
export const runtime = 'nodejs'
// Get user's reading progress for a specific version
export async function GET(request: Request) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const token = authHeader.substring(7)
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }
const { searchParams } = new URL(request.url)
const versionId = searchParams.get('versionId')
if (!versionId) {
// Get all reading progress for user
const allProgress = await prisma.readingHistory.findMany({
where: { userId: decoded.userId },
orderBy: { viewedAt: 'desc' }
})
return NextResponse.json({
success: true,
progress: allProgress
})
}
// Get reading progress for specific version
const progress = await prisma.readingHistory.findUnique({
where: {
userId_versionId: {
userId: decoded.userId,
versionId: versionId
}
}
})
return NextResponse.json({
success: true,
progress: progress || null
})
} catch (error) {
console.error('Error getting reading progress:', error)
return NextResponse.json(
{ success: false, error: 'Failed to get reading progress' },
{ status: 500 }
)
}
}
// Save/update user's reading progress
export async function POST(request: Request) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const token = authHeader.substring(7)
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }
const body = await request.json()
const { versionId, bookId, chapterNum, verseNum } = body
if (!versionId || !bookId || chapterNum === undefined) {
return NextResponse.json(
{ success: false, error: 'versionId, bookId, and chapterNum are required' },
{ status: 400 }
)
}
// Upsert reading progress (update if exists, create if not)
const progress = await prisma.readingHistory.upsert({
where: {
userId_versionId: {
userId: decoded.userId,
versionId: versionId
}
},
update: {
bookId,
chapterNum,
verseNum,
viewedAt: new Date()
},
create: {
userId: decoded.userId,
versionId,
bookId,
chapterNum,
verseNum
}
})
return NextResponse.json({
success: true,
message: 'Reading progress saved',
progress
})
} catch (error) {
console.error('Error saving reading progress:', error)
return NextResponse.json(
{ success: false, error: 'Failed to save reading progress' },
{ status: 500 }
)
}
}

View File

@@ -277,6 +277,7 @@ model UserPrayer {
model ReadingHistory { model ReadingHistory {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
versionId String // Bible version ID
bookId String bookId String
chapterNum Int chapterNum Int
verseNum Int? verseNum Int?
@@ -285,6 +286,8 @@ model ReadingHistory {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, viewedAt]) @@index([userId, viewedAt])
@@index([userId, versionId])
@@unique([userId, versionId]) // Only one reading position per user per version
} }
model UserPreference { model UserPreference {

View File

@@ -1,8 +1,8 @@
// Biblical Guide Service Worker // Biblical Guide Service Worker
const CACHE_NAME = 'biblical-guide-v1.0.0'; const CACHE_NAME = 'biblical-guide-v1.0.1';
const STATIC_CACHE = 'biblical-guide-static-v1.0.0'; const STATIC_CACHE = 'biblical-guide-static-v1.0.1';
const DYNAMIC_CACHE = 'biblical-guide-dynamic-v1.0.0'; const DYNAMIC_CACHE = 'biblical-guide-dynamic-v1.0.1';
const BIBLE_CACHE = 'biblical-guide-bible-v1.0.0'; const BIBLE_CACHE = 'biblical-guide-bible-v1.0.1';
// Static resources that should be cached immediately // Static resources that should be cached immediately
const STATIC_ASSETS = [ const STATIC_ASSETS = [

BIN
screenshots/IMG_1517.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

BIN
screenshots/IMG_1518.PNG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB