v1.1.0 — RSS feed, PWA, lesson comments, progress tracking + streaks

RSS Feed
- /feed.xml proxies the Anchor feed under the site's canonical domain
- Rewrites channel <link> and atom:link self-ref to the site URL
- Served with 30-min Cache-Control, reuses the existing episode cache

PWA
- vite-plugin-pwa installed; Workbox service worker auto-generated on build
- manifest.json inlined in vite.config.ts (name, icons, theme, standalone)
- pwa-192.png and pwa-512.png generated from existing book_icon.png
- StaleWhileRevalidate for /api/episodes and /api/questions; CacheFirst for images
- API, feed.xml, and uploads routes excluded from navigate fallback

Lesson Comments
- Import and wire StudySectionComments into ColossiansStudySectionPage
- Replaces the CommunityBoard in the Lesson Discussion section
- All routes and moderation already existed; only the render was missing

Progress Tracking + Streaks
- Mark-complete handler now records lastStudiedDate, currentStreak, longestStreak on the user record
- Streak increments on consecutive calendar days, resets on a gap
- /api/study-account/overview now returns streak fields
- Account page: 4-stat summary row (notes, streak 🔥, longest streak, member since)
- Per-study progress bars showing completedLessons/totalLessons with gold → green fill at 100%

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-22 13:19:25 -04:00
parent 455f5c96b3
commit 1e4fe5f0e3
11 changed files with 4614 additions and 108 deletions
+37 -6
View File
@@ -7,6 +7,7 @@ import { Breadcrumbs } from './components/Breadcrumbs'
import { StudyCertificate } from './components/StudyCertificate'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NoteCard } from './components/NoteCard'
import { StudySectionComments } from './components/StudySectionComments'
type Props = { content: SiteContent }
@@ -49,6 +50,9 @@ type StudyAccountOverview = {
noteCount: number
memberSince: string
lastLoginAt: string | null
currentStreak: number
longestStreak: number
lastStudiedDate: string | null
}
studies: Array<{
slug: string
@@ -2064,7 +2068,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
Share a thought or reaction with other students in this lesson.{' '}
<Link to={`/study/${study.slug}/community`} style={{ color: '#e0b840' }}>See all community posts →</Link>
</p>
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} sectionFilter={section.id} />
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
</article>
</div>
@@ -2766,16 +2770,43 @@ export function StudyAccountPage() {
{overview?.stats && (
<>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '2rem' }}>
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.noteCount}</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>Total notes saved</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Notes saved</p>
</div>
<div style={{ flex: '1 1 150px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.currentStreak ?? 0} 🔥</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Day streak</p>
</div>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1.6rem', fontWeight: 700, margin: 0 }}>{overview.stats.longestStreak ?? 0}</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Longest streak</p>
</div>
<div style={{ flex: '1 1 130px', background: '#1a1a16', borderRadius: '8px', padding: '1rem', border: '1px solid #2a2518' }}>
<p style={{ fontSize: '1rem', fontWeight: 600, margin: 0 }}>{overview.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime()) ? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</p>
<p style={{ margin: 0, fontSize: '0.85rem' }}>Member since</p>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#7a7060' }}>Member since</p>
</div>
</div>
{overview.studies.filter(s => s.enrolled && s.totalLessons > 0).length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: '#7a7060' }}>Study Progress</p>
{overview.studies.filter(s => s.enrolled && s.totalLessons > 0).map(s => {
const pct = Math.round((s.completedLessons / s.totalLessons) * 100)
return (
<div key={s.slug} style={{ marginBottom: '0.85rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.88rem', marginBottom: '0.3rem' }}>
<span>{s.title}</span>
<span style={{ color: '#7a7060' }}>{s.completedLessons}/{s.totalLessons} lessons · {pct}%</span>
</div>
<div style={{ height: '6px', background: '#2a2518', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: pct === 100 ? '#4a9a4a' : '#c9a84c', borderRadius: '3px', transition: 'width 0.4s ease' }} />
</div>
</div>
)
})}
</div>
)}
{divider}
</>
)}