Skip to content

Commit 22d218d

Browse files
authored
Implement organise dashboard by lesson (#9)
* Implement organise dashboard by lesson * Clean up code and improve external links * Add sorting for exercises for more deterministic behaviour * Improve data structure of Exercise * Add constants index.ts * Address comments
1 parent d1c2061 commit 22d218d

File tree

7 files changed

+197
-98
lines changed

7 files changed

+197
-98
lines changed

src/api/queries/get_exercises.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,76 @@
1+
import { EXERCISES_DIRECTORY_URL } from "@/constants";
2+
import { DetourInfo, Exercise, LessonInfo } from "@/types/exercise";
13
import axios from "axios"
24
import { useQuery } from "react-query"
35

4-
export interface Exercise {
5-
exercise_name: string;
6-
tags: string[];
6+
export interface ExerciseRaw {
7+
lesson?: LessonInfo;
8+
detour?: DetourInfo;
9+
identifier: string;
10+
wip?: number;
711
}
812

9-
export const getExercises = async () => {
13+
type ExercisesResponse = Record<string, ExerciseRaw>;
14+
15+
/**
16+
* Parses a lesson title like to extract tour (T) and lesson (L) numbers.
17+
*/
18+
function parseExerciseOrder(title: string): { tour: number; lesson: number } | null {
19+
const match = title.match(/^T(\d+)L(\d+)/);
20+
if (!match) {
21+
return null;
22+
}
23+
return {
24+
tour: parseInt(match[1], 10),
25+
lesson: parseInt(match[2], 10),
26+
};
27+
}
28+
29+
/**
30+
* Compares two exercises for sorting: by tour (T) first, then by lesson (L).
31+
*/
32+
function compareExercises(a: Exercise, b: Exercise): number {
33+
const orderA = parseExerciseOrder(a.parentLesson.title);
34+
const orderB = parseExerciseOrder(b.parentLesson.title);
35+
36+
if (orderA == null && orderB == null) return 0;
37+
if (orderA == null) return 1;
38+
if (orderB == null) return -1;
39+
40+
if (orderA.tour !== orderB.tour) {
41+
return orderA.tour - orderB.tour;
42+
}
43+
return orderA.lesson - orderB.lesson;
44+
}
45+
46+
export const getExercises = async (): Promise<Exercise[]> => {
1047
try {
11-
const result = await axios.get<Exercise[]>("https://raw.githubusercontent.com/git-mastery/exercises/refs/heads/gh-pages/exercises.json");
12-
return result.data;
48+
const result = await axios.get<ExercisesResponse>(
49+
`${EXERCISES_DIRECTORY_URL}/exercises.json`
50+
);
51+
52+
const exercises: Exercise[] = [];
53+
for (const [key, value] of Object.entries(result.data)) {
54+
// Filter WIP exercises at API layer since they're not meant to be visible
55+
// This is more performant since we only filter once at fetch time, not on every render
56+
if (value?.wip === 1) {
57+
continue;
58+
}
59+
60+
const lesson = value.lesson ?? value.detour?.lesson;
61+
if (!lesson) {
62+
continue;
63+
}
64+
65+
exercises.push({
66+
key,
67+
identifier: value.identifier,
68+
parentLesson: lesson,
69+
detour: value.detour,
70+
});
71+
}
72+
73+
return exercises.sort(compareExercises);
1374
} catch {
1475
return [];
1576
}
@@ -21,4 +82,3 @@ export const useGetExercisesQuery = () => {
2182
queryFn: () => getExercises(),
2283
});
2384
}
24-

src/components/dashboard/ExerciseGroupTable.tsx

Lines changed: 0 additions & 55 deletions
This file was deleted.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { BASE_URL, ExerciseStatus } from "@/constants";
2+
import { Exercise } from "@/types/exercise";
3+
4+
interface ExerciseTableProps {
5+
exercises: Exercise[];
6+
progress: Map<string, string>;
7+
}
8+
9+
function getStatusDisplay(status: string | undefined): { text: string; icon: string } {
10+
switch (status) {
11+
case undefined: // Needed for TypeScript strictness
12+
case null:
13+
return { text: "Not Started", icon: "⚪" };
14+
case ExerciseStatus.COMPLETED:
15+
case ExerciseStatus.SUCCESSFUL: // SUCCESSFUL is for backwards compatibility
16+
return { text: "Completed", icon: "✅" };
17+
case ExerciseStatus.INCOMPLETE:
18+
return { text: "Incomplete", icon: "⏳" };
19+
case ExerciseStatus.ERROR:
20+
return { text: "Error", icon: "❌" };
21+
default:
22+
return { text: status, icon: "🔄" };
23+
}
24+
}
25+
26+
function ExerciseContextLabel({ exercise }: { exercise: Exercise }) {
27+
const linkClass = "text-blue-600 hover:underline";
28+
const lessonLink = `${BASE_URL}/${exercise.parentLesson.path}`;
29+
30+
if (exercise.detour) {
31+
const detourLink = `${BASE_URL}/${exercise.detour.lesson.path}`;
32+
return (
33+
<span className="text-gray-500 text-sm ml-2">
34+
(in <a href={detourLink} target="_blank" rel="noopener noreferrer" className={linkClass}>
35+
{exercise.parentLesson.title} &#8594; Detour: {exercise.detour.title}
36+
</a>)
37+
</span>
38+
);
39+
}
40+
return (
41+
<span className="text-gray-500 text-sm ml-2">
42+
(in <a href={lessonLink} target="_blank" rel="noopener noreferrer" className={linkClass}>
43+
{exercise.parentLesson.title}
44+
</a>)
45+
</span>
46+
);
47+
}
48+
49+
function ExerciseTable({ exercises, progress }: ExerciseTableProps) {
50+
return (
51+
<table className="table-fixed w-full bg-white border border-gray-300 rounded-sm">
52+
<thead>
53+
<tr>
54+
<th className="bg-gray-200 border border-gray-300 px-4 py-2 text-left">
55+
Exercise
56+
</th>
57+
<th className="bg-gray-200 border border-gray-300 px-4 py-2 text-left w-40">
58+
Status
59+
</th>
60+
</tr>
61+
</thead>
62+
<tbody>
63+
{exercises.map((exercise) => {
64+
const status = getStatusDisplay(progress.get(exercise.identifier));
65+
const lessonPath = exercise.detour?.lesson.path ?? exercise.parentLesson.path;
66+
return (
67+
<tr key={exercise.key}>
68+
<td className="border border-gray-300 px-4 py-2 text-left">
69+
<a
70+
target="_blank"
71+
rel="noopener noreferrer"
72+
href={`${BASE_URL}/${lessonPath}/exercise-${exercise.identifier}`}
73+
>
74+
<code className="underline text-blue-800">{exercise.identifier}</code>
75+
</a>
76+
<ExerciseContextLabel exercise={exercise} />
77+
</td>
78+
<td className="border border-gray-300 px-4 py-2 text-left">
79+
<span className="mr-2">{status.icon}</span>
80+
{status.text}
81+
</td>
82+
</tr>
83+
);
84+
})}
85+
</tbody>
86+
</table>
87+
);
88+
}
89+
90+
export default ExerciseTable;

src/components/dashboard/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export { default as DashboardHeader } from "./DashboardHeader";
22
export { default as StatusMessage } from "./StatusMessage";
3-
export { default as ExerciseGroupTable } from "./ExerciseGroupTable";
3+
export { default as ExerciseTable } from "./ExerciseTable";

src/constants/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const BASE_URL = "https://git-mastery.github.io";
2+
3+
export const EXERCISES_DIRECTORY_URL = `${BASE_URL}/exercises-directory`;
4+
5+
export const ExerciseStatus = {
6+
SUCCESSFUL: "SUCCESSFUL",
7+
COMPLETED: "Completed",
8+
INCOMPLETE: "Incomplete",
9+
ERROR: "Error",
10+
} as const;

src/pages/dashboard/(username)/index.tsx

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { Exercise, useGetExercisesQuery } from "@/api/queries/get_exercises";
1+
import { useGetExercisesQuery } from "@/api/queries/get_exercises";
22
import { useGetUserQuery } from "@/api/queries/get_user";
33
import { useGetUserProgressQuery } from "@/api/queries/get_user_progress";
44
import {
55
DashboardHeader,
6-
ExerciseGroupTable,
6+
ExerciseTable,
77
StatusMessage,
88
} from "@/components/dashboard";
9+
import { EXERCISES_DIRECTORY_URL, ExerciseStatus } from "@/constants";
910
import Spinner from "@/components/ui/Spinner";
1011
import { useCallback, useMemo } from "react";
1112
import { useParams } from "react-router";
@@ -34,9 +35,9 @@ function DashboardPage() {
3435
if (!progress.has(up.exercise_name)) {
3536
progress.set(up.exercise_name, up.status);
3637
} else if (
37-
(progress.get(up.exercise_name) !== "SUCCESSFUL" ||
38-
progress.get(up.exercise_name) !== "Completed") &&
39-
(up.status === "SUCCESSFUL" || up.status === "Completed")
38+
(progress.get(up.exercise_name) !== ExerciseStatus.COMPLETED &&
39+
progress.get(up.exercise_name) !== ExerciseStatus.SUCCESSFUL) &&
40+
(up.status === ExerciseStatus.SUCCESSFUL || up.status === ExerciseStatus.COMPLETED)
4041
) {
4142
// Take any success
4243
progress.set(up.exercise_name, up.status);
@@ -48,27 +49,6 @@ function DashboardPage() {
4849

4950
const { data: exercises, isLoading: isProblemSetsLoading } = useGetExercisesQuery();
5051

51-
const exerciseGroups = useMemo(() => {
52-
if (isProblemSetsLoading || exercises == null) {
53-
return new Map<string, Exercise[]>();
54-
}
55-
56-
const repoGroups = new Map<string, Exercise[]>();
57-
for (const exercise of exercises) {
58-
for (const tag of exercise.tags) {
59-
if (parsedUserProgress.has(exercise.exercise_name)) {
60-
if (!repoGroups.has(tag)) {
61-
repoGroups.set(tag, []);
62-
}
63-
repoGroups.get(tag)!.push(exercise);
64-
}
65-
}
66-
}
67-
68-
const sortedGroups = new Map([...repoGroups.entries()].sort());
69-
return sortedGroups;
70-
}, [exercises, isProblemSetsLoading, parsedUserProgress]);
71-
7252
const refreshUserProgress = useCallback(async () => {
7353
await refetchUserProgress();
7454
}, [refetchUserProgress]);
@@ -118,28 +98,26 @@ function DashboardPage() {
11898
);
11999
}
120100

121-
if (exerciseGroups.size === 0) {
101+
if (exercises == null || exercises.length === 0) {
122102
return (
123103
<StatusMessage
124104
buttonText="Go to exercises directory ↗"
125-
buttonHref="https://git-mastery.github.io/exercises-directory"
105+
buttonHref={EXERCISES_DIRECTORY_URL}
126106
variant="primary"
127107
external
128108
>
129-
<p>You have not completed any exercises yet</p>
109+
<p>No exercises available</p>
130110
</StatusMessage>
131111
);
132112
}
133113

134-
return Array.from(exerciseGroups).map(([groupName, exercises]) => (
135-
<ExerciseGroupTable
136-
key={groupName}
137-
groupName={groupName}
114+
return (
115+
<ExerciseTable
138116
exercises={exercises}
139117
progress={parsedUserProgress}
140118
/>
141-
));
142-
}, [isLoading, user, userProgress, exerciseGroups, parsedUserProgress, username]);
119+
);
120+
}, [isLoading, user, userProgress, exercises, parsedUserProgress, username]);
143121

144122
return (
145123
<div className="lg:w-[40%] my-16 mx-auto md:w-[60%] w-[80%]">

src/types/exercise.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface LessonInfo {
2+
path: string;
3+
title: string;
4+
}
5+
6+
export interface DetourInfo {
7+
lesson: LessonInfo;
8+
title: string;
9+
}
10+
11+
export interface Exercise {
12+
key: string;
13+
identifier: string;
14+
parentLesson: LessonInfo;
15+
detour?: DetourInfo;
16+
}

0 commit comments

Comments
 (0)