diff --git a/package-lock.json b/package-lock.json index 90aee52..b6f5edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.10.10", + "version": "2.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.10.10", + "version": "2.11.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 6bcbe9f..3a31868 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.10.10", + "version": "2.11.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index fc27dff..5fadae2 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,7 @@ import { scheduleStudyReminders, scheduleReengagementEmails, scheduleWeeklyDigestEmails, + backfillCompletedStudies, } from './server/study-helpers.js' import { recordVisitor } from './server/routes/analytics.js' @@ -195,6 +196,11 @@ Promise.all([ .catch(err => { console.error('[stats] failed to load persisted stats:', err) }) + .then(() => backfillCompletedStudies()) + .then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) }) + .catch(err => { + console.error('[study] completed-study backfill failed:', err) + }) .finally(() => { createBackupSnapshot('startup').catch(() => {}) setInterval(() => { diff --git a/server/data.js b/server/data.js index 1acf86a..0505680 100644 --- a/server/data.js +++ b/server/data.js @@ -1489,6 +1489,9 @@ export function sanitizeStudyUsers(value) { const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs) ? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER) : [] + const completedStudySlugs = Array.isArray(item?.completedStudySlugs) + ? Array.from(new Set(item.completedStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER * 4) + : [] const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : '' const subscribeNewsletter = item?.subscribeNewsletter !== false const studyRemindersEnabled = item?.studyRemindersEnabled === true @@ -1523,6 +1526,11 @@ export function sanitizeStudyUsers(value) { subscribeNewsletter, studyRemindersEnabled, enrolledStudySlugs, + completedStudySlugs, + currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0, + longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0, + lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null, + firstCompletionAt: typeof item?.firstCompletionAt === 'string' ? item.firstCompletionAt : null, avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '', createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null, updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null, diff --git a/server/routes/study-account.js b/server/routes/study-account.js index e592f3a..195c656 100644 --- a/server/routes/study-account.js +++ b/server/routes/study-account.js @@ -28,6 +28,7 @@ import { findStudyUserByUsername, getStudyAvatarUrl, isStudyUserEnrolled, + isStudyUserCompleted, normalizeStudySlug, getStudyCatalog, clearStudySessionCookie, @@ -212,6 +213,7 @@ export function register(app) { title: study.title, status: study.status, enrolled: isStudyUserEnrolled(user, study.slug), + completed: isStudyUserCompleted(user, study.slug), totalLessons, completedLessons, noteCount, diff --git a/server/routes/study-auth.js b/server/routes/study-auth.js index 30fc8e6..ea87083 100644 --- a/server/routes/study-auth.js +++ b/server/routes/study-auth.js @@ -37,6 +37,7 @@ import { storeEmailOtp, verifyEmailOtp, getStudyUserFromRequest, + getAccessibleStudySlugs, } from '../study-helpers.js' import { sendEmailOtp, @@ -69,7 +70,9 @@ export function register(app) { subscribeNewsletter: user?.subscribeNewsletter !== false, studyRemindersEnabled: user?.studyRemindersEnabled === true, avatarUrl: user ? getStudyAvatarUrl(user) : '', - enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [], + // Studies the student can open: enrolled ones plus any they have finished + enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [], + completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [], emailVerified: user?.emailVerified === true, totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))), twoFaMethod: user?.twoFaMethod ?? null, @@ -211,7 +214,8 @@ export function register(app) { subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, avatarUrl: getStudyAvatarUrl(user.username), - enrolledStudySlugs: user.enrolledStudySlugs ?? [], + enrolledStudySlugs: getAccessibleStudySlugs(user), + completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [], }) }) diff --git a/server/routes/study-certificate.js b/server/routes/study-certificate.js index ba5cbbd..33fc8bf 100644 --- a/server/routes/study-certificate.js +++ b/server/routes/study-certificate.js @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import rateLimit from 'express-rate-limit' import { requireAdminAuth } from '../auth.js' -import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js' +import { requireStudyAuth, normalizeStudySlug, hasStudyAccess, getStudyTitleBySlug, getRequiredSectionIds, markStudyCompletedIfDone } from '../study-helpers.js' import { state } from '../state.js' import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js' import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js' @@ -15,20 +15,6 @@ const publicCertRateLimiter = rateLimit({ message: { message: 'Too many certificate lookups. Please wait a moment.' }, }) -/** - * Returns the list of required section IDs for a study. Every lesson is required, so a - * certificate is only available once the whole study has been released and completed. - */ -function getRequiredSectionIds(studySlug) { - const content = state.cachedSiteContent - if (!content || !Array.isArray(content.studies)) return null - const study = content.studies.find(s => s?.slug === studySlug) - if (!study || !Array.isArray(study.sections)) return null - return study.sections - .filter(s => s?.id) - .map(s => s.id) -} - export function register(app) { // GET — check eligibility and return existing certificate token if one exists app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => { @@ -36,7 +22,7 @@ export function register(app) { const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Not enrolled in this study.' }); return } @@ -49,6 +35,8 @@ export function register(app) { const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? [] const eligible = requiredIds.every(id => completedIds.includes(id)) + if (eligible) markStudyCompletedIfDone(user, studySlug, completedIds) + const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug) res.json({ @@ -67,7 +55,7 @@ export function register(app) { const studySlug = normalizeStudySlug(req.params.studySlug) if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Not enrolled in this study.' }); return } diff --git a/server/routes/study-comments.js b/server/routes/study-comments.js index d420dce..2037ec7 100644 --- a/server/routes/study-comments.js +++ b/server/routes/study-comments.js @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { requireAdminAuth } from '../auth.js' -import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled, findStudyUserById, getCommunityDisplayName } from '../study-helpers.js' +import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName } from '../study-helpers.js' import { state } from '../state.js' import { queueStudyCommentsWrite } from '../data.js' import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js' @@ -40,7 +40,7 @@ export function register(app) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return } diff --git a/server/routes/study-data.js b/server/routes/study-data.js index fbb4db6..fd7cb27 100644 --- a/server/routes/study-data.js +++ b/server/routes/study-data.js @@ -3,13 +3,15 @@ import { normalizeStudySlug, normalizeLessonSectionId, getStudySlugFromNoteId, - isStudyUserEnrolled, + hasStudyAccess, isEnrollableStudySlug, getStudyTitleBySlug, findStudyUserById, getStudyAvatarUrl, getStudyCatalog, getCommunityDisplayName, + getAccessibleStudySlugs, + markStudyCompletedIfDone, } from '../study-helpers.js' import { state } from '../state.js' import { @@ -73,7 +75,8 @@ export function register(app) { ok: true, studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug, - enrolledStudySlugs: user.enrolledStudySlugs, + enrolledStudySlugs: getAccessibleStudySlugs(user), + completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [], }) }) @@ -96,7 +99,8 @@ export function register(app) { ok: true, studySlug, studyTitle: getStudyTitleBySlug(studySlug) || studySlug, - enrolledStudySlugs: user.enrolledStudySlugs, + enrolledStudySlugs: getAccessibleStudySlugs(user), + completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [], }) }) @@ -116,7 +120,7 @@ export function register(app) { } const user = req.studyUser const noteStudySlug = getStudySlugFromNoteId(sectionId) - if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { + if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to access notes.' }) return } @@ -136,7 +140,7 @@ export function register(app) { return } const noteStudySlug = getStudySlugFromNoteId(sectionId) - if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) { + if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) { res.status(403).json({ message: 'Please enroll in this study to save notes.' }) return } @@ -170,7 +174,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to view progress.' }) return } @@ -197,7 +201,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to update progress.' }) return } @@ -227,8 +231,9 @@ export function register(app) { user.firstCompletionAt = new Date().toISOString() } queueStudyUsersWrite() + const justCompleted = markStudyCompletedIfDone(user, studySlug, studyProgress.completedSectionIds) - res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds }) + res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds, studyCompleted: justCompleted || undefined }) }) app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => { @@ -240,7 +245,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to update progress.' }) return } @@ -267,7 +272,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' }) return } @@ -286,7 +291,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' }) return } @@ -315,7 +320,7 @@ export function register(app) { const studySlug = normalizeStudySlug(req.params.studySlug) const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return } - if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return } + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return } const progress = await loadUserProgress(user.id) const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] } @@ -335,7 +340,7 @@ export function register(app) { const studySlug = normalizeStudySlug(req.params.studySlug) const sectionId = normalizeLessonSectionId(req.params.sectionId) if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return } - if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return } + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return } const progress = await loadUserProgress(user.id) const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] } @@ -358,7 +363,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to view the community.' }) return } @@ -403,7 +408,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, studySlug)) { + if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to post in the community.' }) return } @@ -445,7 +450,7 @@ export function register(app) { return } - if (!isStudyUserEnrolled(user, post.studySlug)) { + if (!hasStudyAccess(user, post.studySlug)) { res.status(403).json({ message: 'Please enroll in this study to reply in the community.' }) return } @@ -472,7 +477,7 @@ export function register(app) { const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : '' const post = state.studyCommunityPosts.find(item => item.id === postId) if (!post) { res.status(404).json({ message: 'Post not found.' }); return } - if (!isStudyUserEnrolled(user, post.studySlug)) { + if (!hasStudyAccess(user, post.studySlug)) { res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return } if (!Array.isArray(post.likedBy)) post.likedBy = [] diff --git a/server/study-helpers.js b/server/study-helpers.js index a387d7b..e1a4cc5 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -22,7 +22,7 @@ import { DOWNLOAD_TOKEN_TTL_MS, } from './config.js' import { state } from './state.js' -import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js' +import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js' // ── Username / slug normalizers ──────────────────────────────────────────── @@ -163,6 +163,68 @@ export function isStudyUserEnrolled(user, studySlug) { return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized) } +// A student who has finished every lesson in a study keeps access to it (lessons, notes, +// certificate) even if they later leave the enrollment. Completion is stored on the user +// so it survives content edits and un-enrolling. +export function isStudyUserCompleted(user, studySlug) { + const normalized = normalizeStudySlug(studySlug) + if (!normalized || !user) return false + return Array.isArray(user.completedStudySlugs) && user.completedStudySlugs.includes(normalized) +} + +export function hasStudyAccess(user, studySlug) { + return isStudyUserEnrolled(user, studySlug) || isStudyUserCompleted(user, studySlug) +} + +// Enrolled studies plus finished ones, deduplicated. Sent to the client as the list of +// studies the student can open. +export function getAccessibleStudySlugs(user) { + const enrolled = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [] + const completed = Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [] + return [...new Set([...enrolled, ...completed])] +} + +// Every lesson (released or not) is required, matching the certificate rules. +export function getRequiredSectionIds(studySlug) { + const content = state.cachedSiteContent + if (!content || !Array.isArray(content.studies)) return null + const study = content.studies.find(item => item?.slug === studySlug) + if (!study || !Array.isArray(study.sections)) return null + return study.sections.filter(item => item?.id).map(item => item.id) +} + +// Marks the study completed on the user when every required lesson is done. Sticky: never +// removed, so a later new lesson doesn't revoke access. Returns true if newly completed. +export function markStudyCompletedIfDone(user, studySlug, completedSectionIds) { + const normalized = normalizeStudySlug(studySlug) + if (!normalized || isStudyUserCompleted(user, normalized)) return false + const required = getRequiredSectionIds(normalized) + if (!required || required.length === 0) return false + if (!required.every(id => completedSectionIds.includes(id))) return false + user.completedStudySlugs = [...(Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : []), normalized] + user.updatedAt = new Date().toISOString() + queueStudyUsersWrite() + return true +} + +// One-off pass at startup so students who finished a study before this existed get the flag. +export async function backfillCompletedStudies() { + if (!state.cachedSiteContent) return 0 + let updated = 0 + for (const user of state.studyUsers) { + const progress = await loadUserProgress(user.id) + // Enrolled studies plus any with saved progress, so students who already left are caught too + const slugs = new Set([ + ...(Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []), + ...Object.keys(progress.byStudy ?? {}), + ]) + for (const slug of slugs) { + if (markStudyCompletedIfDone(user, slug, progress.byStudy[slug]?.completedSectionIds ?? [])) updated++ + } + } + return updated +} + export function findStudyUserById(userId) { if (typeof userId !== 'string' || !userId.trim()) return undefined return state.studyUsers.find(user => user.id === userId) @@ -649,6 +711,7 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) { const userSent = state.studyReminders.users[user.id] ?? {} for (const studySlug of enrolledStudySlugs) { + if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders const study = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) : undefined @@ -724,7 +787,9 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) { const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : [] + // Studies the student already finished aren't "unfinished business" for a come-back nudge. const enrolledStudies = enrolledStudySlugs + .filter(slug => !isStudyUserCompleted(user, slug)) .map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug)) .filter(Boolean) @@ -782,6 +847,7 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) { const newlySentByStudy = {} for (const studySlug of enrolledStudySlugs) { + if (isStudyUserCompleted(user, studySlug)) continue const study = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug) : undefined diff --git a/src/App.css b/src/App.css index de01734..7697f0c 100644 --- a/src/App.css +++ b/src/App.css @@ -4541,6 +4541,19 @@ .account-cert-study .study-cert-heading { display: none; } .account-cert-study .study-cert-canvas { width: 100%; height: auto; max-width: 100%; } +.account-chip--inline { margin-left: 0.5rem; vertical-align: middle; } + +.study-completed-banner { + max-width: 560px; + margin: 0 0 1.25rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(111, 207, 151, 0.4); + border-radius: 12px; + background: rgba(111, 207, 151, 0.08); + color: #b9e2c9; + font-size: 0.95rem; +} + /* ── Study hub (guest landing) ── */ .study-hub-count { margin: 1.25rem 0 0; diff --git a/src/studyPages.tsx b/src/studyPages.tsx index b76cb32..4431755 100644 --- a/src/studyPages.tsx +++ b/src/studyPages.tsx @@ -67,6 +67,7 @@ type StudyAccountOverview = { title: string status: 'active' | 'planned' | 'complete' enrolled: boolean + completed?: boolean totalLessons: number completedLessons: number noteCount: number @@ -703,10 +704,14 @@ export function StudyLandingPage({ content }: Props) { }, []) const enrolledStudies = overview?.studies.filter(study => study.enrolled) ?? [] + // Finished studies stay in the classroom (with their certificate) even if the student left the enrollment + const classroomStudies = [...(overview?.studies.filter(study => study.enrolled || study.completed) ?? [])] + .sort((a, b) => Number(Boolean(a.completed)) - Number(Boolean(b.completed))) + const completedStudyCount = classroomStudies.filter(study => study.completed).length const availableStudies = [...(overview?.studies ?? [])].sort((a, b) => Number(b.status === 'active') - Number(a.status === 'active')) const totalEnrolled = enrolledStudies.length - const totalCompletedLessons = enrolledStudies.reduce((sum, study) => sum + study.completedLessons, 0) - const totalLessons = enrolledStudies.reduce((sum, study) => sum + study.totalLessons, 0) + const totalCompletedLessons = classroomStudies.reduce((sum, study) => sum + study.completedLessons, 0) + const totalLessons = classroomStudies.reduce((sum, study) => sum + study.totalLessons, 0) if (loading) { return ( @@ -744,6 +749,7 @@ export function StudyLandingPage({ content }: Props) {
{totalEnrolled} enrolled track{totalEnrolled === 1 ? '' : 's'} + {completedStudyCount > 0 && {completedStudyCount} completed} {totalCompletedLessons}/{totalLessons} lessons completed {overview.stats.noteCount} notes saved {auth.studyRemindersEnabled ? Lesson reminders enabled : Reminders off} @@ -778,6 +784,7 @@ export function StudyLandingPage({ content }: Props) {

{studyInfo.title}

{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes

+ {studyInfo.completed &&

✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.

}
{confirmLeaveSlug === studyInfo.slug ? (
@@ -819,6 +826,7 @@ export function StudyLandingPage({ content }: Props) {

{studyInfo.title}

{!studyInfo.enrolled && studyInfo.status === 'planned' ?

Not open for enrollment yet

:

{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes

} + {studyInfo.completed &&

✓ Completed — you keep your lessons, notes and certificate.

}
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
@@ -886,10 +894,10 @@ export function StudyLandingPage({ content }: Props) {

Your Classroom

-

Enrolled Tracks

+

Your Tracks

- {totalEnrolled === 0 ? ( + {classroomStudies.length === 0 ? (

No enrollments yet

You are signed in, but you have not enrolled in any study track yet. Browse the available tracks and join the one you want to start.

@@ -897,14 +905,14 @@ export function StudyLandingPage({ content }: Props) {
) : (
- {enrolledStudies.map(study => { + {classroomStudies.map(study => { const studyMeta = getStudyBySlug(studies, study.slug) const progress = study.totalLessons > 0 ? Math.round((study.completedLessons / study.totalLessons) * 100) : 0 return (
-

{study.enrolled ? 'Enrolled' : 'Track'}

+

{study.completed ? '✓ Completed' : study.enrolled ? 'Enrolled' : 'Track'}

{study.title}

{studyMeta?.description ?? 'Continue your study with the lessons and notes available in this track.'}

@@ -921,9 +929,10 @@ export function StudyLandingPage({ content }: Props) {

{progress}% complete

- Continue + {study.completed ? 'Review Lessons' : 'Continue'} My Notes Community + {study.completed && 🏅 Certificate}
@@ -1621,6 +1630,7 @@ export function ColossiansStudyIndexPage({ content }: Props) { // Resume at the first released lesson the student hasn't finished yet const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0 + const allLessonsDone = enrolled && !progressLoading && sections.length > 0 && sections.every(item => studyProgress.completedSectionIds.includes(item.id)) const resumeChapter = resumeSection?.chapter ?? chapterSummaries.find(chapter => chapter.lessonCount > 0)?.chapter const completedCount = sections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length const chaptersWithLessons = chapterSummaries.filter(chapter => chapter.lessonCount > 0) @@ -1659,6 +1669,9 @@ export function ColossiansStudyIndexPage({ content }: Props) { Student notes enabled {isSelfPaced && Finished study — go at your own pace}
+ {allLessonsDone && ( +

🎉 You've completed this study. Your lessons, notes and certificate are yours to keep.

+ )} {enrolled && !progressLoading && sections.length > 0 && (
@@ -1671,13 +1684,14 @@ export function ColossiansStudyIndexPage({ content }: Props) {
)}
- {enrolled && resumeSection && {hasStarted ? 'Resume Where You Left Off' : 'Start Class'}} + {enrolled && resumeSection && {allLessonsDone ? 'Review Lessons' : hasStarted ? 'Resume Where You Left Off' : 'Start Class'}} {!auth.authenticated && Sign In to Enroll} {auth.authenticated && !enrolled && ( )} + {allLessonsDone && 🏅 My Certificate} {enrolled && My Notes} {enrolled && Community} {auth.authenticated && My Account} @@ -3219,7 +3233,8 @@ export function StudyAccountPage() { ) } - const enrolledProgress = overview?.studies.filter(item => item.enrolled && item.totalLessons > 0) ?? [] + // Finished studies stay listed (with their certificate) even after the student leaves the enrollment + const enrolledProgress = overview?.studies.filter(item => (item.enrolled || item.completed) && item.totalLessons > 0) ?? [] const memberSince = overview?.stats.memberSince && !isNaN(new Date(overview.stats.memberSince).getTime()) ? new Date(overview.stats.memberSince).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—' @@ -3288,7 +3303,7 @@ export function StudyAccountPage() { return (
- {item.title} + {item.title}{item.completed && Completed} {item.completedLessons}/{item.totalLessons} lessons · {pct}%
@@ -3464,6 +3479,7 @@ export function StudyAccountPage() {

{studyInfo.title}

{!studyInfo.enrolled && studyInfo.status === 'planned' ?

Not open for enrollment yet

:

{studyInfo.completedLessons} of {studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes

} + {studyInfo.completed &&

✓ Completed — leaving only stops reminders. You keep your lessons, notes and certificate.

}
{!studyInfo.enrolled && studyInfo.status === 'planned' ? ( Coming soon diff --git a/test/study-completion.test.js b/test/study-completion.test.js new file mode 100644 index 0000000..3f88f54 --- /dev/null +++ b/test/study-completion.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { state } from '../server/state.js' +import { + hasStudyAccess, + isStudyUserCompleted, + getAccessibleStudySlugs, + markStudyCompletedIfDone, + scheduleReengagementEmails, +} from '../server/study-helpers.js' + +function setup() { + state.cachedSiteContent = { + studies: [{ slug: 'mini', sections: [{ id: 'a' }, { id: 'b' }] }, { slug: 'other', sections: [{ id: 'x' }] }], + } +} + +test('completing every lesson marks the study completed and keeps access after leaving', () => { + setup() + const user = { id: 'u1', enrolledStudySlugs: ['mini'] } + assert.equal(markStudyCompletedIfDone(user, 'mini', ['a']), false) + assert.equal(markStudyCompletedIfDone(user, 'mini', ['a', 'b']), true) + assert.ok(isStudyUserCompleted(user, 'mini')) + + user.enrolledStudySlugs = [] // student leaves + assert.ok(hasStudyAccess(user, 'mini')) + assert.ok(!hasStudyAccess(user, 'other')) + assert.deepEqual(getAccessibleStudySlugs(user), ['mini']) +}) + +test('completion is sticky when a lesson is added later, and not marked twice', () => { + setup() + const user = { id: 'u2', enrolledStudySlugs: ['mini'] } + markStudyCompletedIfDone(user, 'mini', ['a', 'b']) + state.cachedSiteContent.studies[0].sections.push({ id: 'c' }) + assert.equal(markStudyCompletedIfDone(user, 'mini', ['a', 'b']), false) + assert.ok(isStudyUserCompleted(user, 'mini')) + assert.deepEqual(user.completedStudySlugs, ['mini']) +}) + +test('unknown or empty studies are never marked completed', () => { + setup() + const user = { id: 'u3', enrolledStudySlugs: [] } + assert.equal(markStudyCompletedIfDone(user, 'nope', ['a']), false) + state.cachedSiteContent.studies.push({ slug: 'empty', sections: [] }) + assert.equal(markStudyCompletedIfDone(user, 'empty', []), false) +}) + +test('re-engagement emails skip students whose only study is finished', async () => { + setup() + const sent = [] + state.studyUsers = [{ + id: 'u4', username: 'a@b.co', studyRemindersEnabled: true, lastLoginAt: new Date(Date.now() - 40 * 864e5).toISOString(), + enrolledStudySlugs: ['mini'], completedStudySlugs: ['mini'], reengagementSentAt: {}, + }, { + id: 'u5', username: 'c@d.co', studyRemindersEnabled: true, lastLoginAt: new Date(Date.now() - 40 * 864e5).toISOString(), + enrolledStudySlugs: ['mini'], reengagementSentAt: {}, + }] + await scheduleReengagementEmails(async (email) => { sent.push(email) }) + assert.deepEqual(sent, ['c@d.co']) +})