diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 8e5c5e2..e6e1e6d 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -10,7 +10,10 @@ import { requestLoggerMiddleware } from '@/http/request-logger'; import { corsMiddleware, securityHeadersMiddleware } from '@/http/security-headers'; import { sessionAuthMiddleware } from '@/http/session-auth'; import { logger } from '@/logger'; -import { studentStatusRoutes } from './modules/analytics'; +import { + groupsRiskOverviewRoute, + studentStatusRoutes, +} from './modules/analytics'; import { attendeesRoutes } from './modules/attendees'; import { authRoutes } from './modules/auth'; import { classesRoutes } from './modules/classes'; @@ -46,6 +49,7 @@ api.route('/payments', paymentsRoutes); api.route('/waitlist', waitlistRoutes); api.route('/classes', classesRoutes); api.route('/students', studentStatusRoutes); +api.route('/groups-risk', groupsRiskOverviewRoute); app.notFound((c) => { return problemJson(c, notFoundProblem(c.req.path)); diff --git a/apps/backend/src/modules/analytics/features/get-groups-risk-overview/route.ts b/apps/backend/src/modules/analytics/features/get-groups-risk-overview/route.ts new file mode 100644 index 0000000..af7e136 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-groups-risk-overview/route.ts @@ -0,0 +1,19 @@ +import { Hono } from 'hono'; +import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details'; +import { GetGroupsRiskOverview } from './use-case'; + +const route = new Hono(); + +route.get('/overview', async (c) => { + const user = c.get('user'); + if (!user) { + return problemJson(c, unauthorizedProblem(c.req.path)); + } + + const useCase = new GetGroupsRiskOverview(); + const result = await useCase.execute(user.id); + + return resultJson(c, result); +}); + +export default route; \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/features/get-groups-risk-overview/use-case.ts b/apps/backend/src/modules/analytics/features/get-groups-risk-overview/use-case.ts new file mode 100644 index 0000000..6fbd966 --- /dev/null +++ b/apps/backend/src/modules/analytics/features/get-groups-risk-overview/use-case.ts @@ -0,0 +1,136 @@ +import { Prisma } from '@generated/prisma/client'; +import type { + AttendanceStatus, + GroupRiskLevel, + GroupRiskSummary, + GroupsRiskOverview, + ProblemDetails, + Result, +} from '@gruperly/shared'; +import { ok } from '@gruperly/shared'; +import prisma from '@/lib/prisma'; +import { buildGroupWhereForUser } from '@/modules/groups/lib'; +import { + classifyRisk, + monthlyAttendanceRate, + trailingAbsentStreak, +} from '../../lib/risk'; +import { ANALYTICS_WINDOW_MS } from '../get-overview/use-case'; + +type GetGroupsRiskOverviewDeps = { + db?: { + group: Prisma.GroupDelegate; + classSession: Prisma.ClassSessionDelegate; + attendee: Prisma.AttendeeDelegate; + attendance: Prisma.AttendanceDelegate; + }; + now?: Date; +}; + +type GroupRow = { id: string; name: string }; +type AttendeeRow = { id: string; groupId: string }; +type SessionRow = { id: string; groupId: string }; +type AttendanceRow = { + attendeeId: string; + status: AttendanceStatus; + classSession: { startsAt: Date }; +}; + +// Resumen de semáforo de riesgo por grupo para la lista de grupos. +// Un grupo es HIGH si tiene al menos un alumno en riesgo alto, MEDIUM si +// alguno en riesgo moderado y NONE en caso contrario. +export class GetGroupsRiskOverview { + constructor(private readonly deps: GetGroupsRiskOverviewDeps = {}) {} + + async execute(userId: string): Promise> { + const db = this.deps.db ?? prisma; + const now = this.deps.now ?? new Date(); + const since = new Date(now.getTime() - ANALYTICS_WINDOW_MS); + + const groups = (await db.group.findMany({ + where: buildGroupWhereForUser(userId) as Prisma.GroupWhereInput, + select: { id: true, name: true }, + })) as GroupRow[]; + + if (groups.length === 0) { + return ok({ items: [] }); + } + + const groupIds = groups.map((group) => group.id); + const attendeeGroup = new Map(); + const sessionCountByGroup = new Map(); + + const [attendees, sessions, attendances] = await Promise.all([ + db.attendee.findMany({ + where: { groupId: { in: groupIds }, status: 'ACTIVE' }, + select: { id: true, groupId: true }, + }) as Promise, + db.classSession.findMany({ + where: { groupId: { in: groupIds }, startsAt: { gte: since } }, + select: { id: true, groupId: true }, + }) as Promise, + db.attendance.findMany({ + where: { attendee: { groupId: { in: groupIds } } }, + select: { + attendeeId: true, + status: true, + classSession: { select: { startsAt: true } }, + }, + orderBy: { classSession: { startsAt: 'desc' } }, + }) as Promise, + ]); + + for (const attendee of attendees) { + attendeeGroup.set(attendee.id, attendee.groupId); + } + for (const session of sessions) { + sessionCountByGroup.set(session.groupId, (sessionCountByGroup.get(session.groupId) ?? 0) + 1); + } + + const streamByAttendee = groupAttendanceByAttendee(attendances); + + const worstRiskByGroup = new Map(); + for (const attendee of attendees) { + const groupId = attendeeGroup.get(attendee.id)!; + const stream = (streamByAttendee.get(attendee.id) ?? []).map((row) => ({ + status: row.status, + occurredAt: row.classSession.startsAt, + })); + const streak = trailingAbsentStreak(stream); + const monthlyRate = monthlyAttendanceRate( + stream.filter((entry) => entry.occurredAt.getTime() >= since.getTime()), + sessionCountByGroup.get(groupId) ?? 0, + ); + const riskLevel = classifyRisk(streak, monthlyRate); + if (!riskLevel) { + continue; + } + + const current = worstRiskByGroup.get(groupId) ?? 'NONE'; + if ( + riskLevel === 'HIGH' || + (riskLevel === 'MEDIUM' && current === 'NONE') + ) { + worstRiskByGroup.set(groupId, riskLevel); + } + } + + const items: GroupRiskSummary[] = groups.map((group) => ({ + groupId: group.id, + groupName: group.name, + riskLevel: worstRiskByGroup.get(group.id) ?? 'NONE', + })); + + return ok({ items }); + } +} + +function groupAttendanceByAttendee(rows: AttendanceRow[]): Map { + const byAttendee = new Map(); + for (const row of rows) { + const list = byAttendee.get(row.attendeeId) ?? []; + list.push(row); + byAttendee.set(row.attendeeId, list); + } + return byAttendee; +} \ No newline at end of file diff --git a/apps/backend/src/modules/analytics/index.ts b/apps/backend/src/modules/analytics/index.ts index 75c5441..4b66d93 100644 --- a/apps/backend/src/modules/analytics/index.ts +++ b/apps/backend/src/modules/analytics/index.ts @@ -1,2 +1,3 @@ +export { default as groupsRiskOverviewRoute } from './features/get-groups-risk-overview/route'; export { default as studentStatusRoutes } from './features/update-attendee-status/route'; export { default as analyticsRoutes } from './routes'; \ No newline at end of file diff --git a/apps/backend/test/analytics-groups-risk.test.ts b/apps/backend/test/analytics-groups-risk.test.ts new file mode 100644 index 0000000..2a8a2a3 --- /dev/null +++ b/apps/backend/test/analytics-groups-risk.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, mock, vi } from 'bun:test'; +import { Hono } from 'hono'; + +const db = { + group: { + findMany: mock(), + }, + attendee: { + findMany: mock(), + }, + classSession: { + findMany: mock(), + }, + attendance: { + findMany: mock(), + }, +}; + +mock.module('@/lib/prisma', () => ({ + default: db, + getPrismaClient: mock(), + UnitOfWork: class { + executeResult = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + execute = mock(async (cb: (tx: unknown) => Promise) => cb(db)); + }, +})); + +import { groupsRiskOverviewRoute } from '@/modules/analytics'; + +const userId = 'user-1'; +const DAY_MS = 24 * 60 * 60 * 1000; +const now = Date.now(); +const at = (offsetDays: number) => new Date(now - offsetDays * DAY_MS); + +function makeApp(userValue: unknown) { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('user', userValue as never); + await next(); + }); + app.route('/groups-risk', groupsRiskOverviewRoute); + return app; +} + +describe('GET /groups-risk/overview', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('devuelve items vacíos si el usuario no tiene grupos', async () => { + prismaFetchGroupFindMany([]); + prismaFetchAttendeeFindMany([]); + prismaFetchClassSessionFindMany([]); + prismaFetchAttendanceFindMany([]); + + const res = await makeApp({ id: userId }).request('/groups-risk/overview'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ items: [] }); + }); + + it('marca HIGH a un grupo con un alumno con 3 ausencias consecutivas', async () => { + prismaFetchGroupFindMany([ + { id: 'group-1', name: 'Funcional' }, + { id: 'group-2', name: 'Yoga' }, + ]); + prismaFetchAttendeeFindMany([ + { id: 'a-1', groupId: 'group-1' }, + { id: 'a-2', groupId: 'group-2' }, + ]); + prismaFetchClassSessionFindMany([ + { id: 's1', groupId: 'group-1', startsAt: at(-1) }, + { id: 's2', groupId: 'group-1', startsAt: at(-2) }, + { id: 's3', groupId: 'group-1', startsAt: at(-3) }, + { id: 's4', groupId: 'group-2', startsAt: at(-1) }, + ]); + prismaFetchAttendanceFindMany([ + attendance('a-1', 'ABSENT', at(-1)), + attendance('a-1', 'ABSENT', at(-2)), + attendance('a-1', 'ABSENT', at(-3)), + attendance('a-2', 'PRESENT', at(-1)), + ]); + + const res = await makeApp({ id: userId }).request('/groups-risk/overview'); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + items: [ + { groupId: 'group-1', groupName: 'Funcional', riskLevel: 'HIGH' }, + { groupId: 'group-2', groupName: 'Yoga', riskLevel: 'NONE' }, + ], + }); + }); +}); + +function prismaFetchGroupFindMany(rows: unknown[]) { + db.group.findMany.mockResolvedValue(rows); +} + +function prismaFetchAttendeeFindMany(rows: unknown[]) { + db.attendee.findMany.mockResolvedValue(rows); +} + +function prismaFetchClassSessionFindMany(rows: unknown[]) { + db.classSession.findMany.mockResolvedValue(rows); +} + +function prismaFetchAttendanceFindMany(rows: unknown[]) { + db.attendance.findMany.mockResolvedValue(rows); +} + +function attendance(attendeeId: string, status: string, startsAt: string) { + return { attendeeId, status, classSession: { startsAt } }; +} \ No newline at end of file diff --git a/apps/backend/test/classes.test.ts b/apps/backend/test/classes.test.ts index 1856a9b..3daf37b 100644 --- a/apps/backend/test/classes.test.ts +++ b/apps/backend/test/classes.test.ts @@ -65,7 +65,7 @@ function makeSession(overrides: Record = {}) { return { id: 'session-1', groupId: 'group-1', - startsAt: new Date('2026-09-23T19:00:00.000Z'), + startsAt: todayAtUtc(), group: { id: 'group-1', name: 'Funcional', @@ -77,6 +77,13 @@ function makeSession(overrides: Record = {}) { }; } +// Fecha "hoy" a las 19:00 UTC, del día real del runner. +function todayAtUtc() { + const date = new Date(); + date.setUTCHours(19, 0, 0, 0); + return date; +} + describe('classes routes', () => { beforeEach(() => { vi.clearAllMocks(); @@ -85,7 +92,7 @@ describe('classes routes', () => { describe('GET /classes/today', () => { it('materializa y lista las clases de hoy del profesor', async () => { - const startsAt = new Date('2026-09-23T19:00:00.000Z'); + const startsAt = todayAtUtc(); prisma.group.findMany.mockResolvedValue([ { id: 'group-1', @@ -432,7 +439,7 @@ describe('classes routes', () => { }) .format(new Date()) .toUpperCase(); - const startsAt = new Date('2026-09-23T19:00:00.000Z'); + const startsAt = todayAtUtc(); prisma.attendee.findUnique.mockResolvedValue({ id: 'attendee-1', diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f02ac70..9fee200 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -19,6 +19,8 @@ import type { GroupDto, GroupInviteInfoDto, GroupList, + GroupRiskSummary, + GroupsRiskOverview, GroupWaitlistEntryDto, GroupWaitlistList, HomeSummaryDto, @@ -223,6 +225,8 @@ export const getGroupAnalytics = (groupId: string) => export const getStudentsAtRisk = (groupId: string) => apiFetch(`/api/v1/groups/${groupId}/students-at-risk`) +export const getGroupsRiskOverview = () => apiFetch('/api/v1/groups-risk/overview') + export const getAttendeeHistory = (groupId: string, attendeeId: string) => apiFetch(`/api/v1/groups/${groupId}/attendees/${attendeeId}/history`) diff --git a/apps/web/src/routes/analytics.tsx b/apps/web/src/routes/analytics.tsx index a13a87e..0e148d2 100644 --- a/apps/web/src/routes/analytics.tsx +++ b/apps/web/src/routes/analytics.tsx @@ -127,6 +127,7 @@ export function AnalyticsView() { void queryClient.invalidateQueries({ queryKey: ['group', groupId] }); void queryClient.invalidateQueries({ queryKey: ['classes-today'] }); void queryClient.invalidateQueries({ queryKey: ['home-summary'] }); + void queryClient.invalidateQueries({ queryKey: ['groups-risk-overview'] }); }, onError: (err: Error) => { toast.error(err.message || 'No pudimos actualizar el estado del alumno.'); diff --git a/apps/web/src/routes/group-detail.tsx b/apps/web/src/routes/group-detail.tsx index 4c9278d..7616bb3 100644 --- a/apps/web/src/routes/group-detail.tsx +++ b/apps/web/src/routes/group-detail.tsx @@ -44,6 +44,7 @@ import { createAttendee, getGroup, getGroupAttendees, + getGroupsRiskOverview, getGroupWaitlist, getInviteToken, promoteGroupWaitlistEntry, @@ -106,6 +107,12 @@ export function GroupDetailView() { enabled: Boolean(groupId), }); + const riskOverviewQuery = useQuery({ + queryKey: ['groups-risk-overview'], + queryFn: getGroupsRiskOverview, + enabled: Boolean(groupId), + }); + const inviteTokenQuery = useQuery({ queryKey: ['invite-token', groupId], queryFn: () => getInviteToken(groupId), @@ -487,6 +494,9 @@ export function GroupDetailView() { ); } + const riskLevel = + riskOverviewQuery.data?.items.find((item) => item.groupId === groupId)?.riskLevel ?? 'NONE'; + return (
{/* Navigation & Header */} @@ -504,17 +514,28 @@ export function GroupDetailView() {

{group.name}

Activo + {riskLevel === 'HIGH' || riskLevel === 'MEDIUM' ? ( + + + ) : null}
{group.description ? (

{group.description}

) : null} -
+
diff --git a/apps/web/src/routes/groups.tsx b/apps/web/src/routes/groups.tsx index ded2901..0087786 100644 --- a/apps/web/src/routes/groups.tsx +++ b/apps/web/src/routes/groups.tsx @@ -1,14 +1,27 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { CalendarClock, Loader2, Plus, Users } from 'lucide-react' -import type { GroupDto } from '@gruperly/shared' +import type { GroupDto, GroupRiskLevel } from '@gruperly/shared' import { Badge, Button } from '../components/ui' -import { getGroups } from '../lib/api' +import { getGroups, getGroupsRiskOverview } from '../lib/api' import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format' +import { cn } from '../lib/utils' -function GroupCard({ group }: { group: GroupDto }) { +const RISK_LABELS: Record, string> = { + HIGH: 'Riesgo alto', + MEDIUM: 'Riesgo moderado', +} + +function GroupCard({ + group, + riskLevel, +}: { + group: GroupDto + riskLevel: GroupRiskLevel +}) { const navigate = useNavigate() const hasSchedule = (group.days?.length ?? 0) > 0 + const hasRisk = riskLevel === 'HIGH' || riskLevel === 'MEDIUM' return (
@@ -19,7 +32,21 @@ function GroupCard({ group }: { group: GroupDto }) {

{group.description}

) : null} - Activo +
+ {hasRisk ? ( + + + ) : null} + Activo +
{hasSchedule ? ( @@ -62,6 +89,14 @@ export function GroupsView() { queryKey: ['groups'], queryFn: getGroups, }) + const riskQuery = useQuery({ + queryKey: ['groups-risk-overview'], + queryFn: getGroupsRiskOverview, + }) + + const riskByGroup = new Map( + (riskQuery.data?.items ?? []).map((item) => [item.groupId, item.riskLevel]), + ) return (
@@ -111,7 +146,11 @@ export function GroupsView() { {groupsQuery.isSuccess && groupsQuery.data.data.length > 0 ? (
{groupsQuery.data.data.map((group) => ( - + ))}
) : null} diff --git a/packages/shared/src/schemas/analytics.ts b/packages/shared/src/schemas/analytics.ts index d04d7de..29088f1 100644 --- a/packages/shared/src/schemas/analytics.ts +++ b/packages/shared/src/schemas/analytics.ts @@ -64,4 +64,20 @@ export const UpdateStudentStatusResultSchema = z.object({ attendeeId: z.string(), status: attendeeStatusSchema, }); -export type UpdateStudentStatusResult = z.output; \ No newline at end of file +export type UpdateStudentStatusResult = z.output; + +export const groupRiskLevelSchema = z.enum(['NONE', 'MEDIUM', 'HIGH']); +export type GroupRiskLevel = z.output; + +export const GroupRiskSummarySchema = z.object({ + groupId: z.string(), + groupName: z.string(), + riskLevel: groupRiskLevelSchema, +}); +export type GroupRiskSummary = z.output; + +export const GroupsRiskOverviewSchema = z.object({ + items: z.array(GroupRiskSummarySchema), +}); +export type GroupsRiskOverview = z.output; +export type GroupsRiskOverviewDto = GroupsRiskOverview; \ No newline at end of file