Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3ad0c12
feat(admin): Implement administration dashboard for assignment manage…
WeishuZ Nov 21, 2025
bf5eee3
fixed the potential problem
WeishuZ Nov 21, 2025
94b6a57
removed some unused console log and fixed the potential issues
WeishuZ Nov 21, 2025
782127d
refine the comments
WeishuZ Nov 21, 2025
d9483a3
fixed the bug:remove the mailSend status
WeishuZ Nov 21, 2025
27038d5
refactor(admin): Clean up console logs and improve admin route response
WeishuZ Nov 22, 2025
e9c94a7
fixed the bug: cannot see admin page in navBar
WeishuZ Dec 10, 2025
c29537b
Merge branch 'main' into gv-10/introduce-admin-page
WeishuZ Dec 10, 2025
06ba568
add `make dev-local` to run the program locally
WeishuZ Dec 10, 2025
ee93511
Merge branch 'gv-10/introduce-admin-page' of github.com:AFA-Tooling/G…
WeishuZ Dec 10, 2025
171e1cf
introduced the summary button
WeishuZ Dec 10, 2025
4981a68
Improved the logic for data retriving
WeishuZ Dec 10, 2025
022191b
introduce the interactivity for the student table: control the displa…
WeishuZ Dec 10, 2025
127c047
fixed the display issue
WeishuZ Dec 10, 2025
a6d7aff
delete invalid icon file
WeishuZ Dec 11, 2025
ac65b08
fixed the config issue when using docker
WeishuZ Dec 11, 2025
b60243d
Potential fix for pull request finding 'Unused variable, import, func…
WeishuZ Dec 11, 2025
1f6accf
Potential fix for pull request finding 'Unused variable, import, func…
WeishuZ Dec 12, 2025
cc645fd
version confliction:webpack-dev-server
WeishuZ Dec 14, 2025
82caa80
fix the login multiple request
WeishuZ Dec 14, 2025
93ecf2c
implement the local development in makefile:
WeishuZ Dec 14, 2025
8abee85
Enhance admin distribution chart: dynamic bar sizing and improved tic…
WeishuZ Dec 14, 2025
b991986
Refactor Makefile and update import syntax for ProgressReportData in …
WeishuZ Dec 14, 2025
838a209
removed logs
WeishuZ Dec 14, 2025
d8d79ed
Add StudentProfile component and integrate with Admin page for detail…
WeishuZ Jan 2, 2026
af1324b
Refactor email generation in Admin page: remove unused fields and add…
WeishuZ Jan 2, 2026
ee07d37
Enhance Admin page: implement score range selection and improve stude…
WeishuZ Jan 2, 2026
b514bb0
Add Alerts page and integrate with NavBar for admin functionality
WeishuZ Jan 2, 2026
53ad366
Introduce Admin Dashboard and Student Profile components; enhance rou…
WeishuZ Jan 2, 2026
f7790be
Refactor StudentProfile component and extract StudentProfileContent f…
WeishuZ Jan 2, 2026
7343a5e
Enhance StudentProfileContent and Admin view: add margin to chart and…
WeishuZ Jan 2, 2026
646390d
enhance Student Profile components for improved functionality
WeishuZ Jan 2, 2026
760ce73
Enhance Admin view: adjust TableContainer height and overflow propert…
WeishuZ Jan 2, 2026
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
Empty file removed Icon
Empty file.
40 changes: 40 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,46 @@ dev-up:
dev-down:
@docker compose -f docker-compose.dev.yml down

dev-local:
@bash -c '\
echo "Starting services locally..."; \
echo "Checking ports..."; \
if lsof -Pi :8000 -sTCP:LISTEN -t >/dev/null 2>&1 ; then \
echo ""; \
echo "⚠️ Port 8000 is already in use:"; \
lsof -Pi :8000 -sTCP:LISTEN ; \
read -p "Kill process on port 8000? [y/N] " -n 1 reply; \
echo ""; \
if [[ "$$reply" =~ ^[Yy]$$ ]] ; then \
lsof -Pi :8000 -sTCP:LISTEN -t | xargs kill -9; \
echo "✓ Killed process on port 8000"; \
else \
echo "Aborted."; exit 1; \
fi \
fi; \
if lsof -Pi :3000 -sTCP:LISTEN -t >/dev/null 2>&1 ; then \
echo ""; \
echo "⚠️ Port 3000 is already in use:"; \
lsof -Pi :3000 -sTCP:LISTEN ; \
read -p "Kill process on port 3000? [y/N] " -n 1 reply; \
echo ""; \
if [[ "$$reply" =~ ^[Yy]$$ ]] ; then \
lsof -Pi :3000 -sTCP:LISTEN -t | xargs kill -9; \
echo "✓ Killed process on port 3000"; \
else \
echo "Aborted."; exit 1; \
fi \
fi; \
'
@echo "1. Starting Redis and dbcron..."
@docker-compose up -d redis dbcron
@echo "2. Waiting for data to be loaded into Redis..."
@sleep 5
@echo "3. Starting API server..."
@cd api && NODE_ENV=development npm run dev &
@echo "4. Starting website dev server..."
@cd website && REACT_APP_PROXY_SERVER="http://localhost:8000" npm run react

docker:
@cd website && npm install && npm run build
@docker compose build
Expand Down
7 changes: 7 additions & 0 deletions api/config/development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"redis": {
"username": "default",
"host": "localhost",
"port": 6379
}
}
38 changes: 29 additions & 9 deletions api/v2/Routes/admin/categories/index.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
import { Router } from 'express';
import { getEntry } from '../../../../lib/redisHelper.mjs';
import { getEntry, getStudents, getStudentScores } from '../../../../lib/redisHelper.mjs';

const router = Router({ mergeParams: true });

/**
* GET /admin/categories
* Returns assignment categories organized by section
* assignment name : max
* Format: { section: { assignmentName: true } }
*/

/** localhost/admin/categories/ */
router.get('/', async (req, res) => {
try {
// Get categories from Redis or build from assignment data

const categoriesEntry = await getEntry('Categories');
if (categoriesEntry) {
// return res.json(categoriesEntry.categories);
// Try to get categories from Redis first
try {
const categoriesEntry = await getEntry('Categories');
return res.status(200).json(categoriesEntry);
} catch (err) {
// If not found in Redis, proceed to build from student scores
console.log('Categories not found in Redis, building from student scores.');
}

// Build categories from student scores
const students = await getStudents();
const categories = {};

for (const student of students) {
const studentId = student[1];
const scores = await getStudentScores(studentId);

// For each section in scores
for (const [section, assignments] of Object.entries(scores)) {
if (!categories[section]) {
categories[section] = {};
}
// For each assignment in section
for (const assignmentName of Object.keys(assignments)) {
categories[section][assignmentName] = true;
}
}
}

res.status(200).json(categories);
}
catch (error) {
console.error('Error fetching categories:', error);
Expand Down
235 changes: 190 additions & 45 deletions api/v2/Routes/admin/distribution/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,216 @@ const router = Router({ mergeParams: true });

/**
* GET /admin/distribution/:section/:name
* Returns score distribution (frequency data, 1 score = 1 bucket).
* Returns: { freq: [count0, count1, ...], minScore: number, maxScore: number }
* Returns score distribution with student data.
* Returns: {
* freq: [count0, count1, ...],
* minScore: number,
* maxScore: number,
* binWidth: number,
* distribution: [{ range: "50-74", count: N, students: [{name, email, score}, ...] }, ...]
* }
*/
router.get('/:section/:name', async (req, res) => {
try {
const { section, name } = req.params;
const students = await getStudents();

const scorePromises = students.map(async student => {
const studentId = student[1];

const studentScores = await getStudentScores(studentId);

// Assuming scores are under section/name and are numbers
const score = studentScores[section] ? studentScores[section][name] : null;

if (score != null && score !== '' && !isNaN(score)) {
// Ensure we are working with integers for binning,
// but keep original value for max/min if needed.
// Since scores are typically integers, we convert to Number.
return Number(score);
}
return null;
});
const students = await getStudents();

// Get max possible score for this assignment
const { getMaxScores } = await import('../../../../lib/redisHelper.mjs');
const maxScoresData = await getMaxScores();
let maxPossibleScore = null;

if (!name.includes('Summary') && maxScoresData[section] && maxScoresData[section][name]) {
maxPossibleScore = Number(maxScoresData[section][name]);
}

const rawScores = await Promise.all(scorePromises);
let scoreData; // Array of {studentName, studentEmail, score}

const scores = rawScores.filter(score => score !== null);
// Check if this is a summary request
if (name.includes('Summary')) {
// Get sum of all assignments in this section for each student
scoreData = [];
for (const student of students) {
const studentId = student[1];
const studentScores = await getStudentScores(studentId);

if (!studentScores[section]) {
continue;
}

const sectionScores = studentScores[section];
let total = 0;
let count = 0;

Object.values(sectionScores).forEach(score => {
if (score != null && score !== '' && !isNaN(score)) {
total += Number(score);
count++;
}
});

if (count > 0) {
scoreData.push({
studentName: student[0],
studentEmail: student[1],
score: total
});
}
}
} else {
// Get score for a specific assignment
scoreData = [];
for (const student of students) {
const studentId = student[1];
const studentScores = await getStudentScores(studentId);

const score = studentScores[section] ? studentScores[section][name] : null;

if (score != null && score !== '' && !isNaN(score)) {
scoreData.push({
studentName: student[0],
studentEmail: student[1],
score: Number(score)
});
}
}
}

if (scores.length === 0) {
if (scoreData.length === 0) {
// Return empty data structure
return res.json({ freq: [], minScore: 0, maxScore: 0 });
return res.json({
freq: [],
minScore: 0,
maxScore: 0,
binWidth: 1,
distribution: []
});
}


const scores = scoreData.map(d => d.score);
const maxScore = Math.max(...scores);
const minScore = Math.min(...scores);

// --- Logic for 1-point buckets ---

// The number of buckets needed, plus 1 (inclusive range)
const range = maxScore - minScore + 1;
// --- Logic for binning: Always use 1-point bins for accuracy ---
const range = maxScore - minScore;
const isSummary = name.includes('Summary');

// Initialize frequency array with size 'range', all counts start at 0
const freq = Array(range).fill(0);

scores.forEach(score => {
// Calculate the index relative to the minScore.
// A score equal to minScore goes into index 0.
const index = score - minScore;

// This condition is robust because scores are filtered to be between minScore and maxScore.
if (index >= 0 && index < range) {
freq[index]++;
// Validate range
if (!isFinite(range) || range < 0) {
console.error('Invalid range calculation:', { minScore, maxScore, range });
return res.status(500).json({
error: 'Invalid score range',
details: { minScore, maxScore, range }
});
}

// Handle case where all scores are the same
if (range === 0) {
return res.json({
freq: [scoreData.length],
minScore,
maxScore,
binWidth: 1,
totalStudents: scoreData.length,
isSummary,
suggestedTickInterval: 1,
distribution: [{
range: `${minScore}`,
rangeStart: minScore,
rangeEnd: minScore,
count: scoreData.length,
students: scoreData.map(d => ({
name: d.studentName,
email: d.studentEmail,
score: d.score
}))
}]
});
}

// Always use 1-point bins for data accuracy
const binWidth = 1;

// Determine the actual range to use (0 to maxPossibleScore if available)
const displayMinScore = 0;
const displayMaxScore = maxPossibleScore || maxScore;
const displayRange = displayMaxScore - displayMinScore;
const numBuckets = Math.ceil(displayRange) + 1;

// Calculate suggested tick interval for display
// This helps the frontend show appropriate x-axis labels
let suggestedTickInterval = 1;
if (displayRange > 100) {
suggestedTickInterval = 10;
} else if (displayRange > 50) {
suggestedTickInterval = 5;
} else if (displayRange > 25) {
suggestedTickInterval = 2;
}

// Additional validation before creating arrays
if (numBuckets > 1000) {
console.error('Too many buckets requested:', numBuckets);
return res.status(500).json({
error: 'Score range too large',
details: { minScore, maxScore, range, numBuckets }
});
}

// Initialize frequency array and distribution map
// Fill from 0 to displayMaxScore
const freq = Array(numBuckets).fill(0);
const distributionBuckets = Array(numBuckets).fill(null).map(() => ({
students: []
}));

// Group students by bucket
scoreData.forEach(data => {
const score = data.score;
// Calculate which bucket this score falls into (from 0)
let bucketIndex = Math.floor(score / binWidth);

// Handle edge case where score equals or exceeds displayMaxScore
if (bucketIndex >= numBuckets) {
bucketIndex = numBuckets - 1;
}

freq[bucketIndex]++;
distributionBuckets[bucketIndex].students.push({
name: data.studentName,
email: data.studentEmail,
score: data.score
});
});

// Convert distribution buckets to array with range labels
// Start from 0 and go to displayMaxScore
const distribution = distributionBuckets.map((bucket, index) => {
const scoreValue = index * binWidth;

return {
range: `${scoreValue}`,
rangeStart: scoreValue,
rangeEnd: scoreValue,
count: bucket.students.length,
students: bucket.students
};
});

// --- END Logic for 1-point buckets ---
// --- END Logic for binning ---

res.json({
// freq array now contains counts where freq[i] is the count for score (minScore + i)
freq,
minScore,
maxScore
// Removed: bins, max, min, binWidth
minScore: displayMinScore, // Always 0
maxScore: displayMaxScore, // Max possible score or highest student score
actualMinScore: minScore, // Actual lowest student score
actualMaxScore: maxScore, // Actual highest student score
maxPossibleScore, // Max possible score for this assignment (null for summary)
binWidth,
totalStudents: scoreData.length,
isSummary,
suggestedTickInterval, // Frontend can use this to reduce x-axis label density
distribution // Includes all students grouped by score range (0 to maxScore)
});
} catch (error) {
console.error('Error fetching frequency distribution:', error);
Expand Down
Loading