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
6,772 changes: 4,616 additions & 2,156 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"supertest": "^6.3.4"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.920.0",
"@aws-sdk/client-s3": "^3.965.0",
"@azure/storage-blob": "^12.26.0",
"@babel/cli": "^7.15.4",
"@babel/core": "^7.10.2",
Expand All @@ -85,6 +85,7 @@
"cors": "^2.8.4",
"cron": "^1.8.2",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"dotenv": "^5.0.1",
"dropbox": "^10.34.0",
"express": "^4.22.1",
Expand Down Expand Up @@ -119,7 +120,8 @@
"telesignsdk": "^3.0.3",
"twilio": "^5.5.2",
"uuid": "^3.4.0",
"ws": "^8.17.1"
"ws": "^8.17.1",
"zod": "^4.1.12"
},
"nodemonConfig": {
"watch": [
Expand Down
4 changes: 4 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
const express = require('express');
const Sentry = require('@sentry/node');

Check warning on line 2 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups

const app = express();
const logger = require('./startup/logger');
const globalErrorHandler = require('./utilities/errorHandling/globalErrorHandler');

Check warning on line 6 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups

logger.init();

app.use(Sentry.Handlers.requestHandler());

// ✅ Mount analytics routes
const analyticsRoutes = require('./routes/applicantAnalyticsRoutes');

Check warning on line 13 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups

app.use('/api/applicants', analyticsRoutes);

Expand All @@ -20,6 +20,10 @@
require('./startup/bodyParser')(app);
require('./startup/middleware')(app);

const weeklyReportsRouter = require('./routes/weeklyReportsRouter');

app.use('/api', weeklyReportsRouter);

// ⚠ This must come *after* your custom /api routes
require('./startup/routes')(app);

Expand Down
26 changes: 13 additions & 13 deletions src/controllers/educationPortal/downloadReportController.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function buildTaskQuery(type, params) {

function calculateAverageGrade(taskList) {
const gradedTasks = taskList.filter(
t => t.grade && t.grade !== 'pending' && GRADE_MAP[t.grade] !== undefined,
(t) => t.grade && t.grade !== 'pending' && GRADE_MAP[t.grade] !== undefined,
);

if (gradedTasks.length === 0) return 'N/A';
Expand All @@ -99,7 +99,9 @@ async function fetchStudentReport(studentId, startDate, endDate) {
const [tasks, student] = await Promise.all([
EducationTask.find(query)
.populate('studentId', 'firstName lastName email')
.select('type status grade dueAt completedAt feedback suggestedTotalHours loggedHours assignedAt')
.select(
'type status grade dueAt completedAt feedback suggestedTotalHours loggedHours assignedAt',
)
.sort({ assignedAt: -1 })
.limit(MAX_RECORDS_PER_REPORT)
.lean(),
Expand All @@ -114,7 +116,7 @@ async function fetchStudentReport(studentId, startDate, endDate) {
return null;
}

const taskData = tasks.map(task => ({
const taskData = tasks.map((task) => ({
taskName: task.type || 'N/A',
type: task.type,
status: task.status,
Expand All @@ -127,10 +129,10 @@ async function fetchStudentReport(studentId, startDate, endDate) {
assignedAt: task.assignedAt,
}));

const completed = tasks.filter(t => t.status === 'completed').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const graded = tasks.filter(t => t.status === 'graded').length;
const assigned = tasks.filter(t => t.status === 'assigned').length;
const completed = tasks.filter((t) => t.status === 'completed').length;
const inProgress = tasks.filter((t) => t.status === 'in_progress').length;
const graded = tasks.filter((t) => t.status === 'graded').length;
const assigned = tasks.filter((t) => t.status === 'assigned').length;

return {
student: {
Expand Down Expand Up @@ -215,7 +217,7 @@ async function fetchClassReport(classId, startDate, endDate) {
return Number.isNaN(grade) ? sum : sum + grade;
}, 0);

const studentsWithGrades = students.filter(s => s.averageGrade !== 'N/A').length;
const studentsWithGrades = students.filter((s) => s.averageGrade !== 'N/A').length;

return {
classId,
Expand Down Expand Up @@ -373,14 +375,12 @@ function generateCSVReport(res, reportData, metadata, type) {
{ label: 'Feedback', value: 'feedback' },
];

data = reportData.tasks.map(task => ({
data = reportData.tasks.map((task) => ({
taskName: task.taskName,
status: task.status,
grade: task.grade,
dueDate: task.dueDate ? new Date(task.dueDate).toLocaleDateString() : 'N/A',
completedDate: task.completedDate
? new Date(task.completedDate).toLocaleDateString()
: 'N/A',
completedDate: task.completedDate ? new Date(task.completedDate).toLocaleDateString() : 'N/A',
suggestedHours: task.suggestedHours,
loggedHours: task.loggedHours,
feedback: task.feedback || '',
Expand Down Expand Up @@ -479,4 +479,4 @@ const downloadReportController = {
},
};

module.exports = downloadReportController;
module.exports = downloadReportController;
1 change: 0 additions & 1 deletion src/controllers/lbdashboard/bookingsController.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const paypal = require('@paypal/checkout-server-sdk');

const nodemailer = require('nodemailer');
const Joi = require('joi');
require('dotenv').config();
Expand Down
199 changes: 199 additions & 0 deletions src/controllers/tasksWeeklyController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
const TERMINAL_STATUSES = ['Completed', 'Closed', 'Complete'];
// eslint-disable-next-line no-unused-vars
const mongoose = require('mongoose');
const {
startOfWeek,
endOfWeek,
addWeeks,
differenceInCalendarDays,
isValid,
parseISO,
} = require('date-fns');
const { utcToZonedTime, zonedTimeToUtc, format } = require('date-fns-tz');
const { z } = require('zod');
const Task = require('../models/task'); // adjust path if needed

const TZ = 'America/Chicago';
const MAX_WEEKS = 12;
const ALLOWED_WEEKS = new Set([4, 8, 12]);

const querySchema = z
.object({
start: z.string().optional(),
end: z.string().optional(),
// weeks may be "4" | "8" | "12" or 4 | 8 | 12, or omitted entirely
weeks: z.union([z.string(), z.number()]).optional(),
})
.transform((raw) => {
const now = new Date();
const zonedNow = utcToZonedTime(now, TZ);

const defaultEnd = endOfWeek(zonedNow, { weekStartsOn: 1 });
const defaultStart = addWeeks(defaultEnd, -8);

const end = raw.end ? parseISO(raw.end) : defaultEnd;
const start = raw.start ? parseISO(raw.start) : defaultStart;

if (!isValid(start) || !isValid(end) || end < start) {
const err = new Error('Invalid start or end date.');
err.status = 400;
throw err;
}

const startZ = utcToZonedTime(start, TZ);
const endZ = utcToZonedTime(end, TZ);

const startWeek = startOfWeek(startZ, { weekStartsOn: 1 });
const endWeek = endOfWeek(endZ, { weekStartsOn: 1 });

const weeksNormalized =
raw.weeks === undefined || raw.weeks === '' ? undefined : Number(raw.weeks);

if (weeksNormalized !== undefined && !ALLOWED_WEEKS.has(weeksNormalized)) {
const err = new Error('weeks must be one of 4, 8, 12');
err.status = 400;
throw err;
}

const rangeDays = differenceInCalendarDays(endWeek, startWeek) + 1;
if (rangeDays > MAX_WEEKS * 7) {
const err = new Error('Date range cannot exceed 12 weeks.');
err.status = 400;
throw err;
}

const weeks = weeksNormalized ?? 8;

return { startWeek, endWeek, weeks };
});

function buildWeekBuckets(endWeekZoned, weeks) {
const buckets = [];
let cursor = endWeekZoned;
for (let i = 0; i < weeks; i += 1) {
const wEndZ = cursor;
const wStartZ = startOfWeek(wEndZ, { weekStartsOn: 1 });
buckets.push({
startUTC: zonedTimeToUtc(wStartZ, TZ),
endUTC: zonedTimeToUtc(wEndZ, TZ),
// safer format token for date-fns v2:
label: format(wStartZ, 'yyyy-MM-dd', { timeZone: TZ }),
});
cursor = addWeeks(wEndZ, -1);
}
return buckets.reverse(); // ascending
}

/** GET /api/tasks/trends
* Params: start (ISO), end (ISO), weeks (4|8|12 default 8)
* Return: [{ week: 'YYYY-MM-DD', completed: number }, ...]
*/
async function getTrends(req, res) {
try {
// eslint-disable-next-line no-unused-vars
const { startWeek, endWeek, weeks } = querySchema.parse(req.query);

const buckets = buildWeekBuckets(endWeek, weeks);
const rangeStartUTC = buckets[0].startUTC;
const rangeEndUTC = buckets[buckets.length - 1].endUTC;

// Pull all completions in the N-week range
const tasks = await Task.aggregate([
{
$match: {
completedDatetime: { $ne: null, $gte: rangeStartUTC, $lte: rangeEndUTC },
},
},
{ $project: { completedDatetime: 1 } },
]);

// JS bucket counts (fine for small N)
const counts = Object.fromEntries(buckets.map((b) => [b.label, 0]));
// eslint-disable-next-line no-restricted-syntax
for (const t of tasks) {
// eslint-disable-next-line no-restricted-syntax
for (const b of buckets) {
if (t.completedDatetime >= b.startUTC && t.completedDatetime <= b.endUTC) {
counts[b.label] += 1;
break;
}
}
}

const data = buckets.map((b) => ({ week: b.label, completed: counts[b.label] || 0 }));
return res.json(data);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message || 'Invalid request' });
}
}

/** GET /api/tasks/summary
* Return:
* {
* totalTasks,
* completedThisWeek,
* openTasks,
* averageCompletionTimeDays
* }
*/
async function getSummary(req, res) {
try {
const { startWeek, endWeek, weeks } = querySchema.parse(req.query);

const buckets = buildWeekBuckets(endWeek, weeks);
const latest = buckets[buckets.length - 1]; // “This Week”

const totalTasksPromise = Task.countDocuments({
createdDatetime: { $lte: zonedTimeToUtc(endWeek, TZ) },
deleted: { $ne: true },
});

const completedThisWeekPromise = Task.countDocuments({
completedDatetime: { $ne: null, $gte: latest.startUTC, $lte: latest.endUTC },
});

const openTasksPromise = Task.countDocuments({
deleted: { $ne: true },
createdDatetime: { $lte: zonedTimeToUtc(endWeek, TZ) },
status: { $nin: TERMINAL_STATUSES }, // <- no OR, just “not terminal”
});

const avgAggPromise = Task.aggregate([
{
$match: {
deleted: { $ne: true },
completedDatetime: {
$ne: null,
$gte: zonedTimeToUtc(startWeek, TZ),
$lte: zonedTimeToUtc(endWeek, TZ),
},
createdDatetime: { $ne: null }, // <- exclude missing created dates
},
},
{ $project: { diffMs: { $subtract: ['$completedDatetime', '$createdDatetime'] } } },
{ $group: { _id: null, avgMs: { $avg: '$diffMs' } } },
]);

const [totalTasks, completedThisWeek, openTasks, avgAgg] = await Promise.all([
totalTasksPromise,
completedThisWeekPromise,
openTasksPromise,
avgAggPromise,
]);

const averageCompletionTimeDays = avgAgg?.[0]?.avgMs
? avgAgg[0].avgMs / (1000 * 60 * 60 * 24)
: 0;

return res.json({
totalTasks,
completedThisWeek,
openTasks,
averageCompletionTimeDays: Number(averageCompletionTimeDays.toFixed(1)),
});
} catch (err) {
return res.status(err.status || 400).json({ error: err.message || 'Invalid request' });
}
}

module.exports = { getTrends, getSummary };
Loading
Loading