Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/lib/services/connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
*/

export { tutorsConnectService } from "./services/connect.svelte";
export type { TutorsId, CourseVisit, CourseSentimentId } from "./types";
export { COURSE_SENTIMENT_IDS } from "./types";
export { progressService } from "./services/progressService.svelte";
export type { TutorsConnectService, TutorsId, ProfileStore, CourseVisit, CourseSentimentId, CourseProgress } from "./types";
export { COURSE_SENTIMENT_IDS, trackableLoTypes } from "./types";
77 changes: 77 additions & 0 deletions src/lib/services/connect/services/progressService.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { browser } from "$app/environment";
import { rune } from "$lib/runes.svelte";
import { trackableLoTypes, type CourseProgress } from "../types";
import { flattenLos } from "@tutors/tutors-model-lib";
import type { Course, Lo } from "@tutors/tutors-model-lib";

export const progressService = {
visitedLos: new Map<string, Set<string>>(),
totalTrackable: new Map<string, number>(),
version: rune(0),
loadedCourses: new Set<string>(),

loadCourseProgress(course: Course): void {
if (this.loadedCourses.has(course.courseId)) return;
let visitedRoutes: string[] = [];
if (browser) {
const stored = localStorage.getItem(`loProgress_${course.courseId}`);
if (stored) {
try {
visitedRoutes = JSON.parse(stored);
} catch {}
}
}
this.visitedLos.set(course.courseId, new Set(visitedRoutes));
const allLos = flattenLos(course.los);
const total = allLos.filter((lo) => (trackableLoTypes as readonly string[]).includes(lo.type)).length;
this.totalTrackable.set(course.courseId, total);
if (browser) {
localStorage.setItem(`loProgressTotal_${course.courseId}`, String(total));
}
this.loadedCourses.add(course.courseId);
this.version.value++;
},

recordVisit(courseId: string, lo: Lo): void {
if (!(trackableLoTypes as readonly string[]).includes(lo.type)) return;
if (!lo.route) return;
let visited = this.visitedLos.get(courseId);
if (!visited) {
visited = new Set();
this.visitedLos.set(courseId, visited);
}
if (visited.has(lo.route)) return;
visited.add(lo.route);
this.version.value++;
if (browser) {
localStorage.setItem(`loProgress_${courseId}`, JSON.stringify([...visited]));
}
},

getProgress(course: Course): CourseProgress {
void this.version.value;
const total = this.totalTrackable.get(course.courseId) ?? 0;
const visited = this.visitedLos.get(course.courseId);
if (!visited || total === 0) return { visited: 0, total, percentage: 0 };
const allLos = flattenLos(course.los);
const trackable = allLos.filter((lo) => (trackableLoTypes as readonly string[]).includes(lo.type));
const visitedCount = trackable.filter((lo) => visited.has(lo.route)).length;
return { visited: visitedCount, total, percentage: Math.round((visitedCount / total) * 100) };
},

isVisited(courseId: string, loRoute: string): boolean {
void this.version.value;
return this.visitedLos.get(courseId)?.has(loRoute) ?? false;
},

clearCourseProgress(courseId: string): void {
this.visitedLos.delete(courseId);
this.totalTrackable.delete(courseId);
this.loadedCourses.delete(courseId);
if (browser) {
localStorage.removeItem(`loProgress_${courseId}`);
localStorage.removeItem(`loProgressTotal_${courseId}`);
}
this.version.value++;
}
};
8 changes: 8 additions & 0 deletions src/lib/services/connect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import type { Course, IconType } from "@tutors/tutors-model-lib";
export const COURSE_SENTIMENT_IDS = ["neutral", "fine", "delighted", "confident", "overwhelmed", "confused", "drained"] as const;
export type CourseSentimentId = (typeof COURSE_SENTIMENT_IDS)[number];

export const trackableLoTypes = ["lab", "talk", "note", "paneltalk", "panelnote", "panelvideo", "book", "tutorial", "notebook"] as const;

export type CourseProgress = {
visited: number;
total: number;
percentage: number;
};

/**
* Record of a user's interaction with a course
*/
Expand Down
25 changes: 25 additions & 0 deletions src/lib/ui/components/CourseProgressBar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import { progressService } from "$lib/services/connect";
import { currentCourse } from "$lib/runes.svelte";
import ProgressRing from "./ProgressRing.svelte";

const progress = $derived.by(() => {
if (!currentCourse.value) return null;
return progressService.getProgress(currentCourse.value);
});
</script>

{#if progress && progress.percentage > 0}
<div class="bg-surface-100 dark:bg-surface-900 mx-auto mb-2 flex w-11/12 items-center gap-3 rounded-xl px-4 py-2">
<ProgressRing percentage={progress.percentage} size={32} strokeWidth={3} />
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-baseline justify-between">
<span class="text-sm font-medium text-blue-500">{progress.percentage}% complete</span>
<span class="text-xs opacity-60">{progress.visited} of {progress.total} activities</span>
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-blue-500/20">
<div class="h-full rounded-full bg-blue-500 transition-all duration-300" style="width: {progress.percentage}%"></div>
</div>
</div>
</div>
{/if}
35 changes: 35 additions & 0 deletions src/lib/ui/components/ProgressRing.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script lang="ts">
let {
percentage = 0,
size = 32,
strokeWidth = 3
} = $props<{
percentage: number;
size?: number;
strokeWidth?: number;
}>();

const radius = $derived((size - strokeWidth) / 2);
const circumference = $derived(2 * Math.PI * radius);
const offset = $derived(circumference - (percentage / 100) * circumference);
</script>

<svg width={size} height={size} class="progress-ring" role="img" aria-label="{percentage}% complete">
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="currentColor" stroke-width={strokeWidth} class="opacity-20" />
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-dasharray={circumference}
stroke-dashoffset={offset}
stroke-linecap="round"
class="text-blue-500 transition-all duration-300"
transform="rotate(-90 {size / 2} {size / 2})"
/>
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central" class="fill-current" style="font-size: {size * 0.3}px; font-weight: 500;">
{percentage}%
</text>
</svg>
19 changes: 16 additions & 3 deletions src/lib/ui/learning-objects/layout/Card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { LoRecord } from "$lib/services/community";
import { cardStyles, type CardConfig, type CardDetails } from "$lib/services/themes";
import Icon from "$lib/ui/components/Icon.svelte";
import ProgressRing from "$lib/ui/components/ProgressRing.svelte";
import { currentCourse } from "$lib/runes.svelte";
import { themeService } from "$lib/services/themes/services/themes.svelte";
import StudentCard from "$lib/ui/time/StudentCard.svelte";
Expand Down Expand Up @@ -113,8 +114,13 @@
<div class="card-header flex">
{@render header(cardDetails)}
</div>
<div class="card-body flex flex-1 items-center justify-center">
<div class="card-body relative flex flex-1 items-center justify-center">
{@render figure(cardDetails)}
{#if cardDetails.metric}
<div class="absolute top-1/2 left-2 -translate-y-1/2">
<ProgressRing percentage={parseInt(cardDetails.metric)} size={28} strokeWidth={2.5} />
</div>
{/if}
</div>
<div class="card-footer">
{@render content(cardDetails)}
Expand All @@ -129,8 +135,11 @@
<div class="mt-8 flex flex-1 items-center justify-center">
{@render figure(cardDetails)}
</div>
<div class="mb-2">
<div class="mb-2 flex items-center gap-2">
<Icon type={cardDetails.type} height={styles.iconHeight} />
{#if cardDetails.metric}
<ProgressRing percentage={parseInt(cardDetails.metric)} size={24} strokeWidth={2} />
{/if}
</div>
</div>
{/snippet}
Expand All @@ -142,7 +151,11 @@
<div class="relative w-2/3">
{@render header(cardDetails)}
{@render content(cardDetails)}
<div class="absolute right-2 bottom-1 text-xs text-gray-400">{cardDetails.metric}</div>
{#if cardDetails.metric}
<div class="absolute right-2 bottom-1">
<ProgressRing percentage={parseInt(cardDetails.metric)} size={28} strokeWidth={2.5} />
</div>
{/if}
</div>
{/snippet}

Expand Down
21 changes: 19 additions & 2 deletions src/lib/ui/learning-objects/layout/Cards.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";

import type { Lo } from "@tutors/tutors-model-lib";
import { type Lo, flattenLos } from "@tutors/tutors-model-lib";

import Card from "$lib/ui/learning-objects/layout/Card.svelte";
import { scale } from "svelte/transition";
import { scaleTransition } from "$lib/ui/navigators/animations";
import { currentCourse } from "$lib/runes.svelte";
import { setShowHide } from "@tutors/tutors-model-lib";
import { progressService } from "$lib/services/connect";
import { trackableLoTypes } from "$lib/services/connect/types";

function getLoMetric(lo: Lo): string | undefined {
void progressService.version.value;
if (!currentCourse.value) return undefined;
if (!("los" in lo)) return undefined;
const children = flattenLos((lo as any).los as Lo[]);
const trackable = children.filter((c) => (trackableLoTypes as readonly string[]).includes(c.type));
if (trackable.length === 0) return undefined;
const visited = progressService.visitedLos.get(currentCourse.value.courseId);
if (!visited || visited.size === 0) return undefined;
const count = trackable.filter((c) => visited.has(c.route)).length;
if (count === 0) return undefined;
return String(Math.round((count / trackable.length) * 100));
}

interface Props {
los?: Lo[];
Expand Down Expand Up @@ -62,7 +78,8 @@
summary: lo.summary,
img: lo.img,
icon: lo.icon,
video: lo.video
video: lo.video,
metric: getLoMetric(lo)
}}
/>
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/ui/learning-objects/structure/Composite.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@
import Cards from "../layout/Cards.svelte";
import { themeService } from "$lib/services/themes/services/themes.svelte";
import SecondaryNavigator from "$lib/ui/navigators/SecondaryNavigator.svelte";
import CourseProgressBar from "$lib/ui/components/CourseProgressBar.svelte";

interface Props {
composite: Composite;
}
let { composite }: Props = $props();

const sideWidth = $derived(themeService.cardStyle.value === "landscape" ? "w-[64rem]" : "w-[28rem]");
const isCourseHome = $derived(composite?.type === "course");
</script>

<SecondaryNavigator lo={composite} parentCourse={composite?.parentCourse?.properties?.parent} />
{#if isCourseHome}
<CourseProgressBar />
{/if}
{#if composite?.units?.sides?.length > 0}
<div class="m-4 block justify-center md:flex">
<div class="w-full">
Expand Down
17 changes: 15 additions & 2 deletions src/lib/ui/learning-objects/structure/LoReference.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
<script lang="ts">
import type { Lo } from "@tutors/tutors-model-lib";
import Icon from "$lib/ui/components/Icon.svelte";
import Iconify from "@iconify/svelte";
import { goto } from "$app/navigation";
import { sanitizeHtml } from "$lib/utils/sanitize";
import { progressService } from "$lib/services/connect";
import { currentCourse } from "$lib/runes.svelte";

let { lo }: { lo: Lo } = $props();

const visited = $derived.by(() => {
if (!currentCourse.value) return false;
return progressService.isVisited(currentCourse.value.courseId, lo.route);
});

const handleClick = async (e: MouseEvent, href?: string) => {
e.stopPropagation();
e.preventDefault();
Expand All @@ -23,12 +31,17 @@
};
</script>

<div class="py-0.2 flex w-full leading-tight">
<a href={lo?.route} class="flex" onclick={(e) => handleClick(e, lo?.route)}>
<div class="py-0.2 flex w-full leading-tight transition-opacity duration-200 {visited ? 'opacity-100' : 'opacity-60'}">
<a href={lo?.route} class="flex items-center" onclick={(e) => handleClick(e, lo?.route)}>
<span class="shrink-0">
<Icon type={lo.type} width="20" height="20" />
</span>
<span class="mb-1 ml-2"> {@html sanitizeHtml(lo.title ?? "")} </span>
{#if visited}
<span class="ml-1 shrink-0 text-blue-500">
<Iconify icon="mdi:check-circle-outline" width="14" height="14" />
</span>
{/if}
</a>
{#if lo.video && lo.type != "panelvideo"}
<a class="ml-auto flex pl-4" href={lo.video} onclick={(e) => handleClick(e, lo?.video)}>
Expand Down
9 changes: 7 additions & 2 deletions src/routes/(course-reader)/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script lang="ts">
import CourseShell from "$lib/ui/TutorsShell.svelte";
import type { Snippet } from "svelte";
import { tutorsConnectService } from "$lib/services/connect";
import { tutorsConnectService, progressService } from "$lib/services/connect";
import { page } from "$app/state";
import { currentCourse } from "$lib/runes.svelte";
import { currentCourse, currentLo } from "$lib/runes.svelte";
import { afterNavigate } from "$app/navigation";

type Props = { children: Snippet };
Expand All @@ -18,8 +18,13 @@
if (currentCourse.value?.courseId !== lastCourseId) {
tutorsConnectService.checkWhiteList();
tutorsConnectService.courseVisit(currentCourse.value!);
progressService.loadCourseProgress(currentCourse.value!);
lastCourseId = currentCourse.value?.courseId!;
}

if (currentCourse.value && currentLo.value) {
progressService.recordVisit(currentCourse.value.courseId, currentLo.value);
}
});

afterNavigate(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/routes/(home)/CourseList.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { tutorsConnectService, type CourseVisit } from "$lib/services/connect";
import { tutorsConnectService, progressService, type CourseVisit } from "$lib/services/connect";
import { onMount } from "svelte";
import CourseVisitCard from "./CourseVisitCard.svelte";
import { t } from "$lib/services/i18n";
Expand All @@ -11,6 +11,7 @@

function deleteCourse(id: string) {
tutorsConnectService.deleteCourseVisit(id);
progressService.clearCourseProgress(id);
courseVisits = courseVisits.filter((c) => c.id !== id);
}

Expand Down
Loading
Loading