Skip to content
2 changes: 1 addition & 1 deletion apps/src/templates/progress/progressHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export function lessonProgressForSection(sectionLevelProgress, lessons) {
// create empty "dictionary" to store per-lesson progress for student
const studentLessonProgress = {};
// for each lesson, summarize student's progress based on level progress
lessons.forEach(lesson => {
lessons?.forEach(lesson => {
studentLessonProgress[lesson.id] = lessonProgressForStudent(
studentLevelProgress,
lesson.levels
Expand Down
239 changes: 232 additions & 7 deletions apps/src/templates/studentSnapshot/StudentSnapshot.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import FontAwesomeV6Icon from '@code-dot-org/component-library/fontAwesomeV6Icon';
import {Typography} from '@mui/material';
import classNames from 'classnames';
import _ from 'lodash';
import React, {useState} from 'react';
import {useSelector} from 'react-redux';

import {getSelectedUnitId} from '@cdo/apps/redux/unitSelectionRedux';
import {loadUnitProgress} from '@cdo/apps/templates/sectionProgress/sectionProgressLoader';
import {LessonOption} from '@cdo/apps/templates/teacherDashboardShared/LessonSelector';
import HttpClient from '@cdo/apps/util/HttpClient';
import {useAppSelector} from '@cdo/apps/util/reduxHooks';
import {LevelStatus} from '@cdo/generated-scripts/sharedConstants';

import {getFullName} from '../manageStudents/utils';

Expand All @@ -21,6 +25,40 @@ interface LessonsData {
hasUnnumberedLessons: boolean;
}

interface UserProgressInLessonData {
[userId: number]: {
progress: number | null;
timeSpent: string | null;
};
}

interface UserValidationProgressByLessonData {
[lessonId: string]: {
[userId: string]: number;
};
}

const COMPLETED_STATUSES: string[] = [
LevelStatus.completed_assessment,
LevelStatus.free_play_complete,
LevelStatus.passed,
LevelStatus.perfect,
LevelStatus.review_accepted,
LevelStatus.submitted,
];

const COMPLETE_PERCENT_STRING = '100% complete';
const ZERO_TIME_SPENT = '00:00:00';

const formatTimeSpent = (secondsSpent: number) => {
if (!secondsSpent) return ZERO_TIME_SPENT;

const hours = `${Math.floor(secondsSpent / 3600)}`.padStart(2, '0');
const minutes = `${Math.floor((secondsSpent % 3600) / 60)}`.padStart(2, '0');
const seconds = `${secondsSpent % 60}`.padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
};

const getLessons = (unitId: number) =>
HttpClient.fetchJson<LessonsData>(
`/student_snapshots/lessons/${unitId}`
Expand All @@ -29,24 +67,148 @@ const getLessons = (unitId: number) =>
const lessonsCachedLoader = _.memoize(getLessons);

const StudentSnapshot: React.FC = () => {
const [selectedStudentId, setSelectedStudentId] = React.useState<
number | null
>(null);

const [lessons, setLessons] = useState<LessonOption[]>([]);
const [selectedLessonId, setSelectedLessonId] = useState<number | null>(null);
const [isLessonsLoading, setIsLessonsLoading] = useState<boolean>(false);
const [hasUnnumberedLessons, setHasUnnumberedLessons] =
useState<boolean>(false);
const [selectedStudentId, setSelectedStudentId] = React.useState<
number | null
>(null);

const sectionId = useAppSelector(
state => state.teacherSections.selectedSectionId
);
const sectionCourseId = useAppSelector(state =>
state.teacherSections.selectedSectionId
? state.teacherSections.sections[state.teacherSections.selectedSectionId]
.courseId
: null
);
const selectedUnitId = useSelector(getSelectedUnitId);
const selectedUnitPosition = useAppSelector(state =>
state.teacherSections.selectedSectionId
? state.teacherSections.sections[state.teacherSections.selectedSectionId]
.unitPosition
: null
);
const lessonProgressByUnit = useAppSelector(
state => state.sectionProgress?.studentLessonProgressByUnit
);
const unitDataByUnit = useAppSelector(
state => state.sectionProgress?.unitDataByUnit
);
const studentLevelProgressByUnit = useAppSelector(
state => state.sectionProgress?.studentLevelProgressByUnit
);
const {selectedStudents} = useAppSelector(state => state.teacherSections);

const selectedStudent = React.useMemo(
() => selectedStudents.find(student => student.id === selectedStudentId),
[selectedStudentId, selectedStudents]
);

const selectedUnitId = useSelector(getSelectedUnitId);
const userProgressBySelectedLesson = React.useMemo(() => {
const progressByUser: UserProgressInLessonData = {};
if (
selectedLessonId &&
lessonProgressByUnit &&
lessonProgressByUnit[selectedUnitId]
) {
Object.keys(lessonProgressByUnit[selectedUnitId]).forEach(
(userId: string) => {
const lessonProgressByUnitForUser =
lessonProgressByUnit[selectedUnitId][+userId][selectedLessonId];
progressByUser[+userId] = lessonProgressByUnitForUser
? {
progress: Math.floor(
lessonProgressByUnitForUser['completedPercent']
),
timeSpent: formatTimeSpent(
lessonProgressByUnitForUser['timeSpent']
),
}
: {
progress: 0,
timeSpent: ZERO_TIME_SPENT,
};
}
);
}
return progressByUser;
}, [selectedUnitId, selectedLessonId, lessonProgressByUnit]);

// Map each lesson to the number of validated levels it has
const lessonsToValidationLevels = React.useMemo(() => {
const lessonsToValidationLevelsMap: {[lessonId: number]: string[]} = {};
if (unitDataByUnit) {
const lessons = unitDataByUnit[selectedUnitId]?.lessons;
if (lessons) {
Object.values(lessons).forEach(lesson => {
const currLessonValidationLevels: string[] = [];
Object.values(lesson.levels).forEach(level => {
if (level.isValidated) {
currLessonValidationLevels.push(level.id);
}
});
lessonsToValidationLevelsMap[lesson.id] = currLessonValidationLevels;
});
}
}
return lessonsToValidationLevelsMap;
}, [unitDataByUnit, selectedUnitId]);

// Map each lesson to the amount of validation levels each student has completed
const userValidationProgressByLesson = React.useMemo(() => {
const userValidationProgressByLessonMap: UserValidationProgressByLessonData =
{};
if (lessonsToValidationLevels && studentLevelProgressByUnit) {
Object.entries(lessonsToValidationLevels).forEach(
([lessonId, validationLevelIds]) => {
const levelProgressByUser: {[userId: string]: number} = {};
Object.entries(studentLevelProgressByUnit[selectedUnitId]).forEach(
([userId, levelProgress]) => {
levelProgressByUser[userId] = Object.entries(
levelProgress
).filter(
([levelId, progress]) =>
validationLevelIds.includes(levelId) &&
COMPLETED_STATUSES.includes(progress.status)
).length;
}
);
userValidationProgressByLessonMap[lessonId] = levelProgressByUser;
}
);
return userValidationProgressByLessonMap;
}
}, [selectedUnitId, lessonsToValidationLevels, studentLevelProgressByUnit]);

const numValidationLevelsCompleteString = React.useMemo(() => {
if (
!selectedUnitId ||
!selectedLessonId ||
!selectedStudentId ||
!lessonsToValidationLevels ||
!userValidationProgressByLesson ||
!userValidationProgressByLesson[selectedLessonId]
) {
return '';
}

const numValidationLevelsUserCompleted =
userValidationProgressByLesson[selectedLessonId][`${selectedStudentId}`];
const totalValidationLevels = lessonsToValidationLevels[selectedLessonId];
return numValidationLevelsUserCompleted === totalValidationLevels.length
? COMPLETE_PERCENT_STRING
: `${numValidationLevelsUserCompleted} of ${totalValidationLevels.length} passed`;
}, [
selectedUnitId,
selectedLessonId,
selectedStudentId,
lessonsToValidationLevels,
userValidationProgressByLesson,
]);

React.useEffect(() => {
if (selectedUnitId) {
setIsLessonsLoading(true);
Expand All @@ -58,8 +220,14 @@ const StudentSnapshot: React.FC = () => {
.finally(() => {
setIsLessonsLoading(false);
});
loadUnitProgress(
selectedUnitId,
sectionId,
sectionCourseId,
selectedUnitPosition
);
}
}, [selectedUnitId]);
}, [sectionId, selectedUnitId, sectionCourseId, selectedUnitPosition]);

// TODO: replace with actual values from URL/Redux later
const HARDCODED_STUDENT_ID = 8; // Replace with actual student ID
Expand Down Expand Up @@ -132,6 +300,63 @@ const StudentSnapshot: React.FC = () => {
>
<div>Should not be displayed</div>
</WidgetTemplate>
{selectedStudentId && (
<WidgetTemplate
widgetName="Lesson Details"
gridWidth={3}
gridHeight={1}
>
<div className={styles.lessonDetailsWidget}>
<div className={styles.lessonDetail}>
<FontAwesomeV6Icon
iconName={'chart-line'}
iconStyle={'regular'}
/>
<div
className={classNames(
styles.lessonDetailLabelAndInfo,
userProgressBySelectedLesson[selectedStudentId]
?.progress === 100 && styles.greenCompletedText
)}
>
<Typography variant="overline3">Progress</Typography>
<Typography variant="h4">{`${
userProgressBySelectedLesson[selectedStudentId]?.progress ??
'0'
}% complete`}</Typography>
</div>
</div>
<div className={styles.lessonDetail}>
<FontAwesomeV6Icon
iconName={'clipboard-check'}
iconStyle={'regular'}
/>
<div
className={classNames(
styles.lessonDetailLabelAndInfo,
numValidationLevelsCompleteString ===
COMPLETE_PERCENT_STRING && styles.greenCompletedText
)}
>
<Typography variant="overline3">Validation tests</Typography>
<Typography variant="h4">
{numValidationLevelsCompleteString}
</Typography>
</div>
</div>
<div className={styles.lessonDetail}>
<FontAwesomeV6Icon iconName={'clock'} iconStyle={'regular'} />
<div className={styles.lessonDetailLabelAndInfo}>
<Typography variant="overline3">Time spent</Typography>
<Typography variant="h4">
{userProgressBySelectedLesson[selectedStudentId]
?.timeSpent ?? ZERO_TIME_SPENT}
</Typography>
</div>
</div>
</div>
</WidgetTemplate>
)}
</div>
</div>
);
Expand Down
46 changes: 46 additions & 0 deletions apps/src/templates/studentSnapshot/studentSnapshot.module.scss
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
@import 'color.scss';
@import '@code-dot-org/component-library-styles/colors';
@import '@code-dot-org/component-library-styles/variables.scss';

.snapshotContainer {
min-width: 700px;
padding-bottom: 3rem;
}

.widgetGrid {
Expand All @@ -11,3 +14,46 @@
gap: 12px;
width: 100%;
}

.lessonDetailsWidget {
height: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0.5rem;
width: 100%;

.lessonDetail {
display: flex;
flex-direction: row;
flex-basis: 0%;
flex-grow: 1;
align-items: center;
padding: 0.5rem;
background-color: var(--background-neutral-primary);
border-radius: $regular-border-radius;
border-color: var(--borders-neutral-primary);

i {
font-size: 30px;
padding-left: 0.25rem;
padding-right: 0.75rem;
}

.lessonDetailLabelAndInfo {
display: flex;
flex-direction: column;
justify-content: center;

p {
color: var(--text-neutral-tertiary);
}
}
}
}

.greenCompletedText {
h4 {
color: var(--text-success-primary);
}
}
Loading