diff --git a/app/courses/[courseId]/progress/page.tsx b/app/courses/[courseId]/progress/page.tsx index 6966e3da..41e31b7e 100644 --- a/app/courses/[courseId]/progress/page.tsx +++ b/app/courses/[courseId]/progress/page.tsx @@ -1,91 +1,206 @@ "use client"; -import { useState } from "react"; -import * as React from "react"; -import { RewardScores } from "@/components/RewardScores"; -import { RewardScoresHelpButton } from "@/components/RewardScoresHelpButton"; -import { Button, IconButton, Typography } from "@mui/material"; -import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import { pageLearningProgressQuery } from "@/__generated__/pageLearningProgressQuery.graphql"; +import { stringToColor } from "@/components/ChapterHeader"; +import CompetencyProgressbar from "@/components/CompetencyProgressbar"; import { LightTooltip } from "@/components/LightTooltip"; +import { Suggestion } from "@/components/Suggestion"; import { Info } from "@mui/icons-material"; -import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; -import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; -import CompetencyProgressbar from "@/components/CompetencyProgressbar"; -import { stringToColor } from "@/components/ChapterHeader"; -import { useCourseData } from "../../../../components/courses/context/CourseDataContext"; -import { useRouter } from "next/navigation"; -import { StudentCourseLayoutCourseIdQuery$data } from "@/__generated__/StudentCourseLayoutCourseIdQuery.graphql"; +import { + Checkbox, + Chip, + FormControlLabel, + IconButton, + Slide, + Typography, + useTheme, +} from "@mui/material"; +import { useParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { graphql, useLazyLoadQuery } from "react-relay"; export default function LearningProgress() { - const router = useRouter(); + const { courseId } = useParams(); + const data = useLazyLoadQuery( + graphql` + query pageLearningProgressQuery($id: UUID!) { + coursesByIds(ids: [$id]) { + id + numberOfCourseMemberships + suggestions(amount: 100) { + ...SuggestionFragment + content { + id + ... on Assessment { + items { + associatedSkills { + skillName + skillCategory + } + } + } + metadata { + chapter { + id + } + } + } + } + chapters { + elements { + id + suggestedStartDate + suggestedEndDate + startDate + endDate + skills { + skillName + skillCategory + } + } + } + skills { + skillName + skillCategory + skillValue + skillAllUsersStats { + skillValueSum + participantCount + averageSkillValue + } + } + } + } + `, + { id: courseId } + ); - // Get data from context - const data = useCourseData() as StudentCourseLayoutCourseIdQuery$data; const course = data.coursesByIds[0]; - const id = course.id; - const [currentPage, setCurrentPage] = useState(0); + const skillsByCategory = useMemo(() => { + return course.skills.reduce< + Record + >((acc, skill) => { + acc[skill.skillCategory] ??= []; + acc[skill.skillCategory].push(skill); + return acc; + }, {}); + }, [course]); - const categoriesPerPage = 3; - const uniqueSkillCategories = Array.from( - new Map(course.skills.map((skill) => [skill.skillCategory, skill])).values() - ); + const uniqueCategories = Object.keys(skillsByCategory); - // Sort categories by total progress - const sortedSkillCategories = [...uniqueSkillCategories].sort((a, b) => { - const getTotalProgress = (category: typeof a) => { - const skillsInCategory = course.skills.filter( - (skill) => skill.skillCategory === category.skillCategory - ); - const uniqueSkills = Array.from( - new Map(skillsInCategory.map((s) => [s.skillName, s])).values() - ); - return uniqueSkills.reduce( - (acc, skill) => - acc + - Object.values(skill.skillLevels || {}).reduce( - (sum, level) => sum + (level?.value || 0), - 0 - ), - 0 - ); - }; - return getTotalProgress(b) - getTotalProgress(a); - }); + const [selectedCategory, setSelectedCategory] = useState(0); + const [selectedSkill, setSelectedSkill] = useState(null); - const totalPages = Math.ceil( - sortedSkillCategories.length / categoriesPerPage - ); + const [showAverageProgress, setAverageProgress] = useState(false); - const currentCategorySlice = sortedSkillCategories.slice( - currentPage * categoriesPerPage, - (currentPage + 1) * categoriesPerPage - ); + const sortedCategories = useMemo(() => { + if (uniqueCategories.length === 0) return []; + return [...uniqueCategories].sort((a, b) => { + const getTotalProgress = (category: typeof a) => { + const uniqueSkillsInCategory = Array.from( + new Map( + skillsByCategory[category].map((skill) => [skill.skillName, skill]) + ).values() + ); + const progressSum = uniqueSkillsInCategory.reduce((sum, skill) => { + const progress = skill.skillValue; + return sum + progress; + }, 0); + return (progressSum / uniqueSkillsInCategory.length) * 100; + }; + return getTotalProgress(b) - getTotalProgress(a); + }); + }, [skillsByCategory, uniqueCategories]); - const handlePrevPage = () => { - if (currentPage > 0) { - setCurrentPage(currentPage - 1); - } + const currentUniqueSkills = useMemo(() => { + if (sortedCategories.length === 0) return []; + return skillsByCategory[sortedCategories[selectedCategory]] + .reduce((acc, skillA) => { + if (!acc.some((skillB) => skillA.skillName === skillB.skillName)) { + acc.push(skillA); + } + return acc; + }, [] as (typeof skillsByCategory)[string]) + .sort((skillA, skillB) => { + return skillB.skillValue - skillA.skillValue; + }); + }, [selectedCategory, skillsByCategory, sortedCategories]); + + const urgentChapters = useMemo(() => { + return course.chapters.elements.filter((chapter) => { + const suggestedEndDate = Date.parse( + chapter.suggestedEndDate ?? chapter.endDate + ); + return suggestedEndDate.valueOf() < Date.now(); + }); + }, [course.chapters.elements]); + + const lockedChapters = useMemo(() => { + return course.chapters.elements.filter((chapter) => { + const startDate = Date.parse(chapter.startDate); + return startDate.valueOf() > Date.now(); + }); + }, [course.chapters.elements]); + + const filteredSuggestionsByCategory = (category: string) => { + return (course.suggestions ?? []).filter((suggestion) => + suggestion.content.items?.some((item) => + item.associatedSkills.some((skill) => skill.skillCategory === category) + ) + ); }; - const handleNextPage = () => { - if (currentPage < totalPages - 1) { - setCurrentPage(currentPage + 1); - } + const filteredSuggestionsBySkill = (skillName: string) => { + return (course.suggestions ?? []).filter((suggestion) => + suggestion.content.items?.some((item) => + item.associatedSkills.some((skill) => skill.skillName === skillName) + ) + ); }; - return ( -
-
-
- Skill progress + const [previousProgress] = useState>(() => { + if (typeof window === "undefined") return new Map(); + + const stored = sessionStorage.getItem("previousProgress"); + return stored ? new Map(JSON.parse(stored)) : new Map(); + }); + + useEffect(() => { + if (uniqueCategories.length === 0) return; + + const tempMap = new Map(); + + course.skills.forEach((skill) => { + tempMap.set(skill.skillName, skill.skillValue * 100); + }); + + sessionStorage.setItem("previousProgress", JSON.stringify([...tempMap])); + }, [course.skills, uniqueCategories.length]); + + const theme = useTheme(); + + if (uniqueCategories.length === 0) { + return ( + + {" "} + No Skills in this course to display progress. + + ); + } + + return ( +
+
+
+ Knowledge Area -

Information Skillprogress

+

Knowledge Area

{ - "Here you can see your personal progress for this course, splitted up in every skill category that is assigned to this course. Every skill category consists of unique skills. These skills are assigned to the different exercises. If you complete an exercise your skill progress will increase." + "A knowledge area is defined as a thematic field of study that groups together related competencies in order to represent the fundamental knowledge of a given subject area." }

@@ -95,94 +210,327 @@ export default function LearningProgress() {
- - {totalPages > 1 && ( -
- - - - - {currentPage + 1} / {totalPages} - - = totalPages - 1} - > - - -
- )} +
+ setAverageProgress(!showAverageProgress)} + /> + } + label="show average Progress" + /> +
- -
- {currentCategorySlice.map((uniqueSkill) => { - const skillsInCategory = course.skills.filter( - (skill) => skill.skillCategory === uniqueSkill.skillCategory - ); +
+ {sortedCategories.map((category) => { const uniqueSkillsInCategory = Array.from( new Map( - skillsInCategory.map((skill) => [skill.skillName, skill]) + skillsByCategory[category].map((skill) => [ + skill.skillName, + skill, + ]) ).values() ); - const totalCategoryProgress = uniqueSkillsInCategory.reduce( - (acc, skill) => - acc + - Object.values(skill.skillLevels || {}).reduce( - (sum, level) => sum + (level?.value || 0), - 0 - ), + const chaptersWithThisCategory = course.chapters.elements.filter( + (chapter) => { + return chapter.skills?.some( + (skill) => skill?.skillCategory === category + ); + } + ); + + const disabled = chaptersWithThisCategory.every((chapter) => + lockedChapters.includes(chapter) + ); + + const urgent = filteredSuggestionsByCategory(category).some( + (suggestion) => + urgentChapters.some( + (chapter) => + chapter.id === suggestion.content.metadata.chapter.id + ) + ); + + const progressSum = uniqueSkillsInCategory.reduce((sum, skill) => { + const progress = skill.skillValue * 100; + return sum + progress; + }, 0); + + const averageProgressSum = uniqueSkillsInCategory.reduce( + (sum, skill) => { + const averageProgress = + skill.skillAllUsersStats.averageSkillValue * 100; + return sum + averageProgress; + }, 0 ); - const categoryProgressValue = Math.floor( - Math.min( - (totalCategoryProgress * 100) / uniqueSkillsInCategory.length, - 100 + + const maxParticipantCountForaSkill = Math.max( + ...uniqueSkillsInCategory.map( + (skill) => skill.skillAllUsersStats.participantCount ) ); + const categoryProgressValue = + progressSum / uniqueSkillsInCategory.length; + const categoryAverageProgressValue = + averageProgressSum / uniqueSkillsInCategory.length; + + const tempSumPreviousProgress = uniqueSkillsInCategory.reduce( + (sum, skill) => + sum + (previousProgress.get(skill.skillName) ?? 0), + 0 + ); + + const previousCategoryProgressValue = + previousProgress.size === 0 + ? categoryProgressValue + : tempSumPreviousProgress / uniqueSkillsInCategory.length; + return ( -
-
+
+
{ + setSelectedCategory( + sortedCategories.findIndex((c) => c === category) + ); + setSelectedSkill(null); + }} + isDisabled={disabled} + isSelected={category === sortedCategories[selectedCategory]} + isUrgent={urgent} + showAverageProgress={showAverageProgress} + participantCount={maxParticipantCountForaSkill} + courseMemberCount={course.numberOfCourseMemberships} + openTaskCount={ + filteredSuggestionsByCategory(category).length + } />
-
- {uniqueSkillsInCategory.map((skill) => { - const rawValue = Object.values( - skill?.skillLevels || {} - ).reduce((sum, level) => sum + (level?.value || 0), 0); - const clamped = Math.min(rawValue, 1); - const skillProgressPercent = Math.floor(clamped * 100); - - return ( -
- -
- ); - })} -
); })}
+ +
+
+ Competencies + + +

Competency

+

+ { + "Each task has assigned Competencies to represent the content of the task. The following competencies are part of the selected Knowledge Area." + } +

+ + } + > + + + +
+
+
+ {currentUniqueSkills.map((currentSkill) => { + const chaptersWithThisSkill = course.chapters.elements.filter( + (chapter) => { + return chapter.skills?.some( + (skill) => skill?.skillName === currentSkill.skillName + ); + } + ); + + const disabled = chaptersWithThisSkill.every((chapter) => + lockedChapters.includes(chapter) + ); + + const urgent = filteredSuggestionsBySkill( + currentSkill.skillName + ).some((suggestion) => + urgentChapters.some( + (chapter) => + chapter.id === suggestion.content.metadata.chapter.id + ) + ); + + const skillProgressValue = currentSkill.skillValue * 100; + const skillAverageProgressValue = + currentSkill.skillAllUsersStats.averageSkillValue * 100; + + const previousSkillProgressValue = + previousProgress.get(currentSkill.skillName) ?? + skillProgressValue; + + return ( + +
+ { + const currentIndex = currentUniqueSkills.findIndex( + (skill) => skill === currentSkill + ); + currentIndex === selectedSkill + ? setSelectedSkill(null) + : setSelectedSkill(currentIndex); + }} + isDisabled={disabled} + isSelected={ + selectedSkill === null + ? false + : currentUniqueSkills.at(selectedSkill) === currentSkill + } + isUrgent={urgent} + showAverageProgress={showAverageProgress} + participantCount={ + currentSkill.skillAllUsersStats.participantCount + } + courseMemberCount={course.numberOfCourseMemberships} + openTaskCount={ + filteredSuggestionsBySkill(currentSkill.skillName).length + } + /> +
+
+ ); + })} +
+
+ +
+
+ Task Recommendation + + +

Task Recommendation

+

+ { + "Here are some recommended tasks to improve your progress in the selected Knowledge Area." + } +

+ + } + > + + + +
+
+ {selectedSkill === null ? ( +
+ {filteredSuggestionsByCategory(sortedCategories[selectedCategory]) + .length > 0 ? ( + filteredSuggestionsByCategory( + sortedCategories[selectedCategory] + ).map((suggestion) => ( + +
+ +
+
+ )) + ) : ( + + {" "} + No task recommendations available for this knowledge area. + + )} +
+ ) : ( +
+ Urgent Tasks: +
+ {filteredSuggestionsBySkill( + currentUniqueSkills.at(selectedSkill)!.skillName + ) + .filter((suggestion) => + urgentChapters.some( + (urgentChapter) => + urgentChapter.id === + suggestion.content.metadata.chapter.id + ) + ) + .map((urgentSuggestion) => ( + + ))} +
+ Other Tasks: +
+ {filteredSuggestionsBySkill( + currentUniqueSkills.at(selectedSkill)!.skillName + ) + .filter( + (suggestion) => + !urgentChapters.some( + (urgentChapter) => + urgentChapter.id === + suggestion.content.metadata.chapter.id + ) + ) + .map((notUrgentSuggestion) => ( + + ))} +
+
+ )} +
); } diff --git a/components/ChapterOverview.tsx b/components/ChapterOverview.tsx index 58b972e9..91b2ae13 100644 --- a/components/ChapterOverview.tsx +++ b/components/ChapterOverview.tsx @@ -9,12 +9,6 @@ import { StudentChapter } from "./StudentChapter"; const ChapterFragment = graphql` fragment ChapterOverviewFragment on Course { id - suggestions(amount: 4) { - ...SuggestionFragment - content { - id - } - } chapters { elements { id diff --git a/components/CompetencyProgressbar.tsx b/components/CompetencyProgressbar.tsx index 0eb4cc90..98e8d04d 100644 --- a/components/CompetencyProgressbar.tsx +++ b/components/CompetencyProgressbar.tsx @@ -1,37 +1,227 @@ -import * as React from "react"; -import Box from "@mui/material/Box"; -import LinearProgress from "@mui/material/LinearProgress"; +import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; +import GroupsIcon from "@mui/icons-material/Groups"; +import { + Box, + LinearProgress, + Tooltip, + Typography, + useTheme, +} from "@mui/material"; +import { useEffect, useState } from "react"; -type CompetencyProgressbarProps = { +export default function CompetencyProgressbar({ + competencyName, + startProgress, + endProgress, + averageProgress = 0, + small = false, + color, + onClick, + isSelected = false, + isDisabled = false, + isUrgent = false, + showAverageProgress = false, + participantCount, + courseMemberCount, + openTaskCount, +}: { competencyName: string; - progressValue: number; - heightValue: number; + startProgress: number; + endProgress: number; + averageProgress?: number; + small?: boolean; color: string; -}; + onClick?: () => void; + isSelected?: boolean; + isDisabled?: boolean; + isUrgent?: boolean; + showAverageProgress?: boolean; + participantCount: number; + courseMemberCount: number; + openTaskCount: number; +}) { + const [progress, setProgress] = useState(startProgress); + const [showBar, setShowBar] = useState(startProgress !== 100); + const [isMoving, setMoving] = useState(startProgress !== endProgress); -export default function CompetencyProgressbar( - props: CompetencyProgressbarProps -) { - const { competencyName } = props; - const { progressValue } = props; - const { heightValue } = props; - const { color } = props; + useEffect(() => { + setProgress(endProgress); + if (startProgress !== endProgress && endProgress - startProgress > 1) { + setMoving(true); + setShowBar(true); + } else { + setMoving(false); + setShowBar(endProgress !== 100); + } + }, [startProgress, endProgress]); + + const theme = useTheme(); return ( - - - + + {openTaskCount} open Tasks + + + + > + + {competencyName} {!isDisabled && "-"} + + + {isMoving && {`${startProgress}% →`}} + + {!isDisabled && ( + + {endProgress}% + {!showBar && ( + + )} + + )} + + + {isDisabled ? ( + + No progress possible yet. + + ) : ( + showBar && ( + + { + if ( + e.propertyName === "transform" && + (e.target as HTMLElement).classList.contains( + "MuiLinearProgress-bar" + ) + ) { + setMoving(false); + if (endProgress === 100) { + setShowBar(false); + } + } + }} + /> + + {showAverageProgress && ( + + + Average Progress: {averageProgress} % + + + + {participantCount} / {courseMemberCount} students + started working on this. + + + + } + > + + + + + )} + + ) + )} ); } diff --git a/components/Navbar.tsx b/components/Navbar.tsx index ab4bb4a6..5d781275 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -719,6 +719,7 @@ function UserInfo({ tutor, userId }: { tutor: boolean; userId: string }) { edge="end" aria-label="logout" onClick={() => { + sessionStorage.clear(); window.localStorage.removeItem("meitrex-welcome-shown"); clearChat(); auth.signoutRedirect({ diff --git a/components/content-link/ContentLink.tsx b/components/content-link/ContentLink.tsx index fd056479..a5d07c97 100644 --- a/components/content-link/ContentLink.tsx +++ b/components/content-link/ContentLink.tsx @@ -266,7 +266,7 @@ export function ContentLink({ }} >
-
+
{content.metadata.name} diff --git a/components/leaderboard/Leaderboard.tsx b/components/leaderboard/Leaderboard.tsx index 9daae102..57fff974 100644 --- a/components/leaderboard/Leaderboard.tsx +++ b/components/leaderboard/Leaderboard.tsx @@ -216,7 +216,10 @@ export default function Leaderboard({ } } } - allTime: getAllTimeCourseLeaderboards(courseID: $courseID, date: "1970-01-01") { + allTime: getAllTimeCourseLeaderboards( + courseID: $courseID + date: "1970-01-01" + ) { id title startDate diff --git a/src/schema.graphql b/src/schema.graphql index 55e0c25f..58904921 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -299,6 +299,7 @@ type Course { id: UUID! mediaRecords: [MediaRecord!]! memberships: [CourseMembership!]! + numberOfCourseMemberships: Int! published: Boolean! rewardScores: RewardScores! scoreboard: [ScoreboardItem!]! @@ -1273,6 +1274,15 @@ type Post { upvotedByUsers: [UUID]! } +type ProactiveFeedback { + assessmentId: UUID! + correctness: Float! + createdAt: DateTime! + feedbackText: String! + id: UUID! + success: Boolean! +} + type ProgressLogItem { correctness: Float! hintsUsed: Int! @@ -1292,6 +1302,7 @@ type Query { _empty: String achievementsByCourseId(courseId: UUID!): [Achievement!]! achievementsByUserId(userId: UUID): [Achievement!]! + allProactiveFeedback: [ProactiveFeedback!]! contentsByChapterIds(chapterIds: [UUID!]!): [[Content!]!]! contentsByCourseIds(courseIds: [UUID!]!): [[Content!]!] contentsByIds(ids: [UUID!]!): [Content!]! @@ -1340,6 +1351,7 @@ type Query { openQuestionByCourseId(id: UUID!): [Thread!]! otherUserForumActivityByUserId(otherUserId: UUID!): [ForumActivityEntry!]! PlayerHexadScoreExists(userId: UUID!): Boolean! + proactiveFeedback(assessmentId: UUID!): ProactiveFeedback scoreboard(courseId: UUID!): [ScoreboardItem!]! semanticSearch(count: Int! = 10, courseWhitelist: [UUID!], queryText: String!): [SemanticSearchResult!]! submissionExerciseByUser(assessmentId: UUID!): SubmissionExercise! @@ -1592,9 +1604,18 @@ directive @Size(min: Int = 0, max: Int = 2147483647, message: String = "graphql. type Skill { id: UUID! isCustomSkill: Boolean! + skillAllUsersStats: SkillAllUsersStats! skillCategory: String! skillLevels: SkillLevels skillName: String! + skillValue: Float! +} + +type SkillAllUsersStats { + averageSkillValue: Float! + participantCount: Int! + skillId: UUID! + skillValueSum: Float! } input SkillInput { @@ -1638,6 +1659,11 @@ enum SkillType { UNDERSTAND } +type SkillValue { + skillId: UUID! + skillValue: Float! +} + enum SortDirection { ASC DESC @@ -1744,6 +1770,7 @@ type SubmissionSolution { type Subscription { notificationAdded(userId: UUID!): NotificationData! + proactiveFeedbackAdded(userId: UUID!): ProactiveFeedback! } type Suggestion {