feat: show student progress heatmap on moderators dashboard

This commit is contained in:
Jannat Patel
2025-01-07 18:15:59 +05:30
parent 79177b5f5b
commit fb40b627fc
10 changed files with 286 additions and 166 deletions

View File

@@ -11,7 +11,7 @@
{{ __('Add') }} {{ __('Add') }}
</Button> </Button>
</div> </div>
<div v-if="assessments.data?.length"> <div v-if="assessments.data?.length" class="text-sm">
<ListView <ListView
:columns="getAssessmentColumns()" :columns="getAssessmentColumns()"
:rows="assessments.data" :rows="assessments.data"

View File

@@ -1,12 +1,12 @@
<template> <template>
<div class="space-y-10"> <div class="space-y-10">
<Assessments :batch="batch.data.name" />
<UpcomingEvaluations <UpcomingEvaluations
:batch="batch.data.name" :batch="batch.data.name"
:endDate="batch.data.evaluation_end_date" :endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses" :courses="batch.data.courses"
/> />
<StudentHeatmap :batch="batch.data.name" /> <Assessments :batch="batch.data.name" />
<StudentHeatmap />
</div> </div>
</template> </template>
<script setup> <script setup>

View File

@@ -8,10 +8,10 @@
<div class="grid grid-cols-3 gap-5 mb-8"> <div class="grid grid-cols-3 gap-5 mb-8">
<div class="flex items-center shadow py-2 px-3 rounded-md"> <div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3"> <div class="p-2 rounded-md bg-gray-100 mr-3">
<User class="w-18 h-18 stroke-1.5 text-gray-700" /> <User class="w-5 h-5 stroke-1.5 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="flex items-center space-x-2">
<span class="text-xl font-semibold mb-1"> <span class="font-semibold">
{{ students.data?.length }} {{ students.data?.length }}
</span> </span>
<span class="text-gray-700"> <span class="text-gray-700">
@@ -22,10 +22,10 @@
<div class="flex items-center shadow py-2 px-3 rounded-md"> <div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3"> <div class="p-2 rounded-md bg-gray-100 mr-3">
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" /> <BookOpen class="w-5 h-5 stroke-1.5 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="flex items-center space-x-2">
<span class="text-xl font-semibold mb-1"> <span class="font-semibold">
{{ batch.courses?.length }} {{ batch.courses?.length }}
</span> </span>
<span class="text-gray-700"> <span class="text-gray-700">
@@ -36,10 +36,10 @@
<div class="flex items-center shadow py-2 px-3 rounded-md"> <div class="flex items-center shadow py-2 px-3 rounded-md">
<div class="p-2 rounded-md bg-gray-100 mr-3"> <div class="p-2 rounded-md bg-gray-100 mr-3">
<ShieldCheck class="w-18 h-18 stroke-1.5 text-gray-700" /> <ShieldCheck class="w-5 h-5 stroke-1.5 text-gray-700" />
</div> </div>
<div class="flex flex-col"> <div class="flex items-center space-x-2">
<span class="text-xl font-semibold mb-1"> <span class="font-semibold">
{{ assessmentCount }} {{ assessmentCount }}
</span> </span>
<span class="text-gray-700"> <span class="text-gray-700">
@@ -48,28 +48,33 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div v-if="showProgressChart" class="mb-8">
<div class="text-gray-600 font-medium"> <div class="text-gray-600 font-medium">
{{ __('Progress') }} {{ __('Progress') }}
</div> </div>
<ApexChart <ApexChart
v-if="showProgressChart"
:options="chartOptions" :options="chartOptions"
:series="chartData" :series="chartData"
type="bar" type="bar"
height="350" height="200"
/> />
<div <div
class="flex items-center justify-center text-sm text-gray-700 space-x-4" class="flex items-center justify-center text-sm text-gray-700 space-x-4"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-3 h-3" style="background-color: #0f736b"></div> <div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.green[600] }"
></div>
<div> <div>
{{ __('Courses') }} {{ __('Courses') }}
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="w-3 h-3" style="background-color: #0070cc"></div> <div
class="w-3 h-3 rounded-sm"
:style="{ 'background-color': theme.colors.blue[600] }"
></div>
<div> <div>
{{ __('Assessments') }} {{ __('Assessments') }}
</div> </div>
@@ -147,16 +152,6 @@
<ProgressBar :progress="row[column.key]" size="sm" /> <ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div> <div class="text-xs">{{ row[column.key] }}%</div>
</div> </div>
<div
v-else-if="column.key == 'copy'"
class="invisible group-hover:visible"
>
<Button variant="ghost" @click="copyEmail(row)">
<template #icon>
<Clipboard class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
<div v-else> <div v-else>
{{ row[column.key] }} {{ row[column.key] }}
</div> </div>
@@ -221,6 +216,7 @@ import { showToast } from '@/utils'
import ProgressBar from '@/components/ProgressBar.vue' import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue' import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
import ApexChart from 'vue3-apexcharts' import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const showStudentModal = ref(false) const showStudentModal = ref(false)
const showStudentProgressModal = ref(false) const showStudentProgressModal = ref(false)
@@ -271,10 +267,6 @@ const getStudentColumns = () => {
align: 'center', align: 'center',
icon: 'clock', icon: 'clock',
}, },
{
label: '',
key: 'copy',
},
] ]
return columns return columns
@@ -357,8 +349,8 @@ const getChartData = () => {
} }
const getChartOptions = (categories) => { const getChartOptions = (categories) => {
const courseColor = '#0F736B' const courseColor = theme.colors.green[700]
const assessmentColor = '#0070CC' const assessmentColor = theme.colors.blue[700]
const maxY = const maxY =
students.data?.length % 5 students.data?.length % 5
? students.data?.length + (5 - (students.data?.length % 5)) ? students.data?.length + (5 - (students.data?.length % 5))
@@ -367,7 +359,6 @@ const getChartOptions = (categories) => {
return { return {
chart: { chart: {
type: 'bar', type: 'bar',
height: 50,
toolbar: { toolbar: {
show: false, show: false,
}, },
@@ -375,9 +366,10 @@ const getChartOptions = (categories) => {
plotOptions: { plotOptions: {
bar: { bar: {
distributed: true, distributed: true,
borderRadius: 0, borderRadius: 3,
borderRadiusApplication: 'end',
horizontal: true, horizontal: true,
barHeight: '30%', barHeight: '40%',
}, },
}, },
colors: Object.values(categories).map((item) => colors: Object.values(categories).map((item) =>
@@ -391,7 +383,7 @@ const getChartOptions = (categories) => {
}, },
rotate: 0, rotate: 0,
formatter: function (value) { formatter: function (value) {
return value.length > 20 ? `${value.substring(0, 20)}...` : value // Trim long labels return value.length > 30 ? `${value.substring(0, 30)}...` : value // Trim long labels
}, },
}, },
}, },
@@ -400,15 +392,11 @@ const getChartOptions = (categories) => {
min: 0, min: 0,
stepSize: 10, stepSize: 10,
tickAmount: maxY / 5, tickAmount: maxY / 5,
/* reversed: true */
}, },
} }
} }
const copyEmail = (row) => {
navigator.clipboard.writeText(row.email)
showToast(__('Success'), __('Email copied to clipboard'), 'check')
}
watch(students, () => { watch(students, () => {
if (students.data?.length) { if (students.data?.length) {
assessmentCount.value = Object.keys(students.data?.[0].assessments).length assessmentCount.value = Object.keys(students.data?.[0].assessments).length

View File

@@ -1,7 +1,12 @@
<template> <template>
<Dialog v-model="show" :options="{}"> <Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body> <template #body>
<div class="p-5 space-y-8 text-base"> <div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" /> <Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1"> <div class="space-y-1">
@@ -19,67 +24,68 @@
</div> </div>
</div> </div>
<!-- Assessments --> <div class="space-y-8">
<div class="space-y-4 text-sm"> <!-- Assessments -->
<div <div class="space-y-2 text-sm">
class="flex items-center border-b pb-1 text-xs text-gray-700 font-medium" <div class="flex items-center border-b pb-1 font-medium">
> <span class="flex-1">
<span class="flex-1"> {{ __('Assessment') }}
{{ __('Assessment') }} </span>
</span> <span>
<span> {{ __('Progress') }}
{{ __('Progress') }} </span>
</span> </div>
</div> <div
<div v-for="assessment in Object.keys(student.assessments)"
v-for="assessment in Object.keys(student.assessments)" class="flex items-center text-gray-700 font-medium"
class="flex items-center text-gray-700 font-medium" >
> <span class="flex-1">
<span class="flex-1"> {{ assessment }}
{{ assessment }} </span>
</span> <span v-if="isAssignment(student.assessments[assessment])">
<span v-if="isAssignment(student.assessments[assessment])"> <Badge :theme="getStatusTheme(student.assessments[assessment])">
<Badge :theme="getStatusTheme(student.assessments[assessment])"> {{ student.assessments[assessment] }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment] }} {{ student.assessments[assessment] }}
</Badge> </span>
</span> </div>
<span v-else> </div>
{{ student.assessments[assessment] }}
</span> <!-- Courses -->
<div class="space-y-2 text-sm">
<div class="flex items-center border-b pb-1 font-medium">
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-gray-700 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div> </div>
</div> </div>
<!-- Courses --> <!-- Heatmap -->
<div class="space-y-4 text-sm"> <StudentHeatmap :member="student.email" :base_days="120" />
<div
class="flex items-center text-xs text-gray-700 border-b pb-1 font-medium"
>
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-gray-700 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div> </div>
</template> </template>
</Dialog> </Dialog>
</template> </template>
<script setup> <script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui' import { Avatar, Badge, Dialog } from 'frappe-ui'
import ProgressBar from '@/components/ProgressBar.vue' import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel() const show = defineModel()
const props = defineProps({ const props = defineProps({

View File

@@ -1,52 +1,91 @@
<template> <template>
<ApexChart <div v-if="heatmap.data">
v-if="heatmap.data" <div class="text-lg font-semibold mb-2">
:options="chartOptions" {{ heatmap.data.total_activities }}
:series="chartSeries" {{
height="350" heatmap.data.total_activities > 1 ? __('activities') : __('activity')
/> }}
{{ heatmap.data }} {{ __('in the last') }}
{{ heatmap.data.weeks }}
{{ __('weeks') }}
</div>
<ApexChart :options="chartOptions" :series="chartSeries" height="240" />
</div>
</template> </template>
<script setup> <script setup>
import { createResource } from 'frappe-ui' import { createResource } from 'frappe-ui'
import { computed, inject } from 'vue' import { computed, inject, onMounted, ref, watch } from 'vue'
import ApexChart from 'vue3-apexcharts' import ApexChart from 'vue3-apexcharts'
import { theme } from '@/utils/theme'
const user = inject('$user') const user = inject('$user')
const labels = ref([])
const memberName = ref(null)
const props = defineProps({ const props = defineProps({
batch: { member: {
type: String, type: String,
required: true,
}, },
base_days: {
type: Number,
default: 200,
},
})
onMounted(() => {
memberName.value = props.member || user.data?.name
}) })
const heatmap = createResource({ const heatmap = createResource({
url: 'lms.lms.api.get_heatmap_data', url: 'lms.lms.api.get_heatmap_data',
auto: true, makeParams(values) {
cache: ['heatmap', user.data?.name], return {
member: values.member,
base_days: props.base_days,
}
},
auto: false,
cache: ['heatmap', memberName.value],
})
watch(memberName, (newVal) => {
heatmap.reload(
{
member: newVal,
},
{
onSuccess(data) {
labels.value = data.labels
},
}
)
}) })
const chartOptions = computed(() => { const chartOptions = computed(() => {
return { return {
chart: { chart: {
type: 'heatmap', type: 'heatmap',
height: 50,
toolbar: { toolbar: {
show: false, show: false,
}, },
}, },
highlightOnHover: false,
grid: {
show: false,
},
plotOptions: { plotOptions: {
heatmap: { heatmap: {
shadeIntensity: 0.5, radius: 8,
shadeIntensity: 0.2,
enableShades: true, enableShades: true,
colorScale: { colorScale: {
ranges: [ ranges: [
{ from: 0, to: 0, color: '#e3f2fd' }, // No activity { from: 0, to: 0, color: theme.colors.gray[400] },
{ from: 1, to: 5, color: '#81c784' }, // Low activity { from: 1, to: 5, color: theme.colors.green[200] },
{ from: 6, to: 15, color: '#66bb6a' }, // Medium activity { from: 6, to: 15, color: theme.colors.green[500] },
{ from: 16, to: 30, color: '#388e3c' }, // High activity { from: 16, to: 30, color: theme.colors.green[700] },
{ from: 31, to: 100, color: '#1b5e20' }, // Very high activity { from: 31, to: 100, color: theme.colors.green[800] },
], ],
}, },
}, },
@@ -56,34 +95,43 @@ const chartOptions = computed(() => {
}, },
xaxis: { xaxis: {
type: 'category', type: 'category',
labels: { rotate: -45 }, categories: labels.value,
position: 'top',
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
}, },
yaxis: { yaxis: {
type: 'category', type: 'category',
categories: [ categories: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
'Monday', reversed: true,
'Tuesday', tooltip: {
'Wednesday', enabled: false,
'Thursday', },
'Friday',
'Saturday',
'Sunday',
],
}, },
tooltip: { tooltip: {
y: { formatter: (value) => `${value} activities` }, custom: ({ series, seriesIndex, dataPointIndex, w }) => {
return `<div class="text-xs bg-gray-900 text-white font-medium p-1">
<div class="text-center">${heatmap.data.heatmap_data[seriesIndex].data[dataPointIndex].label}</div>
</div>`
},
}, },
colors: ['#008FFB'],
} }
}) })
const chartSeries = computed(() => { const chartSeries = computed(() => {
let series = [] if (!heatmap.data) return []
heatmap.data?.forEach((week) => { let series = heatmap.data.heatmap_data.map((row) => {
series.push({ return {
name: week.name, name: row.name,
data: week.data, data: row.data.map((value) => value.count),
}) }
}) })
return series return series
}) })

View File

@@ -89,10 +89,10 @@
</Tabs> </Tabs>
</div> </div>
<div class="p-5"> <div class="p-5">
<div class="text-xl font-semibold mb-2"> <div class="text-gray-700 font-semibold mb-4">
{{ batch.data.title }} {{ __('About this batch') }}:
</div> </div>
<div v-html="batch.data.description" class="leading-5 mb-2"></div> <div v-html="batch.data.description" class="leading-5 mb-4"></div>
<div class="flex items-center avatar-group overlap mb-5"> <div class="flex items-center avatar-group overlap mb-5">
<div <div

View File

@@ -28,9 +28,7 @@
size="lg" size="lg"
> >
{{ program.members }} {{ program.members }}
{{ {{ program.members == 1 ? __('member') : __('members') }}
program.members == 1 ? __(singularize('members')) : __('members')
}}
</Badge> </Badge>
<Badge <Badge
v-if="program.progress" v-if="program.progress"
@@ -133,7 +131,7 @@ import { computed, inject, onMounted, ref } from 'vue'
import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next' import { BookOpen, Edit, Plus, LockKeyhole } from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { showToast, singularize } from '@/utils' import { showToast } from '@/utils'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
const user = inject('$user') const user = inject('$user')

View File

@@ -0,0 +1,5 @@
import resolveConfig from 'tailwindcss/resolveConfig'
import tailwindConfig from 'tailwind.config.js'
export const config = resolveConfig(tailwindConfig)
export const theme = config.theme

View File

@@ -17,6 +17,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
'tailwind.config.js': path.resolve(__dirname, 'tailwind.config.js'),
}, },
}, },
build: { build: {
@@ -36,6 +37,11 @@ export default defineConfig({
}, },
}, },
optimizeDeps: { optimizeDeps: {
include: ['frappe-ui > feather-icons', 'showdown', 'engine.io-client'], include: [
'feather-icons',
'showdown',
'engine.io-client',
'tailwind.config.js',
],
}, },
}) })

View File

@@ -17,10 +17,12 @@ from frappe.utils import (
time_diff, time_diff,
now_datetime, now_datetime,
get_datetime, get_datetime,
cint,
flt, flt,
now, now,
add_days, add_days,
format_date, format_date,
days_diff,
) )
from typing import Optional from typing import Optional
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
@@ -602,7 +604,7 @@ def get_categories(doctype, filters):
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """Get members for the given search term and start index.
Args: start (int): Start index for the query. Args: start (int): Start index for the query.
search (str): Search term to filter the results. search (str): Search term to filter the results.
Returns: List of members. Returns: List of members.
""" """
@@ -1054,51 +1056,118 @@ def mark_lesson_progress(course, chapter_number, lesson_number):
@frappe.whitelist() @frappe.whitelist()
def get_heatmap_data(member=None): def get_heatmap_data(member=None, base_days=200):
if not member: if not member:
member = frappe.session.user member = frappe.session.user
last_90_days = [add_days(now(), -i) for i in range(90)] base_date, start_date, number_of_days, days = calculate_date_ranges(base_days)
date_count = {format_date(day, "YYYY-MM-dd"): 0 for day in last_90_days} date_count = initialize_date_count(days)
def count_dates(data): lesson_completions, quiz_submissions, assignment_submissions = fetch_activity_data(
for entry in data: member, start_date
date = format_date(entry.creation, "YYYY-MM-dd") )
if date in date_count: count_dates(lesson_completions, date_count)
date_count[date] += 1 count_dates(quiz_submissions, date_count)
count_dates(assignment_submissions, date_count)
heatmap_data, labels, total_activities, weeks = prepare_heatmap_data(
start_date, number_of_days, date_count
)
return {
"heatmap_data": heatmap_data,
"labels": labels,
"total_activities": total_activities,
"weeks": weeks,
}
def calculate_date_ranges(base_days):
today = format_date(now(), "YYYY-MM-dd")
day_today = get_datetime(today).strftime("%w")
padding_end = 6 - cint(day_today)
base_date = add_days(today, -base_days)
day_of_base_date = cint(get_datetime(base_date).strftime("%w"))
start_date = add_days(base_date, -day_of_base_date)
number_of_days = base_days + day_of_base_date + padding_end
days = [add_days(start_date, i) for i in range(number_of_days + 1)]
return base_date, start_date, number_of_days, days
def initialize_date_count(days):
return {format_date(day, "YYYY-MM-dd"): 0 for day in days}
def fetch_activity_data(member, start_date):
lesson_completions = frappe.get_all( lesson_completions = frappe.get_all(
"LMS Course Progress", "LMS Course Progress",
fields=["creation"], fields=["creation"],
filters={"member": member, "creation": [">", add_days(now(), -90)]}, filters={"member": member, "creation": [">=", start_date]},
) )
quiz_submissions = frappe.get_all( quiz_submissions = frappe.get_all(
"LMS Quiz Submission", "LMS Quiz Submission",
fields=["creation"], fields=["creation"],
filters={"member": member, "creation": [">", add_days(now(), -90)]}, filters={"member": member, "creation": [">=", start_date]},
) )
assigment_submissions = frappe.get_all( assignment_submissions = frappe.get_all(
"LMS Assignment Submission", "LMS Assignment Submission",
fields=["creation"], fields=["creation"],
filters={"member": member, "creation": [">", add_days(now(), -90)]}, filters={"member": member, "creation": [">=", start_date]},
) )
count_dates(lesson_completions) return lesson_completions, quiz_submissions, assignment_submissions
count_dates(quiz_submissions)
count_dates(assigment_submissions)
weekMap = {}
heatmap_data = []
for date, count in date_count.items(): def count_dates(data, date_count):
week = get_datetime(date).strftime("%U") for entry in data:
if week not in weekMap: date = format_date(entry.creation, "YYYY-MM-dd")
weekMap[week] = [] if date in date_count:
weekMap[week].append({"x": date, "y": count}) date_count[date] += 1
for week in weekMap.keys():
heatmap_data.update({"name": week, "data": weekMap[week]})
return heatmap_data def prepare_heatmap_data(start_date, number_of_days, date_count):
days_of_week = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
heatmap_data = {day: [] for day in days_of_week}
week_count = -(number_of_days // -7)
labels = [None] * week_count
last_seen_month = None
sorted_dates = sorted(date_count.keys())
for date in sorted_dates:
activity_count = date_count[date]
day_of_week = get_datetime(date).strftime("%a")
current_month = get_datetime(date).strftime("%b")
column_index = get_week_difference(start_date, date)
if 0 <= column_index < week_count:
heatmap_data[day_of_week].append(
{
"date": date,
"count": activity_count,
"label": f"{activity_count} activities on {format_date(date, 'dd MMM')}",
}
)
if last_seen_month != current_month:
labels[column_index] = current_month
last_seen_month = current_month
for (index, label) in enumerate(labels):
if not label:
labels[index] = ""
formatted_heatmap_data = [
{"name": day, "data": heatmap_data[day]} for day in days_of_week
]
total_activities = sum(date_count.values())
return formatted_heatmap_data, labels, total_activities, week_count
def get_week_difference(start_date, current_date):
diff_in_days = days_diff(current_date, start_date)
return diff_in_days // 7