Skip to content

Commit a1eb234

Browse files
Feature: Added student activity chart to details dashboard. (#537)
* Feature: Added student activity chart to details dashboard. Co-authored-by: Newton Chung <[email protected]> * Fix: Changed coloration for StudentActivityChart and key. Co-authored-by: Newton Chung <[email protected]> * Fix(UI): Removed notation for no certification from studentdashboard display. * Added mobile devide compatibility and corrected month label display. Co-authored-by: Newton Chung <[email protected]> * Fixed Day Labels spacing * Improved readability of Student Activity Chart and fixed minor spacing per request. Co-authored-by: Newton Chung <[email protected]> --------- Co-authored-by: Newton Chung <[email protected]> Co-authored-by: Newton Chung <[email protected]> Co-authored-by: Newton Chung <[email protected]>
1 parent 9dfe994 commit a1eb234

File tree

4 files changed

+404
-3
lines changed

4 files changed

+404
-3
lines changed

components/DetailsDashboard.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import React from 'react';
22
import styles from './DetailsCSS.module.css';
33
import DetailsDashboardList from './DetailsDashboardList';
4-
//getStudentProgressInSuperblock
5-
6-
import { getStudentProgressInSuperblock } from '../util/api_proccesor';
4+
import {
5+
getStudentProgressInSuperblock,
6+
extractFilteredCompletionTimestamps
7+
} from '../util/api_proccesor';
8+
import StudentActivityChart from './StudentActivityChart';
79

810
export default function DetailsDashboard(props) {
911
const printSuperblockTitle = individualSuperblockJSON => {
@@ -23,8 +25,17 @@ export default function DetailsDashboard(props) {
2325
);
2426
};
2527

28+
const selectedSuperblocks = props.superblocksDetailsJSONArray.map(
29+
arrayOfBlockObjs => arrayOfBlockObjs[0].superblock
30+
);
31+
const filteredCompletionTimestamps = extractFilteredCompletionTimestamps(
32+
props.studentData.certifications,
33+
selectedSuperblocks
34+
);
35+
2636
return (
2737
<>
38+
<StudentActivityChart timestamps={filteredCompletionTimestamps} />
2839
{props.superblocksDetailsJSONArray.map((arrayOfBlockObjs, idx) => {
2940
let index = props.superblocksDetailsJSONArray.indexOf(arrayOfBlockObjs);
3041
let superblockDashedName =

components/StudentActivityChart.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import React, { useEffect, useState } from 'react';
2+
import styles from './StudentActivityChart.module.css';
3+
4+
// Display only Mon, Wed, Fri labels to reduce visual clutter (like GitHub)
5+
const daysOfWeek = ['Mon', 'Wed', 'Fri'];
6+
7+
// Function to return all student activity in the past year into a dictionary to reference
8+
const generateActivityData = timestamps => {
9+
const oneYearAgo = new Date();
10+
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
11+
// Creating a map of date strings to activity counts to house the chart array data
12+
const activityData = {};
13+
// Initialize all dates within the past year with normalized keys in the format YYYY-MM-DD in activity 0
14+
timestamps.forEach(timestamp => {
15+
const date = new Date(timestamp);
16+
if (date >= oneYearAgo) {
17+
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
18+
2,
19+
'0'
20+
)}-${String(date.getDate()).padStart(2, '0')}`;
21+
activityData[key] = (activityData[key] || 0) + 1;
22+
}
23+
});
24+
return activityData;
25+
};
26+
27+
const activityLevels = ['#3b3b4f', '#99c9ff'];
28+
29+
// Helper function to determine color based on activity count
30+
const getColor = count => {
31+
if (count > 0) return activityLevels[1];
32+
return activityLevels[0];
33+
};
34+
35+
// Helper function to get the date exactly one year prior
36+
const getPreviousYearDate = date => {
37+
const previousYearDate = new Date(date);
38+
previousYearDate.setFullYear(date.getFullYear() - 1);
39+
return previousYearDate;
40+
};
41+
42+
// Main component
43+
const StudentActivityChart = ({ timestamps }) => {
44+
const [weeks, setWeeks] = useState([]);
45+
const [activityData, setActivityData] = useState({});
46+
47+
// Updates the activity data dictionary with the timestamps for the student user activity
48+
useEffect(() => {
49+
const Data = generateActivityData(timestamps);
50+
setActivityData(Data);
51+
}, [timestamps]);
52+
53+
useEffect(() => {
54+
const today = new Date();
55+
const startDate = getPreviousYearDate(today);
56+
57+
// Increment today to the next day to include today's activity
58+
today.setDate(today.getDate() + 1);
59+
60+
/*
61+
Build weeks array for the chart.
62+
The total weeks is 54 with the first week and last week being special
63+
as they may not contain a full 7 days of data.
64+
*/
65+
const weeks = [];
66+
let firstWeek = [];
67+
const startDay = startDate.getDay();
68+
69+
// Fill the first week with the correct dates
70+
for (let i = 0; i < startDay; i++) {
71+
firstWeek.push({ date: null, count: 0 });
72+
}
73+
74+
// Initializing a check to stop adding weeks once we reach today's date
75+
let chart_cutoff = false;
76+
77+
// If we're on the first week use the pre-filled firstWeek array otherwise we are starting fresh
78+
for (let i = 0; i < 54; i++) {
79+
let week;
80+
if (i === 0) {
81+
week = firstWeek;
82+
} else {
83+
week = [];
84+
}
85+
86+
// If we've already reached today's date, stop adding more weeks
87+
if (chart_cutoff) {
88+
break;
89+
}
90+
// Fill in the days for the current week
91+
for (let j = week.length; j < 7; j++) {
92+
const date = new Date(startDate);
93+
date.setDate(startDate.getDate() + i * 7 + j - startDay);
94+
// Trigger flag to stop adding weeks once we reach today's date
95+
if (date.toDateString() === today.toDateString()) {
96+
chart_cutoff = true;
97+
break;
98+
}
99+
// Create a normalized date string in YYYY-MM-DD format accommodating for getMonth starting at 0 and leading zeros
100+
// Using a generated key for activityData lookup
101+
const key = `${date.getFullYear()}-${String(
102+
date.getMonth() + 1
103+
).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
104+
// Add the day to the week array
105+
week.push({ date, count: activityData[key] || 0 });
106+
}
107+
// Add the completed week to the weeks array
108+
weeks.push(week);
109+
}
110+
// Update the weeks state
111+
setWeeks(weeks);
112+
}, [activityData]);
113+
114+
return (
115+
<div className={styles.parentContainer}>
116+
<div className={styles.chartContainer}>
117+
<div className={styles.chartHeader}>
118+
<h3 className={styles.contributionsTotal}>
119+
{Object.keys(activityData).length} contributions in the last year
120+
</h3>
121+
</div>
122+
<div className={styles.chart}>
123+
<div className={styles.dayLabels}>
124+
{daysOfWeek.map((day, index) => (
125+
<div key={index} className={styles.dayLabel}>
126+
{day}
127+
</div>
128+
))}
129+
</div>
130+
131+
<div className={styles.scrollableContainer}>
132+
<div className={styles.monthLabels}>
133+
{weeks.map((week, index) => {
134+
// Find the day in the week that is the first of the month
135+
const firstDay = week.find(
136+
d => d && d.date && d.date.getDate() === 1
137+
);
138+
139+
if (firstDay) {
140+
return (
141+
<div key={index} className={styles.monthLabel}>
142+
{firstDay.date.toLocaleString('default', {
143+
month: 'short'
144+
})}
145+
</div>
146+
);
147+
}
148+
149+
// If this is the very first week, fall back to the first available day
150+
if (index === 0) {
151+
const firstDay = week.find(d => d && d.date);
152+
if (firstDay) {
153+
return (
154+
<div key={index} className={styles.monthLabel}>
155+
{firstDay.date.toLocaleString('default', {
156+
month: 'short'
157+
})}
158+
</div>
159+
);
160+
}
161+
}
162+
163+
return <div key={index} className={styles.monthLabel}></div>;
164+
})}
165+
</div>
166+
167+
<div className={styles.grid}>
168+
{weeks.map((week, index) => (
169+
<div key={index} className={styles.week}>
170+
{week.map((day, index) =>
171+
day.date ? (
172+
<div
173+
key={index}
174+
className={styles.day}
175+
style={{ backgroundColor: getColor(day.count) }}
176+
title={`${day.date.toDateString()}: ${
177+
day.count
178+
} completions`}
179+
></div>
180+
) : (
181+
<div key={index} className={styles.dayEmpty}></div>
182+
)
183+
)}
184+
</div>
185+
))}
186+
</div>
187+
</div>
188+
</div>
189+
190+
<div className={styles.legend}>
191+
<span>Inactive</span>
192+
<div
193+
className={styles.legendColor}
194+
style={{ backgroundColor: activityLevels[0] }}
195+
></div>
196+
<span>Active</span>
197+
<div
198+
className={styles.legendColor}
199+
style={{ backgroundColor: activityLevels[1] }}
200+
></div>
201+
</div>
202+
</div>
203+
</div>
204+
);
205+
};
206+
207+
export default StudentActivityChart;
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
.parentContainer {
2+
display: flex;
3+
justify-content: center;
4+
align-items: center;
5+
width: 100%;
6+
}
7+
8+
.chartContainer {
9+
border: 1px solid #444;
10+
border-radius: 8px;
11+
padding: 15px;
12+
margin: 10px;
13+
color: #f5f6f7;
14+
background-color: #1b1b32;
15+
font-family: 'Roboto Mono', monospace;
16+
max-width: 100%;
17+
box-sizing: border-box;
18+
}
19+
20+
.chartHeader {
21+
display: flex;
22+
justify-content: space-between;
23+
align-items: center;
24+
width: 100%;
25+
color: #f5f6f7;
26+
}
27+
28+
.contributionsTotal {
29+
text-align: left;
30+
font-size: 14px;
31+
margin-bottom: 10px;
32+
font-weight: bold;
33+
}
34+
35+
.chart {
36+
display: flex;
37+
flex-direction: row;
38+
align-items: flex-start;
39+
justify-content: flex-start;
40+
}
41+
42+
.dayLabels {
43+
display: flex;
44+
flex-direction: column;
45+
margin-right: 5px;
46+
margin-top: 45px;
47+
font-size: 10px;
48+
color: #f5f6f7;
49+
flex-shrink: 0;
50+
}
51+
52+
.dayLabel {
53+
height: 44px;
54+
text-align: right;
55+
padding-right: 5px;
56+
}
57+
58+
.scrollableContainer {
59+
overflow-x: auto;
60+
overflow-y: hidden;
61+
max-width: calc(100vw - 150px);
62+
}
63+
64+
.monthLabels {
65+
display: grid;
66+
grid-template-columns: repeat(54, 20px);
67+
gap: 2px;
68+
font-size: 10px;
69+
font-family: 'Roboto Mono', monospace;
70+
color: #f5f6f7;
71+
margin-bottom: 5px;
72+
min-width: min-content;
73+
}
74+
75+
.monthLabel {
76+
text-align: center;
77+
width: 20px;
78+
}
79+
80+
.grid {
81+
display: grid;
82+
grid-template-columns: repeat(54, 20px);
83+
gap: 2px;
84+
min-width: min-content;
85+
}
86+
87+
.week {
88+
display: grid;
89+
grid-template-rows: repeat(7, 20px);
90+
gap: 2px;
91+
}
92+
93+
.day {
94+
width: 20px;
95+
height: 20px;
96+
border-radius: 3px;
97+
border: 1px solid #0a0a23;
98+
}
99+
100+
.dayEmpty {
101+
width: 20px;
102+
height: 20px;
103+
}
104+
105+
.legend {
106+
display: flex;
107+
align-items: center;
108+
justify-content: flex-end;
109+
width: 100%;
110+
margin-top: 10px;
111+
font-size: 10px;
112+
color: #f5f6f7;
113+
}
114+
115+
.legendColor {
116+
width: 12px;
117+
height: 12px;
118+
margin: 0 2px;
119+
border-radius: 3px;
120+
border: 1px solid #ddd;
121+
}
122+
123+
/* Mobile adjustments */
124+
@media (max-width: 768px) {
125+
.scrollableContainer {
126+
max-width: calc(100vw - 120px);
127+
}
128+
129+
.chartContainer {
130+
padding: 10px;
131+
margin: 5px;
132+
}
133+
}
134+
135+
@media (max-width: 480px) {
136+
.scrollableContainer {
137+
max-width: calc(100vw - 100px);
138+
}
139+
140+
.chartContainer {
141+
padding: 8px;
142+
margin: 5px 0;
143+
}
144+
145+
.dayLabel {
146+
font-size: 9px;
147+
}
148+
149+
.legend {
150+
font-size: 9px;
151+
}
152+
}

0 commit comments

Comments
 (0)