diff --git a/pb_migrations/1781000000_created_theme_sessions.js b/pb_migrations/1781000000_created_theme_sessions.js new file mode 100644 index 0000000..850f9e2 --- /dev/null +++ b/pb_migrations/1781000000_created_theme_sessions.js @@ -0,0 +1,203 @@ +/// +migrate((app) => { + const curriculumVersionsCollectionId = "pbc_curriculum_v2"; + const teamMembersCollectionId = "pbc_3980519374"; + + const themeSessions = new Collection({ + "id": "pbc_theme_sessions_0", + "name": "theme_sessions", + "type": "base", + "system": false, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text_id_ts", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "system": false, + "id": "rel_curriculum_version", + "name": "curriculum_version", + "type": "relation", + "required": true, + "presentable": false, + "collectionId": curriculumVersionsCollectionId, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + }, + { + "hidden": false, + "id": "num_week_number_ts", + "max": 26, + "min": 1, + "name": "week_number", + "onlyInt": true, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "system": false, + "id": "text_theme_ts", + "name": "theme", + "type": "text", + "required": true, + "presentable": false, + "max": 0, + "min": 0, + "pattern": "" + }, + { + "system": false, + "id": "json_content_ts", + "name": "content", + "type": "json", + "required": true, + "presentable": false + }, + { + "hidden": false, + "id": "autodate_created_ts", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_updated_ts", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_theme_sessions_version_week` ON `theme_sessions` (`curriculum_version`, `week_number`)" + ], + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "" + }); + + app.save(themeSessions); + + const themeSessionCompletions = new Collection({ + "id": "pbc_theme_session_completions_0", + "name": "theme_session_completions", + "type": "base", + "system": false, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text_id_tsc", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "system": false, + "id": "rel_tsc_team_member", + "name": "team_member_id", + "type": "relation", + "required": true, + "presentable": false, + "collectionId": teamMembersCollectionId, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + }, + { + "system": false, + "id": "rel_tsc_theme_session", + "name": "theme_session_id", + "type": "relation", + "required": true, + "presentable": false, + "collectionId": themeSessions.id, + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + }, + { + "hidden": false, + "id": "num_tsc_session_week", + "max": null, + "min": 1, + "name": "session_week", + "onlyInt": true, + "presentable": false, + "required": true, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "autodate_created_tsc", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": true, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate_updated_tsc", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": true, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_theme_session_completions_user_week` ON `theme_session_completions` (`team_member_id`, `session_week`)" + ], + "listRule": "", + "viewRule": "", + "createRule": "", + "updateRule": "", + "deleteRule": "" + }); + + app.save(themeSessionCompletions); + +}, (app) => { + const themeSessionCompletions = app.findCollectionByNameOrId("theme_session_completions"); + if (themeSessionCompletions) { + app.delete(themeSessionCompletions); + } + const themeSessions = app.findCollectionByNameOrId("theme_sessions"); + if (themeSessions) { + app.delete(themeSessions); + } +}) diff --git a/src/components/theme_session/ThemeSessionView.jsx b/src/components/theme_session/ThemeSessionView.jsx new file mode 100644 index 0000000..e58d099 --- /dev/null +++ b/src/components/theme_session/ThemeSessionView.jsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from 'react'; +import { Loader, BookOpen, CheckCircle2, ArrowRight, Sparkles } from 'lucide-react'; +import Card from '../ui/Card'; +import Button from '../ui/Button'; +import Tag from '../ui/Tag'; +import { getOrGenerateThemeSession } from '../../lib/themeSessionService'; + +/** + * Normalize the content payload coming back from PocketBase, which may be + * either an object or a JSON string depending on how it was written. + */ +function normalizeContent(raw) { + if (!raw) return null; + if (typeof raw === 'string') { + try { return JSON.parse(raw); } catch { return null; } + } + return raw; +} + +export default function ThemeSessionView({ + curriculumVersionId, + weekContent, + alreadyCompleted = false, + onCompleted, + onPracticeTopic, +}) { + const [record, setRecord] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [marked, setMarked] = useState(alreadyCompleted); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + getOrGenerateThemeSession(curriculumVersionId, weekContent) + .then((rec) => { if (!cancelled) setRecord(rec); }) + .catch((err) => { if (!cancelled) setError(err.message || 'Failed to load theme session.'); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [curriculumVersionId, weekContent?.weekNumber]); + + if (loading) { + return ( + + +

Preparing this week's theme session…

+

This may take 15–30 seconds the first time.

+
+ ); + } + + if (error) { + return ( + +

Could not load the theme session

+

{error}

+
+ ); + } + + const content = normalizeContent(record?.content); + if (!content) { + return ( + +

No theme session content available yet.

+
+ ); + } + + const handleFinish = () => { + if (marked) return; + setMarked(true); + if (typeof onCompleted === 'function') { + onCompleted(record); + } + }; + + return ( +
+ +
+ + Theme Session + {weekContent.theme} + {marked && Completed} +
+

{content.title}

+

{content.intro}

+
+ + {content.topicSections.map((section, idx) => ( + +
+ + Topic {idx + 1} of {content.topicSections.length} +
+

{section.label}

+
+ {typeof onPracticeTopic === 'function' && ( +
+ +
+ )} + + ))} + + +

How these connect

+
+ + + +

Key takeaways

+
    + {content.keyTakeaways.map((t, i) => ( +
  • {t}
  • + ))} +
+
+ +
+ +
+
+ ); +} diff --git a/src/lib/db.js b/src/lib/db.js index 0ef7ce6..6d99ae2 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -242,6 +242,61 @@ export async function getLearnDone() { return false; } /** @deprecated learn_progress collection has been dropped. */ export async function setLearnDone() { return null; } +// ── Theme Sessions ────────────────────────────────────────────────────────── + +/** + * Look up the cached theme session for a given (curriculum_version, week_number). + * Returns null when no cache entry exists yet. + */ +export async function getThemeSession(curriculumVersionId, weekNumber) { + try { + const record = await pb.collection('theme_sessions').getFirstListItem( + `curriculum_version = "${curriculumVersionId}" && week_number = ${weekNumber}` + ); + return record; + } catch { + return null; + } +} + +/** + * Upsert the theme session for a given (curriculum_version, week_number). + */ +export async function setThemeSession(curriculumVersionId, weekNumber, theme, content) { + return pbUpsert( + 'theme_sessions', + `curriculum_version = "${curriculumVersionId}" && week_number = ${weekNumber}`, + { theme, content }, + { curriculum_version: curriculumVersionId, week_number: weekNumber, theme, content }, + ); +} + +/** + * Has the user completed the theme session for a given personal week? + * Returns the completion record, or null. + */ +export async function getThemeSessionCompletion(userId, sessionWeek) { + try { + return await pb.collection('theme_session_completions').getFirstListItem( + `team_member_id = "${userId}" && session_week = ${sessionWeek}` + ); + } catch { + return null; + } +} + +/** + * Mark a theme session as completed for the user/week. Idempotent. + */ +export async function setThemeSessionCompletion(userId, themeSessionId, sessionWeek) { + return pbUpsert( + 'theme_session_completions', + `team_member_id = "${userId}" && session_week = ${sessionWeek}`, + { theme_session_id: themeSessionId }, + { team_member_id: userId, theme_session_id: themeSessionId, session_week: sessionWeek }, + ); +} + // ── Leaderboard ─────────────────────────────────────────────────────────────── export async function getLeaderboard() { @@ -484,6 +539,8 @@ export async function resetForSmokeTest({ 'topics', 'micro_learnings', 'micro_learning_completions', + 'theme_session_completions', + 'theme_sessions', 'test_results', ]; if (includeProgress) collections.push('leaderboard'); diff --git a/src/lib/llmSchemas.js b/src/lib/llmSchemas.js index 63ae60f..424d91c 100644 --- a/src/lib/llmSchemas.js +++ b/src/lib/llmSchemas.js @@ -213,6 +213,22 @@ export const replaceTakeawaysPatchSchema = z.object({ items: z.array(z.string().min(1)).min(1), }); +// ── Theme session schema ───────────────────────────────────────────────────── + +const themeSessionTopicSection = z.object({ + topic_id: z.string().min(1), + label: z.string().min(1), + summary: z.string().min(1), +}); + +export const themeSessionSchema = z.object({ + title: z.string().min(1), + intro: z.string().min(1), + topicSections: z.array(themeSessionTopicSection).min(1), + connections: z.string().min(1), + keyTakeaways: z.array(z.string().min(1)).min(3), +}); + /** * Registry mapping known tool names to their input schemas. `callLLM` * consults this when the caller does not pass an explicit `toolSchemas` @@ -235,4 +251,5 @@ export const toolSchemaRegistry = { add_section: addSectionPatchSchema, remove_section: removeSectionPatchSchema, replace_takeaways: replaceTakeawaysPatchSchema, + emit_theme_session: themeSessionSchema, }; diff --git a/src/lib/llmTools.js b/src/lib/llmTools.js index fddb9a0..b68db37 100644 --- a/src/lib/llmTools.js +++ b/src/lib/llmTools.js @@ -418,3 +418,39 @@ export const EMIT_FLASHCARD_SET_TOOL = { }, }; +// ── Theme session (theme-level weekly summary) ─────────────────────────────── + +export const EMIT_THEME_SESSION_TOOL = { + name: 'emit_theme_session', + description: 'Return a detailed weekly theme session that synthesises every topic in the week into one coherent learning experience. Cover each topic in its own section and finish with how the topics connect plus key takeaways for the theme.', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: 'A short, learner-facing title for this weekly theme session. 3–8 words.' }, + intro: { type: 'string', description: 'One-paragraph introduction (3–5 sentences) framing the theme and why it matters to a Respellion employee this week.' }, + topicSections: { + type: 'array', + description: 'One section per topic in the week. Use the topic_id you were given so the UI can link back to per-topic practice.', + items: { + type: 'object', + properties: { + topic_id: { type: 'string', description: 'The id of the topic this section covers (must match one of the topic ids provided).' }, + label: { type: 'string', description: 'The topic label, as provided.' }, + summary: { type: 'string', description: 'Detailed summary of the topic in HTML. Use

,

    ,
  • , . At least 4 sentences with a concrete example or application.' }, + }, + required: ['topic_id', 'label', 'summary'], + }, + minItems: 1, + }, + connections: { type: 'string', description: 'A short HTML paragraph (2–4 sentences) explaining how the topics in this week connect to each other within the theme.' }, + keyTakeaways: { + type: 'array', + items: { type: 'string' }, + minItems: 3, + description: 'At least 3 punchy bullet takeaways covering the theme as a whole.', + }, + }, + required: ['title', 'intro', 'topicSections', 'connections', 'keyTakeaways'], + }, +}; + diff --git a/src/lib/themeSessionService.js b/src/lib/themeSessionService.js new file mode 100644 index 0000000..5646415 --- /dev/null +++ b/src/lib/themeSessionService.js @@ -0,0 +1,61 @@ +import * as db from './db'; +import { callLLM, cachedSystem } from './llm'; +import { EMIT_THEME_SESSION_TOOL } from './llmTools'; + +const THEME_SESSION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company. +You produce theme-level weekly learning sessions for the perpetual 26-week curriculum. +A weekly session is a single, coherent piece of learning that covers every topic the curriculum has slotted for that week's theme — not a list of separate articles. +Write in clear, professional English. Each topic section must be detailed (at least four sentences) and include a concrete workplace example or application. +Emit content only through the emit_theme_session tool.`; + +function buildUserPrompt(weekContent) { + const topicLines = weekContent.topics + .map((t, idx) => `${idx + 1}. id=${t.id} | label="${t.label}" | type=${t.type} | description=${t.description}`) + .join('\n'); + + return `Theme: ${weekContent.theme} +Week ${weekContent.weekNumber} of the 26-week curriculum. +Estimated duration: ${weekContent.estimatedDuration || 30} minutes. +Rationale for this week's position: ${weekContent.rationale || '—'} + +Topics in this theme this week (you MUST cover every one of them, one section each, reusing the exact topic_id): +${topicLines} + +Produce a detailed theme session that synthesises these topics into a single coherent learning experience. Cover every topic in its own section, finish with a "how these connect" paragraph, and at least 3 takeaways for the theme.`; +} + +/** + * Get the cached theme session for the active curriculum version + week, + * or generate one with the LLM and cache it. + * + * @param {string} curriculumVersionId — id of the active curriculum_versions row + * @param {object} weekContent — output of getCurrentWeekContent: { weekNumber, theme, topics[], estimatedDuration, rationale } + * @param {object} [opts] + * @param {boolean} [opts.force=false] — regenerate even if cached + */ +export async function getOrGenerateThemeSession(curriculumVersionId, weekContent, { force = false } = {}) { + if (!curriculumVersionId) throw new Error('Theme session requires an active curriculum version.'); + if (!weekContent || !Array.isArray(weekContent.topics) || weekContent.topics.length === 0) { + throw new Error('Theme session requires at least one topic for the week.'); + } + + if (!force) { + const cached = await db.getThemeSession(curriculumVersionId, weekContent.weekNumber); + if (cached) return cached; + } + + const result = await callLLM({ + task: 'theme_session.generate', + tier: 'standard', + system: cachedSystem(THEME_SESSION_SYSTEM), + user: buildUserPrompt(weekContent), + tools: [EMIT_THEME_SESSION_TOOL], + toolChoice: { type: 'tool', name: EMIT_THEME_SESSION_TOOL.name }, + maxTokens: 8192, + }); + + const emitted = result.toolUses[0]?.input; + if (!emitted) throw new Error('AI did not return a theme session. Please try again.'); + + return db.setThemeSession(curriculumVersionId, weekContent.weekNumber, weekContent.theme, emitted); +} diff --git a/src/pages/Leren.jsx b/src/pages/Leren.jsx index e5ad63f..6f7c123 100644 --- a/src/pages/Leren.jsx +++ b/src/pages/Leren.jsx @@ -1,104 +1,125 @@ import { useState, useEffect } from 'react'; -import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar, CheckSquare } from 'lucide-react'; +import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar, CheckSquare, Sparkles } from 'lucide-react'; import { motion } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; import Card from '../components/ui/Card'; import Button from '../components/ui/Button'; import Tag from '../components/ui/Tag'; import { useApp } from '../store/AppContext'; -import { getAssignedTopic } from '../lib/learningService'; import { generateWeeklyQuiz } from '../lib/testService'; import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService'; import * as db from '../lib/db'; import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector'; -import { useMicroLearningCompletions } from '../hooks/useMicroLearningCompletions'; +import ThemeSessionView from '../components/theme_session/ThemeSessionView'; const Leren = () => { const { state } = useApp(); const navigate = useNavigate(); - const [assignedTopic, setAssignedTopic] = useState(null); const [allTopics, setAllTopics] = useState([]); - - // View state - const [view, setView] = useState('overview'); // overview, detail + + // View state — overview | session | topic + const [view, setView] = useState('overview'); const [activeTopic, setActiveTopic] = useState(null); - - // Weekly status - const [weeklyDone, setWeeklyDone] = useState(false); - const [sessionDone, setSessionDone] = useState(false); + + // Per-topic micro-learning post-completion state + const [topicDone, setTopicDone] = useState(false); // Curriculum state - const [hasCurriculum, setHasCurriculum] = useState(false); + const [activeVersion, setActiveVersion] = useState(null); const [yearProgress, setYearProgress] = useState(null); const [weekContent, setWeekContent] = useState(null); - const { getSessionCompletions } = useMicroLearningCompletions(); + // Theme session completion flag for the current week + const [themeSessionDone, setThemeSessionDone] = useState(false); useEffect(() => { if (state.currentUser) { const load = async () => { - const [assigned, topics] = await Promise.all([ - getAssignedTopic(state.currentUser.id, state.weekNumber), - db.getTopics(), - ]); - setAssignedTopic(assigned); + const topics = await db.getTopics(); setAllTopics(topics); - // Load curriculum data - let currentWeekContent = null; try { - const activeVersion = await getActiveVersion(); - setHasCurriculum(!!activeVersion); - if (activeVersion) { - const [yProgress, wc] = await Promise.all([ + const av = await getActiveVersion(); + setActiveVersion(av || null); + if (av) { + const [yProgress, wc, completion] = await Promise.all([ getYearProgress(state.currentUser.id, state.weekNumber), getCurrentWeekContent(state.weekNumber), + db.getThemeSessionCompletion(state.currentUser.id, state.weekNumber), ]); setYearProgress(yProgress); - currentWeekContent = wc; setWeekContent(wc); + setThemeSessionDone(!!completion); } } catch (e) { console.warn('[Learn] Could not load curriculum data:', e.message); } - - // Check completions for the current week - const completions = await getSessionCompletions(state.weekNumber); - - // Define required topics for the week - // We assume assignedTopic is the minimum required if no explicit list in weekContent. - // Actually, let's just check if the assignedTopic is completed. - const requiredTopicIds = currentWeekContent?.topics || [assigned?.id].filter(Boolean); - - const completedTopicIds = new Set(completions.map(c => c.topic_id)); - const allRequiredCompleted = requiredTopicIds.length > 0 && requiredTopicIds.every(id => completedTopicIds.has(id)); - - setWeeklyDone(allRequiredCompleted); }; load(); } - }, [state.currentUser, state.weekNumber, view]); // Reload when view changes (e.g. going back to overview) + }, [state.currentUser, state.weekNumber, view]); + + const handleOpenSession = () => { + setView('session'); + }; const handleOpenTopic = (topic) => { setActiveTopic(topic); - setView('detail'); - setSessionDone(false); + setView('topic'); + setTopicDone(false); }; - const handleTopicCompleted = () => { - setSessionDone(true); - // Pre-generate this week's test questions in the background so they are - // ready in localStorage by the time the user navigates to /test. The call - // is fire-and-forget — a failure here is harmless; the test page will just - // generate on demand as before. - if (state.currentUser) { + const handlePracticeTopicId = (topicId) => { + const t = allTopics.find(x => x.id === topicId); + if (t) handleOpenTopic(t); + }; + + const handleThemeSessionCompleted = async (record) => { + if (!state.currentUser || !record) return; + try { + await db.setThemeSessionCompletion(state.currentUser.id, record.id, state.weekNumber); + setThemeSessionDone(true); generateWeeklyQuiz(state.currentUser.id, state.weekNumber).catch(() => {}); + } catch (e) { + console.warn('[Learn] Failed to record theme session completion:', e.message); } }; - // ── Detail View ────────────────────────────────────────── - if (view === 'detail' && activeTopic) { - if (sessionDone) { + const handleTopicCompleted = () => { + setTopicDone(true); + }; + + // ── Theme session view ──────────────────────────────────── + if (view === 'session' && activeVersion && weekContent) { + return ( +
    + + +
    + + {themeSessionDone && ( + + )} +
    +
    + ); + } + + // ── Per-topic detail view ──────────────────────────────── + if (view === 'topic' && activeTopic) { + if (topicDone) { return (
    @@ -112,10 +133,10 @@ const Leren = () => { Ready to put it to the test, or explore another format for this topic?

    - -
    ); } // ── Overview ────────────────────────────────────────────── - const otherTopics = allTopics.filter(t => t.id !== assignedTopic?.id && t.type !== 'fact' && t.learning_relevance !== 'exclude'); + const hasCurriculum = !!activeVersion; const currentCycle = getCurriculumCycle(state.weekNumber); const currWeek = getCurriculumWeek(state.weekNumber); + const weeklyTopicIds = new Set((weekContent?.topics || []).map(t => t.id)); + + // Library = every learnable topic, grouped by theme. + // Topics already part of this week's theme session are shown inline under + // that theme but flagged so the learner sees they're already covered. + const learnableTopics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); + const libraryByTheme = new Map(); + for (const t of learnableTopics) { + const themeName = t.theme || 'General'; + if (!libraryByTheme.has(themeName)) libraryByTheme.set(themeName, []); + libraryByTheme.get(themeName).push(t); + } + // Sort: current week's theme first, then alphabetical. + const currentTheme = weekContent?.theme || null; + const sortedThemes = Array.from(libraryByTheme.keys()).sort((a, b) => { + if (a === currentTheme) return -1; + if (b === currentTheme) return 1; + return a.localeCompare(b); + }); + return (
    @@ -162,14 +204,13 @@ const Leren = () => {

    {hasCurriculum ? `Cycle ${currentCycle} · Week ${currWeek} of 26` - : 'Complete at least 1 topic per week. Explore more from the library!'} + : 'Complete at least one theme session per week. Explore more from the library!'}

    - {/* Progress Cards (only shown when curriculum exists) */} + {/* Progress Cards */} {hasCurriculum && yearProgress && (
    - {/* Cycle Progress */}
    @@ -190,14 +231,12 @@ const Leren = () => {
    {yearProgress.completed}/{yearProgress.total} weeks
    - {/* Current Week */}
    {currWeek}
    Current Week
    - {/* Theme */}
    {weekContent?.theme || 'General'}
    @@ -206,58 +245,89 @@ const Leren = () => {
    )} - {/* Required Topic */} - {assignedTopic && ( -
    + {/* Weekly Theme Session */} + {hasCurriculum && weekContent && ( +

    - This Week's Topic {weeklyDone && } + This Week's Theme Session + {themeSessionDone && }

    - handleOpenTopic(assignedTopic)} +
    -
    - - {weeklyDone ? 'Completed' : 'Required'} +
    + + {themeSessionDone ? 'Completed' : 'Required'} - {hasCurriculum && ( - Theme: {weekContent?.theme || 'General'} + Theme: {weekContent.theme} + {weekContent.topics.length} topic{weekContent.topics.length === 1 ? '' : 's'} + {weekContent.estimatedDuration && ( + ~{weekContent.estimatedDuration} min )}
    -

    {assignedTopic.label}

    -

    {assignedTopic.description}

    +

    + {weekContent.theme} +

    +

    + A detailed weekly summary covering every topic in this theme: {weekContent.topics.map(t => t.label).join(', ')}. +

    )} - {/* Other Available Topics */} - {otherTopics.length > 0 && ( + {/* Theme-grouped library */} + {sortedThemes.length > 0 && (
    -

    Knowledge Base Library

    -
    - {otherTopics.map(topic => ( - handleOpenTopic(topic)} - > - {topic.type} -

    {topic.label}

    -

    {topic.description}

    -
    - Learn -
    -
    - ))} +

    Knowledge Library

    +

    + Every topic in the knowledge base, grouped by theme. Topics in this week's theme also appear inside the theme session above. +

    +
    + {sortedThemes.map((themeName) => { + const topics = libraryByTheme.get(themeName) || []; + const isCurrent = themeName === currentTheme; + return ( +
    +
    +

    {themeName}

    + {isCurrent && This week} + · {topics.length} topic{topics.length === 1 ? '' : 's'} +
    +
    + {topics.map(topic => { + const inWeek = weeklyTopicIds.has(topic.id); + return ( + handleOpenTopic(topic)} + > +
    + {topic.type} + {inWeek && In this week's session} +
    +

    {topic.label}

    +

    {topic.description}

    +
    + Practice +
    +
    + ); + })} +
    +
    + ); + })}
    )}