diff --git a/pb_migrations/1780800000_created_micro_learnings.js b/pb_migrations/1780800000_created_micro_learnings.js new file mode 100644 index 0000000..4ffcff6 --- /dev/null +++ b/pb_migrations/1780800000_created_micro_learnings.js @@ -0,0 +1,183 @@ +/// +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); + } +}) diff --git a/pb_migrations/1780800001_deleted_legacy_collections.js b/pb_migrations/1780800001_deleted_legacy_collections.js new file mode 100644 index 0000000..44aa604 --- /dev/null +++ b/pb_migrations/1780800001_deleted_legacy_collections.js @@ -0,0 +1,23 @@ +/// +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. +}) diff --git a/src/components/micro_learning/ConceptExplainer.jsx b/src/components/micro_learning/ConceptExplainer.jsx new file mode 100644 index 0000000..e798bb8 --- /dev/null +++ b/src/components/micro_learning/ConceptExplainer.jsx @@ -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 ( + + + Concept Explainer + + + {content?.sections?.map((section, i) => ( +
+

{section.title}

+
+
+ ))} + + + ); +} diff --git a/src/components/micro_learning/FlashcardSet.jsx b/src/components/micro_learning/FlashcardSet.jsx new file mode 100644 index 0000000..16a841c --- /dev/null +++ b/src/components/micro_learning/FlashcardSet.jsx @@ -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
No flashcards available.
; + } + + const currentCard = cards[currentIndex]; + + return ( +
+
+ Card {currentIndex + 1} of {cards.length} +
+ + + + {isFlipped ? ( +
+

{currentCard.back}

+
+ ) : ( +
+

{currentCard.front}

+
+ )} +
+
+ +
+ Click the card to flip +
+ +
+ + +
+
+ ); +} diff --git a/src/components/micro_learning/MicroLearningContainer.jsx b/src/components/micro_learning/MicroLearningContainer.jsx new file mode 100644 index 0000000..141e28a --- /dev/null +++ b/src/components/micro_learning/MicroLearningContainer.jsx @@ -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 ; + case 'scenario_quiz': + return ; + case 'flashcard_set': + return ; + case 'reflection_prompt': + return ; + default: + return
Unknown micro learning type.
; + } + }; + + return ( +
+ {renderComponent()} + + {completed && ( +
+

Micro Learning Completed!

+

Your progress has been recorded.

+
+ )} +
+ ); +} diff --git a/src/components/micro_learning/MicroLearningSelector.jsx b/src/components/micro_learning/MicroLearningSelector.jsx new file mode 100644 index 0000000..ca22bfc --- /dev/null +++ b/src/components/micro_learning/MicroLearningSelector.jsx @@ -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
Loading learning formats...
; + + if (availableMLs.length === 0) { + return
No micro learnings available for this topic yet.
; + } + + // If one is selected, render it + if (selectedML) { + return ( +
+ + +
+ ); + } + + // Otherwise, show the selection menu + return ( + + + Choose a Learning Format + + +
+ {availableMLs.map((ml) => ( + handleSelection(ml)} + > + +

{TYPE_LABELS[ml.type] || ml.type}

+

{TYPE_DESCRIPTIONS[ml.type]}

+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/micro_learning/ReflectionPrompt.jsx b/src/components/micro_learning/ReflectionPrompt.jsx new file mode 100644 index 0000000..39ecd91 --- /dev/null +++ b/src/components/micro_learning/ReflectionPrompt.jsx @@ -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 ( + + + Reflection Prompt + + +
+

{content?.prompt}

+
+ + {!submitted ? ( +
+