diff --git a/app/controllers/grades_controller.ts b/app/controllers/grades_controller.ts new file mode 100644 index 0000000..b0c4088 --- /dev/null +++ b/app/controllers/grades_controller.ts @@ -0,0 +1,41 @@ +import { GradeService } from '#services/grade_service' +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' +import { DateTime } from 'luxon' + +const PERIODS = ['MONTH', 'SEMESTER', 'YEAR'] + +@inject() +export default class GradesController { + constructor(private gradeService: GradeService) {} + + // GET /grades + async index({ auth, request, response }: HttpContext) { + const period = request.input('period', PERIODS[0]).toUpperCase() + if (!PERIODS.includes(period)) { + return response.badRequest({ + message: `Invalid period. Allowed values are: ${PERIODS.join(', ')}`, + }) + } + + // TODO: Choose a start date + // const { startDate: rawStartDate } = request.qs() + + // Validate startDate + // const startDate = rawStartDate ? DateTime.fromISO(rawStartDate, { zone: 'local' }) : null + // if (!rawStartDate || !startDate || !startDate.isValid) { + // return response.badRequest({ message: 'Invalid start date format' }) + // } + + const userId = auth.user!.id + if (period === PERIODS[0]) { + const startDate = DateTime.now().minus({ month: 1 }) + return this.gradeService.getMonthGrade(userId, startDate) + } + const months = period === PERIODS[1] ? 3 : 12 + const startDate = DateTime.now().minus({ months }) + return this.gradeService.getPeriodGrade(userId, startDate, months) + } + + // TODO: Radar chart for subjects +} diff --git a/app/controllers/notifications_controller.ts b/app/controllers/notifications_controller.ts index cf2afb6..ada6ac5 100644 --- a/app/controllers/notifications_controller.ts +++ b/app/controllers/notifications_controller.ts @@ -24,7 +24,7 @@ export default class NotificationsController { const device = this.service.getUserSignature(ua) const data = await request.validateUsing(subscribeValidator) - await Subscription.create({ + return Subscription.create({ userId: auth.user!.id, device, events: 0, // Default to no events diff --git a/app/services/grade_service.ts b/app/services/grade_service.ts new file mode 100644 index 0000000..caddace --- /dev/null +++ b/app/services/grade_service.ts @@ -0,0 +1,174 @@ +import Colle from '#models/colle' +import { DateTime } from 'luxon' + +export class GradeService { + private calculateAverage(colles: Colle[]) { + const total = colles.reduce((sum, colle) => sum + colle.grade, 0) + return parseFloat((total / colles.length).toFixed(2)) + } + + private calculateSubjectAverage(colles: Colle[], subject: string) { + const subjectColles = colles.filter((colle) => colle.subject.name === subject) + const total = subjectColles.reduce((sum, colle) => sum + colle.grade, 0) + return parseFloat((total / subjectColles.length).toFixed(2)) + } + + private getSubjects(colles: Colle[]) { + return Array.from(new Set(colles.map((colle) => colle.subject.name))) + } + + private getPeriodColles(colles: Colle[], startDate: DateTime, endDate: DateTime) { + return colles.filter((colle) => colle.date >= startDate && colle.date < endDate) + } + + private getColles(userId: number, startDate: DateTime, months: number = 0) { + const endDate = startDate.plus({ months }) + return Colle.query() + .where('studentId', userId) + .where('date', '>=', startDate.toJSDate()) + .where('date', '<', endDate.toJSDate()) + .preload('subject') + .orderBy('date', 'asc') + } + + private async getGlobalAverage(userId: number, subjectId: number, beforeDate: DateTime) { + const colles = await Colle.query() + .where('studentId', userId) + .where('subjectId', subjectId) + .where('date', '<', beforeDate.toJSDate()) + return colles.length ? this.calculateAverage(colles) : 0 + } + + public async getMonthGrade(userId: number, startDate: DateTime) { + const colles = await this.getColles(userId, startDate, 1) + const subjects = this.getSubjects(colles) + + const results: PeriodResult[] = [] + let periodStartDate = startDate + + for (let week = 1; week <= 4; week++) { + const periodEndDate = periodStartDate.plus({ weeks: 1 }) + const periodColles = this.getPeriodColles(colles, periodStartDate, periodEndDate) + + const periodAverage = this.calculateAverage(periodColles) + const subjectAverages = await this.getSubjectAverages( + subjects, + periodColles, + colles, + results, + week, + userId, + periodStartDate + ) + + results.push({ + period: `Week ${week}`, + average: periodAverage, + ...this.reduce(subjectAverages), + }) + + periodStartDate = periodEndDate + } + + return { grades: results, subjects } + } + + public async getPeriodGrade(userId: number, startDate: DateTime, months: number = 0) { + const colles = await this.getColles(userId, startDate, months) + const subjects = this.getSubjects(colles) + + const results: PeriodResult[] = [] + let periodStartDate = startDate + + let index = 1 + + while (periodStartDate < startDate.plus({ months })) { + const periodEndDate = periodStartDate.endOf('month') + const periodColles = this.getPeriodColles(colles, periodStartDate, periodEndDate) + + const periodAverage = this.calculateAverage(periodColles) + const subjectAverages = await this.getSubjectAverages( + subjects, + periodColles, + colles, + results, + index, + userId, + periodStartDate + ) + + console.log(this.reduce(subjectAverages), subjectAverages) + results.push({ + period: periodStartDate.toFormat('MMM'), + average: periodAverage, + ...this.reduce(subjectAverages), + }) + + periodStartDate = periodEndDate.plus({ days: 1 }) + index++ + } + + return { grades: results, subjects } + } + + private reduce(subjectAverages: SubjectPerformance[]) { + return subjectAverages.reduce((acc: any, { subject, average }) => { + acc[subject] = average + return acc + }, {}) + } + + private async getSubjectAverages( + subjects: string[], + periodColles: Colle[], + allColles: Colle[], + previousResults: PeriodResult[], + unitIndex: number, + userId: number, + periodStart: DateTime + ) { + const results = await Promise.all( + subjects.map(async (subject) => { + if (periodColles.length > 0) { + return { + subject, + average: this.calculateSubjectAverage(periodColles, subject), + } + } + + // Try to use the previous unit's average + if (unitIndex > 1) { + const prev = previousResults[unitIndex - 2].subjectAverages?.find( + (s) => s.subject === subject + ) + if (prev) { + return { subject, average: prev.average } + } + } + + // Otherwise fall back to global average before this period + const subjectId = allColles.find((colle) => colle.subject.name === subject)!.subjectId + + const beforeAverage = await this.getGlobalAverage(userId, subjectId, periodStart) + if (beforeAverage) { + return { subject, average: beforeAverage } + } + + return undefined + }) + ) + + return results.filter((s): s is SubjectPerformance => Boolean(s)) + } +} + +interface SubjectPerformance { + subject: string + average: number +} + +interface PeriodResult { + period: string + average: number + subjectAverages: SubjectPerformance[] +} diff --git a/app/services/notification_service.ts b/app/services/notification_service.ts index f7557bb..25bfb9a 100644 --- a/app/services/notification_service.ts +++ b/app/services/notification_service.ts @@ -85,7 +85,7 @@ export class NotificationService { ? device.type : 'Desktop' - return [deviceStr, osStr, browserStr].filter(Boolean).join(' | ') + return [deviceStr, osStr, browserStr].filter(Boolean).join(' - ') } } diff --git a/database/migrations/1755685548096_create_subscriptions_table.ts b/database/migrations/1755685548096_create_subscriptions_table.ts new file mode 100644 index 0000000..676a715 --- /dev/null +++ b/database/migrations/1755685548096_create_subscriptions_table.ts @@ -0,0 +1,27 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'subscriptions' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table + .integer('user_id') + .notNullable() + .unsigned() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('device').notNullable() + table.integer('events').defaultTo(0).notNullable() + table.jsonb('data').notNullable() + table.integer('failed_attempts').defaultTo(0).notNullable() + table.boolean('enabled').defaultTo(true).notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/start/routes.ts b/start/routes.ts index b416659..e609e03 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -52,10 +52,13 @@ const NotificationsController = () => import('#controllers/notifications_control router .group(() => { router.get('/', [NotificationsController, 'index']) - router.post('/subscribe', [NotificationsController, 'subscribe']) + router.post('/', [NotificationsController, 'subscribe']) router.post('/:id/unsubscribe', [NotificationsController, 'unsubscribe']) router.post('/:id', [NotificationsController, 'update']) router.post('/:id/test', [NotificationsController, 'test']).use(middleware.auth()) }) .prefix('/notifications') .use(middleware.auth()) + +const GradesController = () => import('#controllers/grades_controller') +router.get('/grades', [GradesController, 'index']).use(middleware.auth())