From c68bfb62b52f40b6fcf4f162e3cbbb2497eda4fd Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 31 Dec 2024 17:04:07 +0100 Subject: [PATCH] feat: organisation home page with analytics (#532) --- .../components/Analytics/ActivityCard.svelte | 2 +- .../lib/components/Icons/AudienceIcon.svelte | 12 +- .../lib/components/Icons/CourseIcon.svelte | 6 +- .../src/lib/utils/translations/de.json | 27 +- .../src/lib/utils/translations/en.json | 27 +- .../src/lib/utils/translations/es.json | 27 +- .../src/lib/utils/translations/fr.json | 27 +- .../src/lib/utils/translations/hi.json | 27 +- .../src/lib/utils/translations/pt.json | 27 +- .../src/lib/utils/translations/ru.json | 27 +- .../src/lib/utils/translations/vi.json | 27 +- .../src/lib/utils/types/analytics.ts | 20 + .../src/routes/api/analytics/dash/+server.ts | 103 ++++ apps/dashboard/src/routes/lms/+page.svelte | 48 +- .../src/routes/org/[slug]/+page.svelte | 503 +++++++----------- .../20241016235827_student_analytics.sql | 5 +- .../20241231154543_dashboard_stats.sql | 79 +++ 17 files changed, 554 insertions(+), 440 deletions(-) create mode 100644 apps/dashboard/src/routes/api/analytics/dash/+server.ts create mode 100644 supabase/migrations/20241231154543_dashboard_stats.sql diff --git a/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte b/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte index 46d6f40b..fbccee03 100644 --- a/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte +++ b/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte @@ -2,7 +2,7 @@ interface Activity { icon: any; title: string; - percentage: number; + percentage: number | string; description: string; hidePercentage?: boolean; } diff --git a/apps/dashboard/src/lib/components/Icons/AudienceIcon.svelte b/apps/dashboard/src/lib/components/Icons/AudienceIcon.svelte index 5e8d6f89..3bb7c654 100644 --- a/apps/dashboard/src/lib/components/Icons/AudienceIcon.svelte +++ b/apps/dashboard/src/lib/components/Icons/AudienceIcon.svelte @@ -1,6 +1,12 @@ + + diff --git a/apps/dashboard/src/lib/components/Icons/CourseIcon.svelte b/apps/dashboard/src/lib/components/Icons/CourseIcon.svelte index c1fcb51e..fad7c44c 100644 --- a/apps/dashboard/src/lib/components/Icons/CourseIcon.svelte +++ b/apps/dashboard/src/lib/components/Icons/CourseIcon.svelte @@ -1,10 +1,12 @@ { + const analytics: OrganisationAnalytics = { + revenue: 0, + numberOfCourses: 0, + totalStudents: 0, + topCourses: [], + enrollments: [] + }; + // Run all queries in parallel + const [statsResult, topCoursesResult, enrollmentsResult] = await Promise.all([ + supabase.from('dash_org_stats').select('*').eq('org_id', orgId).single(), + + supabase + .rpc('get_dash_org_top_courses', { + org_id_arg: orgId + }) + .order('completion_percentage', { ascending: false }), + + supabase.rpc('get_dash_org_recent_enrollments', { + org_id_arg: orgId + }) + ]); + + // Check for errors + if (statsResult.error) { + console.error(statsResult.error); + throw new Error('Failed to fetch organisation analytics'); + } + if (topCoursesResult.error) { + console.error(topCoursesResult.error); + throw new Error('Failed to fetch top courses'); + } + if (enrollmentsResult.error) { + console.error(enrollmentsResult.error); + throw new Error('Failed to fetch enrollments'); + } + + // Set analytics data + analytics.revenue = 0; // Hardcode until we implement payment + analytics.numberOfCourses = statsResult.data.no_of_courses; + analytics.totalStudents = statsResult.data.enrolled_students; + + analytics.topCourses = topCoursesResult.data.map((course) => ({ + id: course.course_id, + title: course.course_title, + enrollments: course.total_students, + completion: course.completion_percentage + })); + + analytics.enrollments = enrollmentsResult.data.map((enrollment) => ({ + id: enrollment.profile_id, + avatarUrl: enrollment.avatar_url, + name: enrollment.fullname, + courseId: enrollment.course_id, + course: enrollment.course_title, + date: enrollment.enrolled_at + })); + + return analytics; +} diff --git a/apps/dashboard/src/routes/lms/+page.svelte b/apps/dashboard/src/routes/lms/+page.svelte index bab6de34..7a06473d 100644 --- a/apps/dashboard/src/routes/lms/+page.svelte +++ b/apps/dashboard/src/routes/lms/+page.svelte @@ -1,16 +1,16 @@
-
-

+
+

{$t(getGreeting())} {$profile.fullname}!

@@ -63,10 +63,10 @@
-

+

{$currentOrg.customization.dashboard.bannerText ? $currentOrg.customization.dashboard.bannerText : $t('dashboard.lms_dashboard_hero')} @@ -82,28 +82,28 @@ ? $currentOrg.customization.dashboard.bannerImage : '/images/student-learning.svg'} alt="student Learning Pictogram" - class="w-28 md:block md:w-1/3 lg:w-[275px] lg:h-[205px] mb-3 md:mb-0" + class="mb-3 w-28 md:mb-0 md:block md:w-1/3 lg:h-[205px] lg:w-[275px]" />

-
-
+
+
-
-

+

+

{$t('dashboard.your_progress')}

student Learning score
-

+

{$t('dashboard.your_progress')}

{#if totalLessons > 0} @@ -113,12 +113,12 @@

{:else}

- {$t('dashboard.No_courses_started')} + {$t('dashboard.no_courses_started')}

{/if}

{progressPercentage} %

diff --git a/apps/dashboard/src/routes/org/[slug]/+page.svelte b/apps/dashboard/src/routes/org/[slug]/+page.svelte index 128b14c9..c7c7aa7b 100644 --- a/apps/dashboard/src/routes/org/[slug]/+page.svelte +++ b/apps/dashboard/src/routes/org/[slug]/+page.svelte @@ -1,182 +1,96 @@ @@ -186,7 +100,7 @@
-
+

{$t(getGreeting())} {$profile.fullname}! @@ -209,194 +123,143 @@

-
- -

- {$t('dashboard.hero_content')} -

- goto(`${$currentOrgPath}/courses`)} - /> -
- student Learning Pictogram +
+ +
+ {#each cards as card} + {#if !dashAnalytics} + + {:else} + + {/if} + {/each} +
+
- +
+
+

+ {$t('dashboard.top_courses')} +

-
- -
-

{$t('dashboard.your_schedule')}

-
- {#if !$isMobile} -
- -
+
+ {#if !dashAnalytics} + {#each Array(5) as _} + + {/each} {:else} -
- - - -
- {/if} - -
- {#if !$isMobile} -

- {formatDate($store?.selected)} -

- {/if} - {#each selectedDateLessonData as lessonData} -
-
- - {lessonData.lesson_title} - {#if lessonData.is_unlocked} - - - - {:else} - - {/if} - -

- - {` ${lessonData.course_title}`} - + {#each dashAnalytics.topCourses as course} +

+
+ +

{course.title}

+ +

+ {course.enrollments} + {$t('dashboard.students')}

- {#if lessonData.is_unlocked} - - {$t('dashboard.join_call')} - - {/if} +
+ +
+ {course.completion}% + {$t('dashboard.completion')} +
+
{:else} -

- {$t('dashboard.no_schedule')} -

+
+
+ +
+
+

+ {$t('dashboard.create_first_course')} +

+

+ {$t('dashboard.create_first_course_description')} +

+
+ +
{/each} -
+ {/if}
- +
+

+ {$t('dashboard.recent_enrollments')} +

- +
- - diff --git a/supabase/migrations/20241016235827_student_analytics.sql b/supabase/migrations/20241016235827_student_analytics.sql index c3d44bdb..19bead1f 100644 --- a/supabase/migrations/20241016235827_student_analytics.sql +++ b/supabase/migrations/20241016235827_student_analytics.sql @@ -52,11 +52,12 @@ CREATE OR REPLACE FUNCTION public.insert_login_event_on_user_session_update() SECURITY DEFINER AS $function$ BEGIN - IF (NEW.updated_at IS NOT NULL) THEN + IF (NEW.updated_at IS NOT NULL AND NEW.updated_at != OLD.updated_at) THEN INSERT INTO public.analytics_login_events (logged_in_at, user_id) VALUES (NEW.updated_at, NEW.user_id) ON CONFLICT (user_id) DO UPDATE - SET logged_in_at = NEW.updated_at; + SET logged_in_at = EXCLUDED.logged_in_at + WHERE analytics_login_events.logged_in_at < EXCLUDED.logged_in_at; END IF; RETURN NEW; END; diff --git a/supabase/migrations/20241231154543_dashboard_stats.sql b/supabase/migrations/20241231154543_dashboard_stats.sql new file mode 100644 index 00000000..95f89783 --- /dev/null +++ b/supabase/migrations/20241231154543_dashboard_stats.sql @@ -0,0 +1,79 @@ +set check_function_bodies = off; + +create or replace view "public"."dash_org_stats" as SELECT gp.organization_id AS org_id, + count(DISTINCT course.id) AS no_of_courses, + count(DISTINCT gm.profile_id) AS enrolled_students + FROM ((course + JOIN "group" gp ON ((gp.id = course.group_id))) + LEFT JOIN groupmember gm ON (((gm.group_id = gp.id) AND (gm.role_id = 3)))) + WHERE (course.status = 'ACTIVE'::text) + GROUP BY gp.organization_id; + + +CREATE OR REPLACE FUNCTION public.get_dash_org_recent_enrollments(org_id_arg uuid) + RETURNS TABLE(profile_id uuid, avatar_url text, fullname text, course_id uuid, course_title character varying, enrolled_at timestamp with time zone) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY + SELECT + p.id, + p.avatar_url, + p.fullname, + course.id, + course.title, + gm.created_at as enrolled_at + FROM + course + JOIN "group" as gp ON gp.id = course.group_id + LEFT JOIN groupmember as gm ON gm.group_id = gp.id AND gm.role_id = 3 + JOIN profile as p ON p.id = gm.profile_id + WHERE + course.status = 'ACTIVE' + AND gp.organization_id = org_id_arg + GROUP BY + p.id, + course.id, + course.title, + enrolled_at + ORDER BY + enrolled_at DESC + LIMIT 5; +END; +$function$ +; + +CREATE OR REPLACE FUNCTION public.get_dash_org_top_courses(org_id_arg uuid) + RETURNS TABLE(course_id uuid, course_title character varying, total_students integer, completion_percentage integer) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY +SELECT + course_stats.course_id, + course_stats.course_title, + course_stats.total_students::integer, + CASE + WHEN course_stats.total_students * course_stats.total_lessons = 0 THEN 0 + ELSE ROUND((course_stats.completed_lessons::numeric / (course_stats.total_students * course_stats.total_lessons)) * 100)::integer + END as completion_percentage + FROM ( + SELECT + c.id as course_id, + c.title as course_title, + COUNT(DISTINCT gm.id) as total_students, + COUNT(DISTINCT l.id) as total_lessons, + COUNT(DISTINCT lc.id) FILTER (WHERE lc.is_complete = true) as completed_lessons + FROM course c + JOIN "group" g ON g.id = c.group_id + LEFT JOIN groupmember gm ON gm.group_id = g.id and gm.role_id = 3 + LEFT JOIN lesson l ON l.course_id = c.id + LEFT JOIN lesson_completion lc ON lc.lesson_id = l.id + AND lc.profile_id = gm.profile_id + WHERE c.status = 'ACTIVE' AND g.organization_id = org_id_arg + GROUP BY c.id, c.title + ) as course_stats + LIMIT 5; +END; +$function$ +;