feat: theme-level weekly sessions and theme-grouped knowledge library
- New theme_sessions + theme_session_completions PocketBase collections - Generate a detailed per-week theme summary covering every topic in the week's curriculum slot via EMIT_THEME_SESSION_TOOL; cache per (curriculum_version, week_number) - Replace Leren.jsx "This Week's Topic" card with "This Week's Theme Session" that opens the new ThemeSessionView; per-topic micro-learnings remain reachable as optional practice from inside the session - Group the Knowledge Library by topic.theme, pin the current week's theme on top, and badge topics already covered by the active session - Mark week complete when the user finishes reading the theme session Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
203
pb_migrations/1781000000_created_theme_sessions.js
Normal file
203
pb_migrations/1781000000_created_theme_sessions.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
145
src/components/theme_session/ThemeSessionView.jsx
Normal file
145
src/components/theme_session/ThemeSessionView.jsx
Normal file
@@ -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 (
|
||||||
|
<Card className="w-full text-center py-16">
|
||||||
|
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||||
|
<p className="font-medium text-lg">Preparing this week's theme session…</p>
|
||||||
|
<p className="text-fg-muted text-sm mt-2">This may take 15–30 seconds the first time.</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full border border-red-200 bg-red-50 text-red-900 p-6">
|
||||||
|
<p className="font-bold mb-1">Could not load the theme session</p>
|
||||||
|
<p className="text-sm">{error}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = normalizeContent(record?.content);
|
||||||
|
if (!content) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<p className="text-fg-muted italic">No theme session content available yet.</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinish = () => {
|
||||||
|
if (marked) return;
|
||||||
|
setMarked(true);
|
||||||
|
if (typeof onCompleted === 'function') {
|
||||||
|
onCompleted(record);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sparkles size={18} className="text-teal" />
|
||||||
|
<Tag variant="dark" className="text-[10px] uppercase tracking-wide">Theme Session</Tag>
|
||||||
|
<Tag variant="accent" className="text-[10px]">{weekContent.theme}</Tag>
|
||||||
|
{marked && <Tag variant="success" className="text-[10px]">Completed</Tag>}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-teal mb-2">{content.title}</h2>
|
||||||
|
<p className="text-fg-muted">{content.intro}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{content.topicSections.map((section, idx) => (
|
||||||
|
<Card key={section.topic_id || idx} className="w-full p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<BookOpen size={18} className="text-teal" />
|
||||||
|
<Tag variant="dark" className="text-[10px]">Topic {idx + 1} of {content.topicSections.length}</Tag>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-bold mb-3">{section.label}</h3>
|
||||||
|
<div
|
||||||
|
className="prose dark:prose-invert max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: section.summary }}
|
||||||
|
/>
|
||||||
|
{typeof onPracticeTopic === 'function' && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-bg-warm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPracticeTopic(section.topic_id)}
|
||||||
|
className="inline-flex items-center text-teal font-medium text-sm hover:underline"
|
||||||
|
>
|
||||||
|
Practice this topic <ArrowRight size={14} className="ml-1" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3">How these connect</h3>
|
||||||
|
<div
|
||||||
|
className="prose dark:prose-invert max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content.connections }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="w-full p-6">
|
||||||
|
<h3 className="text-lg font-bold mb-3">Key takeaways</h3>
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||||
|
{content.keyTakeaways.map((t, i) => (
|
||||||
|
<li key={i}>{t}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleFinish} disabled={marked}>
|
||||||
|
{marked ? (
|
||||||
|
<span className="flex items-center"><CheckCircle2 size={16} className="mr-2" /> Session completed</span>
|
||||||
|
) : (
|
||||||
|
'Mark session as completed'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,6 +242,61 @@ export async function getLearnDone() { return false; }
|
|||||||
/** @deprecated learn_progress collection has been dropped. */
|
/** @deprecated learn_progress collection has been dropped. */
|
||||||
export async function setLearnDone() { return null; }
|
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 ───────────────────────────────────────────────────────────────
|
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getLeaderboard() {
|
export async function getLeaderboard() {
|
||||||
@@ -484,6 +539,8 @@ export async function resetForSmokeTest({
|
|||||||
'topics',
|
'topics',
|
||||||
'micro_learnings',
|
'micro_learnings',
|
||||||
'micro_learning_completions',
|
'micro_learning_completions',
|
||||||
|
'theme_session_completions',
|
||||||
|
'theme_sessions',
|
||||||
'test_results',
|
'test_results',
|
||||||
];
|
];
|
||||||
if (includeProgress) collections.push('leaderboard');
|
if (includeProgress) collections.push('leaderboard');
|
||||||
|
|||||||
@@ -213,6 +213,22 @@ export const replaceTakeawaysPatchSchema = z.object({
|
|||||||
items: z.array(z.string().min(1)).min(1),
|
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`
|
* Registry mapping known tool names to their input schemas. `callLLM`
|
||||||
* consults this when the caller does not pass an explicit `toolSchemas`
|
* consults this when the caller does not pass an explicit `toolSchemas`
|
||||||
@@ -235,4 +251,5 @@ export const toolSchemaRegistry = {
|
|||||||
add_section: addSectionPatchSchema,
|
add_section: addSectionPatchSchema,
|
||||||
remove_section: removeSectionPatchSchema,
|
remove_section: removeSectionPatchSchema,
|
||||||
replace_takeaways: replaceTakeawaysPatchSchema,
|
replace_takeaways: replaceTakeawaysPatchSchema,
|
||||||
|
emit_theme_session: themeSessionSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 <p>, <ul>, <li>, <strong>. 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'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
61
src/lib/themeSessionService.js
Normal file
61
src/lib/themeSessionService.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,104 +1,125 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { motion } from 'framer-motion';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Tag from '../components/ui/Tag';
|
import Tag from '../components/ui/Tag';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import { getAssignedTopic } from '../lib/learningService';
|
|
||||||
import { generateWeeklyQuiz } from '../lib/testService';
|
import { generateWeeklyQuiz } from '../lib/testService';
|
||||||
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
|
import { getYearProgress, getCurriculumCycle, getCurriculumWeek, getActiveVersion, getCurrentWeekContent } from '../lib/curriculumService';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
|
import MicroLearningSelector from '../components/micro_learning/MicroLearningSelector';
|
||||||
import { useMicroLearningCompletions } from '../hooks/useMicroLearningCompletions';
|
import ThemeSessionView from '../components/theme_session/ThemeSessionView';
|
||||||
|
|
||||||
const Leren = () => {
|
const Leren = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [assignedTopic, setAssignedTopic] = useState(null);
|
|
||||||
const [allTopics, setAllTopics] = useState([]);
|
const [allTopics, setAllTopics] = useState([]);
|
||||||
|
|
||||||
// View state
|
// View state — overview | session | topic
|
||||||
const [view, setView] = useState('overview'); // overview, detail
|
const [view, setView] = useState('overview');
|
||||||
const [activeTopic, setActiveTopic] = useState(null);
|
const [activeTopic, setActiveTopic] = useState(null);
|
||||||
|
|
||||||
// Weekly status
|
// Per-topic micro-learning post-completion state
|
||||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
const [topicDone, setTopicDone] = useState(false);
|
||||||
const [sessionDone, setSessionDone] = useState(false);
|
|
||||||
|
|
||||||
// Curriculum state
|
// Curriculum state
|
||||||
const [hasCurriculum, setHasCurriculum] = useState(false);
|
const [activeVersion, setActiveVersion] = useState(null);
|
||||||
const [yearProgress, setYearProgress] = useState(null);
|
const [yearProgress, setYearProgress] = useState(null);
|
||||||
const [weekContent, setWeekContent] = useState(null);
|
const [weekContent, setWeekContent] = useState(null);
|
||||||
|
|
||||||
const { getSessionCompletions } = useMicroLearningCompletions();
|
// Theme session completion flag for the current week
|
||||||
|
const [themeSessionDone, setThemeSessionDone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.currentUser) {
|
if (state.currentUser) {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [assigned, topics] = await Promise.all([
|
const topics = await db.getTopics();
|
||||||
getAssignedTopic(state.currentUser.id, state.weekNumber),
|
|
||||||
db.getTopics(),
|
|
||||||
]);
|
|
||||||
setAssignedTopic(assigned);
|
|
||||||
setAllTopics(topics);
|
setAllTopics(topics);
|
||||||
|
|
||||||
// Load curriculum data
|
|
||||||
let currentWeekContent = null;
|
|
||||||
try {
|
try {
|
||||||
const activeVersion = await getActiveVersion();
|
const av = await getActiveVersion();
|
||||||
setHasCurriculum(!!activeVersion);
|
setActiveVersion(av || null);
|
||||||
if (activeVersion) {
|
if (av) {
|
||||||
const [yProgress, wc] = await Promise.all([
|
const [yProgress, wc, completion] = await Promise.all([
|
||||||
getYearProgress(state.currentUser.id, state.weekNumber),
|
getYearProgress(state.currentUser.id, state.weekNumber),
|
||||||
getCurrentWeekContent(state.weekNumber),
|
getCurrentWeekContent(state.weekNumber),
|
||||||
|
db.getThemeSessionCompletion(state.currentUser.id, state.weekNumber),
|
||||||
]);
|
]);
|
||||||
setYearProgress(yProgress);
|
setYearProgress(yProgress);
|
||||||
currentWeekContent = wc;
|
|
||||||
setWeekContent(wc);
|
setWeekContent(wc);
|
||||||
|
setThemeSessionDone(!!completion);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Learn] Could not load curriculum data:', e.message);
|
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();
|
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) => {
|
const handleOpenTopic = (topic) => {
|
||||||
setActiveTopic(topic);
|
setActiveTopic(topic);
|
||||||
setView('detail');
|
setView('topic');
|
||||||
setSessionDone(false);
|
setTopicDone(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTopicCompleted = () => {
|
const handlePracticeTopicId = (topicId) => {
|
||||||
setSessionDone(true);
|
const t = allTopics.find(x => x.id === topicId);
|
||||||
// Pre-generate this week's test questions in the background so they are
|
if (t) handleOpenTopic(t);
|
||||||
// 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.
|
const handleThemeSessionCompleted = async (record) => {
|
||||||
if (state.currentUser) {
|
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(() => {});
|
generateWeeklyQuiz(state.currentUser.id, state.weekNumber).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Learn] Failed to record theme session completion:', e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Detail View ──────────────────────────────────────────
|
const handleTopicCompleted = () => {
|
||||||
if (view === 'detail' && activeTopic) {
|
setTopicDone(true);
|
||||||
if (sessionDone) {
|
};
|
||||||
|
|
||||||
|
// ── Theme session view ────────────────────────────────────
|
||||||
|
if (view === 'session' && activeVersion && weekContent) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||||
|
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
|
||||||
|
<ChevronLeft size={16} /> Back to overview
|
||||||
|
</button>
|
||||||
|
<ThemeSessionView
|
||||||
|
curriculumVersionId={activeVersion.id}
|
||||||
|
weekContent={weekContent}
|
||||||
|
alreadyCompleted={themeSessionDone}
|
||||||
|
onCompleted={handleThemeSessionCompleted}
|
||||||
|
onPracticeTopic={handlePracticeTopicId}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setView('overview')}>
|
||||||
|
<ChevronLeft size={16} className="mr-2" /> Back to Station
|
||||||
|
</Button>
|
||||||
|
{themeSessionDone && (
|
||||||
|
<Button onClick={() => navigate('/test')}>
|
||||||
|
<CheckSquare size={16} className="mr-2" /> Go to test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-topic detail view ────────────────────────────────
|
||||||
|
if (view === 'topic' && activeTopic) {
|
||||||
|
if (topicDone) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
<div className="p-4 md:p-8 max-w-2xl mx-auto text-center py-20">
|
||||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||||
@@ -112,10 +133,10 @@ const Leren = () => {
|
|||||||
Ready to put it to the test, or explore another format for this topic?
|
Ready to put it to the test, or explore another format for this topic?
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center gap-3">
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
<Button variant="outline" onClick={() => { setView('overview'); setSessionDone(false); }}>
|
<Button variant="outline" onClick={() => { setView('overview'); setTopicDone(false); }}>
|
||||||
<ChevronLeft size={16} className="mr-2" /> Back to Station
|
<ChevronLeft size={16} className="mr-2" /> Back to Station
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setSessionDone(false)}>
|
<Button variant="outline" onClick={() => setTopicDone(false)}>
|
||||||
Try another format
|
Try another format
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate('/test')}>
|
<Button onClick={() => navigate('/test')}>
|
||||||
@@ -126,6 +147,7 @@ const Leren = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWeekly = (weekContent?.topics || []).some(t => t.id === activeTopic.id);
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||||
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
|
<button onClick={() => setView('overview')} className="flex items-center gap-2 text-fg-muted hover:text-teal mb-6 transition-colors">
|
||||||
@@ -135,7 +157,7 @@ const Leren = () => {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Tag variant="dark" className="font-mono text-xs">{activeTopic.type}</Tag>
|
<Tag variant="dark" className="font-mono text-xs">{activeTopic.type}</Tag>
|
||||||
{activeTopic.id === assignedTopic?.id && <Tag variant="accent">Weekly Required</Tag>}
|
{isWeekly && <Tag variant="accent">In this week's theme</Tag>}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-teal">{activeTopic.label}</h1>
|
<h1 className="text-3xl md:text-4xl font-bold text-teal">{activeTopic.label}</h1>
|
||||||
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
|
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
|
||||||
@@ -151,10 +173,30 @@ const Leren = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Overview ──────────────────────────────────────────────
|
// ── 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 currentCycle = getCurriculumCycle(state.weekNumber);
|
||||||
const currWeek = getCurriculumWeek(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 (
|
return (
|
||||||
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
<div className="p-4 md:p-8 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in duration-300">
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
@@ -162,14 +204,13 @@ const Leren = () => {
|
|||||||
<p className="text-fg-muted text-lg">
|
<p className="text-fg-muted text-lg">
|
||||||
{hasCurriculum
|
{hasCurriculum
|
||||||
? `Cycle ${currentCycle} · Week ${currWeek} of 26`
|
? `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!'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Cards (only shown when curriculum exists) */}
|
{/* Progress Cards */}
|
||||||
{hasCurriculum && yearProgress && (
|
{hasCurriculum && yearProgress && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
{/* Cycle Progress */}
|
|
||||||
<Card className="border border-bg-warm text-center p-4">
|
<Card className="border border-bg-warm text-center p-4">
|
||||||
<div className="relative w-16 h-16 mx-auto mb-2">
|
<div className="relative w-16 h-16 mx-auto mb-2">
|
||||||
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
<svg viewBox="0 0 36 36" className="w-full h-full -rotate-90">
|
||||||
@@ -190,14 +231,12 @@ const Leren = () => {
|
|||||||
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
|
<div className="text-[10px] text-fg-muted">{yearProgress.completed}/{yearProgress.total} weeks</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Current Week */}
|
|
||||||
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center">
|
||||||
<Calendar size={24} className="text-teal mb-1" />
|
<Calendar size={24} className="text-teal mb-1" />
|
||||||
<div className="text-2xl font-bold text-teal">{currWeek}</div>
|
<div className="text-2xl font-bold text-teal">{currWeek}</div>
|
||||||
<div className="text-xs text-fg-muted">Current Week</div>
|
<div className="text-xs text-fg-muted">Current Week</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Theme */}
|
|
||||||
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center col-span-2">
|
<Card className="border border-bg-warm text-center p-4 flex flex-col items-center justify-center col-span-2">
|
||||||
<BookOpen size={24} className="text-purple-600 mb-1" />
|
<BookOpen size={24} className="text-purple-600 mb-1" />
|
||||||
<div className="text-lg font-bold text-purple-700">{weekContent?.theme || 'General'}</div>
|
<div className="text-lg font-bold text-purple-700">{weekContent?.theme || 'General'}</div>
|
||||||
@@ -206,58 +245,89 @@ const Leren = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Required Topic */}
|
{/* Weekly Theme Session */}
|
||||||
{assignedTopic && (
|
{hasCurriculum && weekContent && (
|
||||||
<div className="mb-8">
|
<div className="mb-10">
|
||||||
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
|
||||||
This Week's Topic {weeklyDone && <CheckCircle size={20} className="text-teal" />}
|
This Week's Theme Session
|
||||||
|
{themeSessionDone && <CheckCircle size={20} className="text-teal" />}
|
||||||
</h2>
|
</h2>
|
||||||
<Card
|
<Card
|
||||||
hoverable
|
hoverable
|
||||||
className={`border-2 cursor-pointer transition-all ${weeklyDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
|
className={`border-2 cursor-pointer transition-all ${themeSessionDone ? 'border-teal/30 bg-teal/5' : 'border-teal shadow-md'}`}
|
||||||
onClick={() => handleOpenTopic(assignedTopic)}
|
onClick={handleOpenSession}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<Tag variant={weeklyDone ? 'success' : 'accent'} className="text-xs">
|
<Tag variant={themeSessionDone ? 'success' : 'accent'} className="text-xs">
|
||||||
{weeklyDone ? 'Completed' : 'Required'}
|
{themeSessionDone ? 'Completed' : 'Required'}
|
||||||
</Tag>
|
</Tag>
|
||||||
{hasCurriculum && (
|
<Tag variant="dark" className="text-[10px]">Theme: {weekContent.theme}</Tag>
|
||||||
<Tag variant="dark" className="text-[10px]">Theme: {weekContent?.theme || 'General'}</Tag>
|
<Tag variant="dark" className="text-[10px]">{weekContent.topics.length} topic{weekContent.topics.length === 1 ? '' : 's'}</Tag>
|
||||||
|
{weekContent.estimatedDuration && (
|
||||||
|
<Tag variant="dark" className="text-[10px]">~{weekContent.estimatedDuration} min</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-teal">{assignedTopic.label}</h3>
|
<h3 className="text-2xl font-bold text-teal flex items-center gap-2">
|
||||||
<p className="text-fg-muted mt-1">{assignedTopic.description}</p>
|
<Sparkles size={20} /> {weekContent.theme}
|
||||||
|
</h3>
|
||||||
|
<p className="text-fg-muted mt-1">
|
||||||
|
A detailed weekly summary covering every topic in this theme: {weekContent.topics.map(t => t.label).join(', ')}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="whitespace-nowrap flex-shrink-0">
|
<Button className="whitespace-nowrap flex-shrink-0">
|
||||||
{weeklyDone ? 'Review' : 'Start Learning'} <ArrowRight size={18} className="ml-2" />
|
{themeSessionDone ? 'Review session' : 'Start session'} <ArrowRight size={18} className="ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Other Available Topics */}
|
{/* Theme-grouped library */}
|
||||||
{otherTopics.length > 0 && (
|
{sortedThemes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold mb-4">Knowledge Base Library</h2>
|
<h2 className="text-xl font-bold mb-2">Knowledge Library</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<p className="text-fg-muted text-sm mb-6">
|
||||||
{otherTopics.map(topic => (
|
Every topic in the knowledge base, grouped by theme. Topics in this week's theme also appear inside the theme session above.
|
||||||
<Card
|
</p>
|
||||||
key={topic.id}
|
<div className="space-y-8">
|
||||||
hoverable
|
{sortedThemes.map((themeName) => {
|
||||||
className="border border-bg-warm cursor-pointer flex flex-col h-full"
|
const topics = libraryByTheme.get(themeName) || [];
|
||||||
onClick={() => handleOpenTopic(topic)}
|
const isCurrent = themeName === currentTheme;
|
||||||
>
|
return (
|
||||||
<Tag variant="dark" className="self-start text-[10px] mb-2">{topic.type}</Tag>
|
<section key={themeName}>
|
||||||
<h3 className="font-bold text-lg mb-1">{topic.label}</h3>
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<p className="text-sm text-fg-muted line-clamp-2 mb-4 flex-1">{topic.description}</p>
|
<h3 className={`text-lg font-bold ${isCurrent ? 'text-teal' : ''}`}>{themeName}</h3>
|
||||||
<div className="flex items-center text-teal font-medium text-sm mt-auto group">
|
{isCurrent && <Tag variant="accent" className="text-[10px]">This week</Tag>}
|
||||||
Learn <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
|
<span className="text-xs text-fg-muted">· {topics.length} topic{topics.length === 1 ? '' : 's'}</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
))}
|
{topics.map(topic => {
|
||||||
|
const inWeek = weeklyTopicIds.has(topic.id);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={topic.id}
|
||||||
|
hoverable
|
||||||
|
className={`border cursor-pointer flex flex-col h-full ${inWeek ? 'border-teal/40 bg-teal/[0.03]' : 'border-bg-warm'}`}
|
||||||
|
onClick={() => handleOpenTopic(topic)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<Tag variant="dark" className="text-[10px]">{topic.type}</Tag>
|
||||||
|
{inWeek && <Tag variant="accent" className="text-[10px]">In this week's session</Tag>}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-lg mb-1">{topic.label}</h4>
|
||||||
|
<p className="text-sm text-fg-muted line-clamp-2 mb-4 flex-1">{topic.description}</p>
|
||||||
|
<div className="flex items-center text-teal font-medium text-sm mt-auto group">
|
||||||
|
Practice <ArrowRight size={14} className="ml-1 transition-transform group-hover:translate-x-1" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user