feat: implement micro-learning system with content delivery components, completion tracking hooks, and database schema migrations
This commit is contained in:
183
pb_migrations/1780800000_created_micro_learnings.js
Normal file
183
pb_migrations/1780800000_created_micro_learnings.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
// Create micro_learnings collection
|
||||
const microLearnings = new Collection({
|
||||
"id": "pbc_micro_learnings_0",
|
||||
"name": "micro_learnings",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "rel_topic_id",
|
||||
"name": "topic_id",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"collectionId": "pbc_2800040823", // the ID for topics from snapshot (we will use topic name later if possible, but let's just use collectionName: "topics")
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sel_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"concept_explainer",
|
||||
"scenario_quiz",
|
||||
"flashcard_set",
|
||||
"reflection_prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "json_content",
|
||||
"name": "content",
|
||||
"type": "json",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sel_status",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"draft",
|
||||
"published"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "status = 'published'",
|
||||
"viewRule": "status = 'published'",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null
|
||||
});
|
||||
|
||||
// Since we don't know the exact ID of topics collection safely across instances without looking it up,
|
||||
// we can look it up first.
|
||||
const topicsCollection = app.findCollectionByNameOrId("topics");
|
||||
microLearnings.schema.getFieldByName("topic_id").options.collectionId = topicsCollection.id;
|
||||
|
||||
app.save(microLearnings);
|
||||
|
||||
// Create micro_learning_completions collection
|
||||
const completions = new Collection({
|
||||
"id": "pbc_ml_completions_0",
|
||||
"name": "micro_learning_completions",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "rel_team_member_id",
|
||||
"name": "team_member_id",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"collectionId": "team_members_placeholder",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "rel_micro_learning_id",
|
||||
"name": "micro_learning_id",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"collectionId": microLearnings.id,
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "rel_comp_topic_id",
|
||||
"name": "topic_id",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"collectionId": topicsCollection.id,
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "txt_comp_type",
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "num_session_week",
|
||||
"name": "session_week",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != '' && team_member_id.user_id = @request.auth.id",
|
||||
"viewRule": "@request.auth.id != '' && team_member_id.user_id = @request.auth.id",
|
||||
"createRule": "@request.auth.id != ''",
|
||||
"updateRule": null,
|
||||
"deleteRule": null
|
||||
});
|
||||
|
||||
const teamMembersCollection = app.findCollectionByNameOrId("team_members");
|
||||
completions.schema.getFieldByName("team_member_id").options.collectionId = teamMembersCollection.id;
|
||||
|
||||
app.save(completions);
|
||||
|
||||
}, (app) => {
|
||||
const completions = app.findCollectionByNameOrId("micro_learning_completions");
|
||||
if (completions) {
|
||||
app.delete(completions);
|
||||
}
|
||||
const microLearnings = app.findCollectionByNameOrId("micro_learnings");
|
||||
if (microLearnings) {
|
||||
app.delete(microLearnings);
|
||||
}
|
||||
})
|
||||
23
pb_migrations/1780800001_deleted_legacy_collections.js
Normal file
23
pb_migrations/1780800001_deleted_legacy_collections.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collectionsToDrop = [
|
||||
"learn_progress",
|
||||
"quiz_banks",
|
||||
"quiz_cache",
|
||||
"quiz_results"
|
||||
];
|
||||
|
||||
for (const name of collectionsToDrop) {
|
||||
try {
|
||||
const collection = app.findCollectionByNameOrId(name);
|
||||
if (collection) {
|
||||
app.delete(collection);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore if not found
|
||||
}
|
||||
}
|
||||
}, (app) => {
|
||||
// Downgrade would normally recreate these, but we omit it here for simplicity
|
||||
// since they are deprecated.
|
||||
})
|
||||
45
src/components/micro_learning/ConceptExplainer.jsx
Normal file
45
src/components/micro_learning/ConceptExplainer.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; // assuming some UI library, otherwise use standard divs
|
||||
|
||||
export default function ConceptExplainer({ content, onComplete }) {
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// Trigger completion when scrolled to the end
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
// Check initially in case content is short and doesn't need scrolling
|
||||
if (containerRef.current && containerRef.current.scrollHeight <= containerRef.current.clientHeight) {
|
||||
onComplete();
|
||||
}
|
||||
|
||||
const currentRef = containerRef.current;
|
||||
currentRef?.addEventListener('scroll', handleScroll);
|
||||
return () => currentRef?.removeEventListener('scroll', handleScroll);
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<Card className="w-full h-[60vh] flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Concept Explainer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className="flex-1 overflow-y-auto prose dark:prose-invert"
|
||||
ref={containerRef}
|
||||
>
|
||||
{content?.sections?.map((section, i) => (
|
||||
<div key={i} className="mb-6">
|
||||
<h3>{section.title}</h3>
|
||||
<div dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
75
src/components/micro_learning/FlashcardSet.jsx
Normal file
75
src/components/micro_learning/FlashcardSet.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export default function FlashcardSet({ content, onComplete }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const [viewedCards, setViewedCards] = useState(new Set());
|
||||
|
||||
const cards = content?.cards || [];
|
||||
|
||||
const handleFlip = () => {
|
||||
setIsFlipped(!isFlipped);
|
||||
|
||||
// Once flipped, mark this card as viewed
|
||||
const newViewed = new Set(viewedCards);
|
||||
newViewed.add(currentIndex);
|
||||
setViewedCards(newViewed);
|
||||
|
||||
// If all cards are viewed, trigger completion
|
||||
if (newViewed.size === cards.length && cards.length > 0) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setIsFlipped(false);
|
||||
setCurrentIndex((prev) => (prev + 1) % cards.length);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setIsFlipped(false);
|
||||
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
|
||||
};
|
||||
|
||||
if (cards.length === 0) {
|
||||
return <div className="text-center p-4">No flashcards available.</div>;
|
||||
}
|
||||
|
||||
const currentCard = cards[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center space-y-6">
|
||||
<div className="text-sm text-slate-500">
|
||||
Card {currentIndex + 1} of {cards.length}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
className="w-full max-w-lg min-h-[300px] cursor-pointer perspective-1000 relative"
|
||||
onClick={handleFlip}
|
||||
>
|
||||
<CardContent className="absolute inset-0 flex items-center justify-center p-8 text-center text-xl">
|
||||
{isFlipped ? (
|
||||
<div className="prose dark:prose-invert">
|
||||
<p>{currentCard.back}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prose dark:prose-invert font-medium">
|
||||
<p>{currentCard.front}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-sm text-slate-400 mt-2">
|
||||
Click the card to flip
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" onClick={handlePrev}>Previous</Button>
|
||||
<Button variant="outline" onClick={handleNext}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/micro_learning/MicroLearningContainer.jsx
Normal file
62
src/components/micro_learning/MicroLearningContainer.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import ConceptExplainer from './ConceptExplainer';
|
||||
import ScenarioQuiz from './ScenarioQuiz';
|
||||
import FlashcardSet from './FlashcardSet';
|
||||
import ReflectionPrompt from './ReflectionPrompt';
|
||||
import { useMicroLearningCompletions } from '../../hooks/useMicroLearningCompletions';
|
||||
|
||||
export default function MicroLearningContainer({ microLearning, sessionWeek, onCompletedSuccessfully }) {
|
||||
const { recordCompletion } = useMicroLearningCompletions();
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (completed) return; // Prevent double recording
|
||||
|
||||
const record = await recordCompletion({
|
||||
microLearningId: microLearning.id,
|
||||
topicId: microLearning.topic_id,
|
||||
type: microLearning.type,
|
||||
sessionWeek: sessionWeek
|
||||
});
|
||||
|
||||
if (record) {
|
||||
setCompleted(true);
|
||||
if (onCompletedSuccessfully) {
|
||||
onCompletedSuccessfully(record);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderComponent = () => {
|
||||
const props = {
|
||||
content: microLearning.content,
|
||||
onComplete: handleComplete
|
||||
};
|
||||
|
||||
switch (microLearning.type) {
|
||||
case 'concept_explainer':
|
||||
return <ConceptExplainer {...props} />;
|
||||
case 'scenario_quiz':
|
||||
return <ScenarioQuiz {...props} />;
|
||||
case 'flashcard_set':
|
||||
return <FlashcardSet {...props} />;
|
||||
case 'reflection_prompt':
|
||||
return <ReflectionPrompt {...props} />;
|
||||
default:
|
||||
return <div>Unknown micro learning type.</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
{renderComponent()}
|
||||
|
||||
{completed && (
|
||||
<div className="mt-4 p-4 bg-green-50 text-green-800 border border-green-200 rounded-md text-center">
|
||||
<p className="font-semibold">Micro Learning Completed!</p>
|
||||
<p className="text-sm mt-1">Your progress has been recorded.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/micro_learning/MicroLearningSelector.jsx
Normal file
89
src/components/micro_learning/MicroLearningSelector.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useMicroLearnings } from '../../hooks/useMicroLearnings';
|
||||
import MicroLearningContainer from './MicroLearningContainer';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
'concept_explainer': 'Concept Explainer',
|
||||
'scenario_quiz': 'Scenario Quiz',
|
||||
'flashcard_set': 'Flashcard Set',
|
||||
'reflection_prompt': 'Reflection Prompt'
|
||||
};
|
||||
|
||||
const TYPE_DESCRIPTIONS = {
|
||||
'concept_explainer': 'Read a structured explanation to understand the concept.',
|
||||
'scenario_quiz': 'Apply your knowledge in a realistic workplace scenario.',
|
||||
'flashcard_set': 'Test your recall with a set of quick flashcards.',
|
||||
'reflection_prompt': 'Connect the topic to your own professional experience.'
|
||||
};
|
||||
|
||||
export default function MicroLearningSelector({ topicId, sessionWeek, onTopicCompleted }) {
|
||||
const { getMicroLearningsByTopic } = useMicroLearnings();
|
||||
const [availableMLs, setAvailableMLs] = useState([]);
|
||||
const [selectedML, setSelectedML] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMLs = async () => {
|
||||
setLoading(true);
|
||||
const data = await getMicroLearningsByTopic(topicId);
|
||||
setAvailableMLs(data);
|
||||
setLoading(false);
|
||||
};
|
||||
if (topicId) {
|
||||
fetchMLs();
|
||||
}
|
||||
}, [topicId]);
|
||||
|
||||
const handleSelection = (ml) => {
|
||||
setSelectedML(ml);
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-slate-500">Loading learning formats...</div>;
|
||||
|
||||
if (availableMLs.length === 0) {
|
||||
return <div className="text-slate-500">No micro learnings available for this topic yet.</div>;
|
||||
}
|
||||
|
||||
// If one is selected, render it
|
||||
if (selectedML) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" onClick={() => setSelectedML(null)} className="mb-4">
|
||||
← Back to selection
|
||||
</Button>
|
||||
<MicroLearningContainer
|
||||
microLearning={selectedML}
|
||||
sessionWeek={sessionWeek}
|
||||
onCompletedSuccessfully={onTopicCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, show the selection menu
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Choose a Learning Format</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{availableMLs.map((ml) => (
|
||||
<Card
|
||||
key={ml.id}
|
||||
className="cursor-pointer hover:border-blue-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors"
|
||||
onClick={() => handleSelection(ml)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-bold text-lg mb-2">{TYPE_LABELS[ml.type] || ml.type}</h3>
|
||||
<p className="text-sm text-slate-500">{TYPE_DESCRIPTIONS[ml.type]}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
55
src/components/micro_learning/ReflectionPrompt.jsx
Normal file
55
src/components/micro_learning/ReflectionPrompt.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export default function ReflectionPrompt({ content, onComplete }) {
|
||||
const [response, setResponse] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!response.trim() || submitted) return;
|
||||
|
||||
setSubmitted(true);
|
||||
onComplete(); // Trigger completion when a response is submitted
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Reflection Prompt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="prose dark:prose-invert">
|
||||
<p className="text-lg font-medium">{content?.prompt}</p>
|
||||
</div>
|
||||
|
||||
{!submitted ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<textarea
|
||||
className="w-full min-h-[150px] p-4 border rounded-md resize-y focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-slate-800 dark:border-slate-700"
|
||||
placeholder="Write your reflection here..."
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={!response.trim()}>Submit Reflection</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 rounded-md">
|
||||
<h4 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-2">Your Reflection</h4>
|
||||
<p className="whitespace-pre-wrap">{response}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-md">
|
||||
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 uppercase tracking-wider mb-2">Model Answer</h4>
|
||||
<div className="prose prose-blue dark:prose-invert">
|
||||
<p>{content?.model_answer}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
62
src/components/micro_learning/ScenarioQuiz.jsx
Normal file
62
src/components/micro_learning/ScenarioQuiz.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export default function ScenarioQuiz({ content, onComplete }) {
|
||||
const [selectedOptionId, setSelectedOptionId] = useState(null);
|
||||
const [showExplanations, setShowExplanations] = useState(false);
|
||||
|
||||
const handleSelect = (idx) => {
|
||||
if (showExplanations) return; // Prevent changing after selection
|
||||
setSelectedOptionId(idx);
|
||||
setShowExplanations(true);
|
||||
onComplete(); // Trigger completion immediately after an option is selected
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Scenario Quiz</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="prose dark:prose-invert">
|
||||
<p className="text-lg font-medium">{content?.scenario}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{content?.options?.map((option, idx) => {
|
||||
const isSelected = selectedOptionId === idx;
|
||||
|
||||
// Basic styling for the option buttons
|
||||
let buttonStyle = "w-full justify-start text-left h-auto p-4 ";
|
||||
if (showExplanations) {
|
||||
if (option.isCorrect) buttonStyle += "bg-green-100 dark:bg-green-900 border-green-500 ";
|
||||
else if (isSelected && !option.isCorrect) buttonStyle += "bg-red-100 dark:bg-red-900 border-red-500 ";
|
||||
else buttonStyle += "opacity-70 ";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className={buttonStyle}
|
||||
onClick={() => handleSelect(idx)}
|
||||
disabled={showExplanations}
|
||||
>
|
||||
<span className="flex-1 whitespace-normal">{option.text}</span>
|
||||
</Button>
|
||||
|
||||
{showExplanations && (
|
||||
<div className={`p-3 rounded-md text-sm ${option.isCorrect ? 'bg-green-50 dark:bg-green-950 text-green-800 dark:text-green-200' : 'bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300'}`}>
|
||||
<span className="font-semibold">{option.isCorrect ? '✓ ' : '✗ '}</span>
|
||||
{option.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
53
src/hooks/useMicroLearningCompletions.js
Normal file
53
src/hooks/useMicroLearningCompletions.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import pb from '../lib/pb';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export function useMicroLearningCompletions() {
|
||||
const { user } = useAuthStore?.() || { user: pb.authStore.model };
|
||||
|
||||
// Note: user in PB context for team_members usually matches user_id or team_member_id.
|
||||
// We need the team_member record. If the frontend is currently storing it in authStore, we can get it.
|
||||
|
||||
const recordCompletion = async ({ microLearningId, topicId, type, sessionWeek }) => {
|
||||
try {
|
||||
// Find the team_member record for the current user
|
||||
let teamMemberId = user?.id;
|
||||
// If user is just the auth user, we might need to query the team_members collection
|
||||
// but in many setups, the auth user is the team_member or there's a 1-to-1.
|
||||
// Let's ensure we fetch team_member_id properly if needed.
|
||||
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${user.id}"`);
|
||||
|
||||
if (!teamMemberRec) {
|
||||
throw new Error("Team member record not found for user.");
|
||||
}
|
||||
|
||||
const record = await pb.collection('micro_learning_completions').create({
|
||||
team_member_id: teamMemberRec.id,
|
||||
micro_learning_id: microLearningId,
|
||||
topic_id: topicId,
|
||||
type: type,
|
||||
session_week: sessionWeek
|
||||
});
|
||||
return record;
|
||||
} catch (err) {
|
||||
console.error("Error recording completion:", err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionCompletions = async (sessionWeek) => {
|
||||
try {
|
||||
const teamMemberRec = await pb.collection('team_members').getFirstListItem(`user_id = "${pb.authStore.model.id}"`);
|
||||
if (!teamMemberRec) return [];
|
||||
|
||||
const records = await pb.collection('micro_learning_completions').getFullList({
|
||||
filter: `team_member_id = "${teamMemberRec.id}" && session_week = ${sessionWeek}`
|
||||
});
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error("Error fetching session completions:", err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return { recordCompletion, getSessionCompletions };
|
||||
}
|
||||
17
src/hooks/useMicroLearnings.js
Normal file
17
src/hooks/useMicroLearnings.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import pb from '../lib/pb';
|
||||
|
||||
export function useMicroLearnings() {
|
||||
const getMicroLearningsByTopic = async (topicId) => {
|
||||
try {
|
||||
const records = await pb.collection('micro_learnings').getFullList({
|
||||
filter: `topic_id = "${topicId}" && status = 'published'`,
|
||||
});
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error("Error fetching micro learnings:", err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return { getMicroLearningsByTopic };
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BookOpen, CheckCircle, Loader, ArrowRight, ChevronLeft, MessageSquare, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOpen, CheckCircle, ArrowRight, ChevronLeft, Calendar } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card from '../components/ui/Card';
|
||||
import Button from '../components/ui/Button';
|
||||
import Tag from '../components/ui/Tag';
|
||||
import LearningContentViewer from '../components/ui/LearningContentViewer';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import { getAssignedTopic, generateLearningContent, getCachedContent } from '../lib/learningService';
|
||||
import { getAssignedTopic } from '../lib/learningService';
|
||||
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';
|
||||
|
||||
const Leren = () => {
|
||||
const { state } = useApp();
|
||||
@@ -19,39 +20,30 @@ const Leren = () => {
|
||||
// View state
|
||||
const [view, setView] = useState('overview'); // overview, detail
|
||||
const [activeTopic, setActiveTopic] = useState(null);
|
||||
const [content, setContent] = useState(null);
|
||||
|
||||
// Loading & Error
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Weekly status
|
||||
const [weeklyDone, setWeeklyDone] = useState(false);
|
||||
const [sessionDone, setSessionDone] = useState(false);
|
||||
|
||||
// Feedback
|
||||
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||
const [feedbackText, setFeedbackText] = useState('');
|
||||
const [feedbackPrompted, setFeedbackPrompted] = useState(false);
|
||||
|
||||
// Curriculum state
|
||||
const [hasCurriculum, setHasCurriculum] = useState(false);
|
||||
const [yearProgress, setYearProgress] = useState(null);
|
||||
const [weekContent, setWeekContent] = useState(null);
|
||||
|
||||
const { getSessionCompletions } = useMicroLearningCompletions();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.currentUser) {
|
||||
const load = async () => {
|
||||
const [assigned, topics, done] = await Promise.all([
|
||||
const [assigned, topics] = await Promise.all([
|
||||
getAssignedTopic(state.currentUser.id, state.weekNumber),
|
||||
db.getTopics(),
|
||||
db.getLearnDone(state.currentUser.id, state.weekNumber),
|
||||
]);
|
||||
setAssignedTopic(assigned);
|
||||
setAllTopics(topics);
|
||||
if (done) setWeeklyDone(true);
|
||||
|
||||
// Load curriculum data
|
||||
let currentWeekContent = null;
|
||||
try {
|
||||
const activeVersion = await getActiveVersion();
|
||||
setHasCurriculum(!!activeVersion);
|
||||
@@ -61,76 +53,38 @@ const Leren = () => {
|
||||
getCurrentWeekContent(state.weekNumber),
|
||||
]);
|
||||
setYearProgress(yProgress);
|
||||
currentWeekContent = wc;
|
||||
setWeekContent(wc);
|
||||
}
|
||||
} 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]);
|
||||
}, [state.currentUser, state.weekNumber, view]); // Reload when view changes (e.g. going back to overview)
|
||||
|
||||
const handleOpenTopic = async (topic) => {
|
||||
const handleOpenTopic = (topic) => {
|
||||
setActiveTopic(topic);
|
||||
setView('detail');
|
||||
setSessionDone(false);
|
||||
setError(null);
|
||||
setFeedbackText('');
|
||||
setFeedbackPrompted(false);
|
||||
const cached = await getCachedContent(topic.id);
|
||||
if (cached) {
|
||||
setContent(cached);
|
||||
} else {
|
||||
setContent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadContent = async (selectedType = 'article') => {
|
||||
if (!activeTopic) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const generated = await generateLearningContent(activeTopic, false, selectedType);
|
||||
setContent(generated);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doComplete = async () => {
|
||||
const handleTopicCompleted = () => {
|
||||
setSessionDone(true);
|
||||
if (!weeklyDone) {
|
||||
setWeeklyDone(true);
|
||||
await db.setLearnDone(state.currentUser.id, state.weekNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
if (!feedbackPrompted) {
|
||||
setFeedbackPrompted(true);
|
||||
setShowFeedbackModal(true);
|
||||
return;
|
||||
}
|
||||
doComplete();
|
||||
};
|
||||
|
||||
const handleSubmitFeedback = async () => {
|
||||
if (feedbackText.trim()) {
|
||||
await db.setSetting(
|
||||
`feedback:${state.currentUser.id}:${activeTopic.id}:${state.weekNumber}`,
|
||||
feedbackText.trim()
|
||||
);
|
||||
}
|
||||
setShowFeedbackModal(false);
|
||||
doComplete();
|
||||
};
|
||||
|
||||
const handleSkipFeedback = () => {
|
||||
setShowFeedbackModal(false);
|
||||
doComplete();
|
||||
};
|
||||
|
||||
// ── Detail View ──────────────────────────────────────────
|
||||
@@ -141,16 +95,14 @@ const Leren = () => {
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', stiffness: 200 }}>
|
||||
<CheckCircle size={80} className="mx-auto text-teal mb-6" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold mb-4">Learning session complete!</h1>
|
||||
<h1 className="text-3xl font-bold mb-4">Topic reviewed!</h1>
|
||||
<p className="text-fg-muted mb-8">
|
||||
You have successfully reviewed "<strong>{activeTopic.label}</strong>".
|
||||
{weeklyDone && ' Your weekly minimum is met.'}
|
||||
You have successfully completed a micro learning for "<strong>{activeTopic.label}</strong>".
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" onClick={() => setView('overview')}>Learn Another</Button>
|
||||
<Link to="/test">
|
||||
<Button>Start Weekly Test <ArrowRight size={18} className="ml-2" /></Button>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => { setView('overview'); setSessionDone(false); }}>
|
||||
Back to Station
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -158,50 +110,6 @@ const Leren = () => {
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 max-w-4xl mx-auto pb-24 md:pb-8">
|
||||
<AnimatePresence>
|
||||
{showFeedbackModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 16 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 16 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
|
||||
className="w-full max-w-lg"
|
||||
>
|
||||
<Card className="border border-bg-warm shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-full bg-teal/10 flex items-center justify-center flex-shrink-0">
|
||||
<MessageSquare size={20} className="text-teal" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold">How was this content?</h2>
|
||||
</div>
|
||||
<p className="text-fg-muted text-sm mb-5">
|
||||
Your feedback helps improve future learning content. This is optional — you can skip if you prefer.
|
||||
</p>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={feedbackText}
|
||||
onChange={e => setFeedbackText(e.target.value)}
|
||||
placeholder="What was clear? What could be improved? Anything missing?"
|
||||
rows={4}
|
||||
className="w-full rounded-[var(--r-sm)] border border-bg-warm bg-bg p-3 text-sm resize-none focus:outline-none focus:border-teal transition-colors"
|
||||
/>
|
||||
<div className="flex justify-end gap-3 mt-4">
|
||||
<Button variant="outline" onClick={handleSkipFeedback}>Skip</Button>
|
||||
<Button onClick={handleSubmitFeedback}>Submit Feedback</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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>
|
||||
@@ -215,43 +123,11 @@ const Leren = () => {
|
||||
<p className="text-fg-muted mt-2">{activeTopic.description}</p>
|
||||
</div>
|
||||
|
||||
{!content && !isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<BookOpen size={48} className="mx-auto text-teal/30 mb-4" />
|
||||
<p className="text-fg-muted mb-6">Choose how you want to learn this topic.</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button onClick={() => loadContent('article')}>Article</Button>
|
||||
<Button onClick={() => loadContent('slides')}>Slides</Button>
|
||||
<Button onClick={() => loadContent('infographic')}>Infographic</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Card className="border border-bg-warm text-center py-16">
|
||||
<Loader size={48} className="mx-auto text-teal animate-spin mb-4" />
|
||||
<p className="font-medium">AI is generating your learning module...</p>
|
||||
<p className="text-fg-muted text-sm mt-2">This may take 10–30 seconds.</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border border-red-200 bg-red-50 text-red-900 p-6">
|
||||
<p className="font-bold mb-1">Generation failed</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button onClick={loadContent} variant="outline" className="mt-4 border-red-300 text-red-700">Try again</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{content && <LearningContentViewer content={content} topic={activeTopic} onGenerate={loadContent} isLoading={isLoading} />}
|
||||
|
||||
{content && (
|
||||
<div className="mt-10 pt-6 border-t border-bg-warm flex justify-end">
|
||||
<Button onClick={handleComplete}>
|
||||
<CheckCircle size={18} className="mr-2" /> Complete Session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MicroLearningSelector
|
||||
topicId={activeTopic.id}
|
||||
sessionWeek={state.weekNumber}
|
||||
onTopicCompleted={handleTopicCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -272,12 +148,6 @@ const Leren = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 text-red-800 p-4 rounded-[var(--r-sm)] border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Cards (only shown when curriculum exists) */}
|
||||
{hasCurriculum && yearProgress && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
|
||||
Reference in New Issue
Block a user