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:
+37
-6
@@ -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}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user