diff --git a/src/backend/src/prisma/factories/graphs.factory.ts b/src/backend/src/prisma/factories/graphs.factory.ts index 133579dfb3..bc52c65cc7 100644 --- a/src/backend/src/prisma/factories/graphs.factory.ts +++ b/src/backend/src/prisma/factories/graphs.factory.ts @@ -47,7 +47,9 @@ const GRAPH_TITLE_BY_TYPE: Record = { [Graph_Type.REIMBURSEMENT_TOTAL_BY_TEAM]: 'Reimbursement Total by Team', [Graph_Type.REIMBURSEMENT_TOTAL_BY_DIVISION]: 'Reimbursement Total by Division', [Graph_Type.CHANGE_REQUESTS_BY_STATUS]: 'Change Requests by Status', - [Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT]: 'Project Budget vs Reimbursed Amount' + [Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT]: 'Project Budget vs Reimbursed Amount', + [Graph_Type.ATTENDANCE_BY_TEAM]: 'Attendance by Team', + [Graph_Type.ATTENDANCE_BY_DIVISION]: 'Attendance by Division' }; export type GraphCollectionPlan = { @@ -110,7 +112,9 @@ const pickGraphType = (faker: Faker): Graph_Type => { weight: 8, value: Graph_Type.CHANGE_REQUESTS_BY_TEAM }, { weight: 8, value: Graph_Type.CHANGE_REQUESTS_BY_DIVISION }, { weight: 8, value: Graph_Type.CHANGE_REQUESTS_BY_STATUS }, - { weight: 6, value: Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT } + { weight: 6, value: Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT }, + { weight: 8, value: Graph_Type.ATTENDANCE_BY_TEAM }, + { weight: 6, value: Graph_Type.ATTENDANCE_BY_DIVISION } ]); const pickDisplayType = (faker: Faker, graphType: Graph_Type): Graph_Display_Type => @@ -203,6 +207,26 @@ const planGraph = ( }; }; +// Builds an all-time attendance graph (no date-range filter) so it always renders seeded +// Meeting_Attendance data. Attendance graphs have no car relation, so carIds is left empty. +const planAttendanceGraph = ( + faker: Faker, + creators: GraphActor[], + creationWindow: DateRange, + graphType: Graph_Type, + collectionIndex: number +): GraphPlan => ({ + title: GRAPH_TITLE_BY_TYPE[graphType], + graphType, + displayGraphType: Graph_Display_Type.BAR, + measure: faker.helpers.arrayElement([Measure.SUM, Measure.AVG]), + specialPermissions: [], + creatorId: faker.helpers.arrayElement(creators).userId, + dateCreated: generateRandomDate(faker, creationWindow.start, creationWindow.end), + carIds: [], + collectionIndex +}); + export const planGraphs = ( faker: Faker, collectionPlans: GraphCollectionPlan[], @@ -218,6 +242,12 @@ export const planGraphs = ( // just anywhere in the overall car span. const standaloneWindow: DateRange = { start: span.start, end: new Date(Math.min(span.end.getTime(), now.getTime())) }; + // Pick a visible (non-deleted) collection to showcase BOTH attendance graph types together. + const showcaseIndex = Math.max( + 0, + collectionPlans.findIndex((collectionPlan) => !collectionPlan.dateDeleted) + ); + collectionPlans.forEach((collectionPlan, collectionIndex) => { const count = graphsPerCollection(faker); const creationWindow: DateRange = { @@ -228,6 +258,17 @@ export const planGraphs = ( for (let i = 0; i < count; i++) { graphs.push(planGraph(faker, creators, cars, creationWindow, collectionIndex)); } + + // Guarantee every collection surfaces an attendance graph so the new attendance statistics are + // visible when browsing collections, not just as standalone graphs. One showcase collection gets + // both Team and Division; the rest alternate to keep a single attendance graph each. + if (collectionIndex === showcaseIndex) { + graphs.push(planAttendanceGraph(faker, creators, creationWindow, Graph_Type.ATTENDANCE_BY_TEAM, collectionIndex)); + graphs.push(planAttendanceGraph(faker, creators, creationWindow, Graph_Type.ATTENDANCE_BY_DIVISION, collectionIndex)); + } else { + const attendanceType = collectionIndex % 2 === 0 ? Graph_Type.ATTENDANCE_BY_TEAM : Graph_Type.ATTENDANCE_BY_DIVISION; + graphs.push(planAttendanceGraph(faker, creators, creationWindow, attendanceType, collectionIndex)); + } }); for (let i = 0; i < standaloneCount; i++) { diff --git a/src/backend/src/prisma/migrations/20260818000000_attendance_graph_type/migration.sql b/src/backend/src/prisma/migrations/20260818000000_attendance_graph_type/migration.sql new file mode 100644 index 0000000000..263b250c28 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260818000000_attendance_graph_type/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Graph_Type" ADD VALUE 'ATTENDANCE_BY_TEAM'; +ALTER TYPE "Graph_Type" ADD VALUE 'ATTENDANCE_BY_DIVISION'; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index cfc701cbb8..c008bebff1 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -101,6 +101,9 @@ enum Graph_Type { CHANGE_REQUESTS_BY_STATUS PROJECT_BUDGET_VS_REIMBURSED_AMOUNT + + ATTENDANCE_BY_TEAM + ATTENDANCE_BY_DIVISION } enum Graph_Display_Type { diff --git a/src/backend/src/prisma/seed/event.process.ts b/src/backend/src/prisma/seed/event.process.ts index f120b46799..18956669bd 100644 --- a/src/backend/src/prisma/seed/event.process.ts +++ b/src/backend/src/prisma/seed/event.process.ts @@ -11,7 +11,6 @@ import { DAYS_AFTER_NO_EVENT, documentCreateInput, eventCreateInput, - generateAttendeeCount, generateConflictStatus, generateEventCount, generateEventDateCreated, @@ -65,6 +64,19 @@ export class EventProcess extends SeedProcess> const creators = [...leadership, ...heads, ...admins, ...appAdmins]; const allUsers = [...members, ...leadership, ...heads, ...admins, ...appAdmins]; + // Preload each team's roster (members + leads + head) once so meeting-attendance seeding can + // draw attendees from the real roster without an extra query per meeting. + const teamRosters = await this.prisma.team.findMany({ + where: { teamId: { in: teams.map((team) => team.teamId) } }, + select: { teamId: true, headId: true, members: { select: { userId: true } }, leads: { select: { userId: true } } } + }); + const rosterIdsByTeamId = new Map( + teamRosters.map((team) => [ + team.teamId, + [team.headId, ...team.members.map((m) => m.userId), ...team.leads.map((l) => l.userId)] + ]) + ); + const now = new Date(); const BATCH_SIZE = 20; @@ -79,6 +91,7 @@ export class EventProcess extends SeedProcess> creators, allUsers, teams, + rosterIdsByTeamId, eventTypes, now ); @@ -96,6 +109,7 @@ export class EventProcess extends SeedProcess> creators: UsersOutput['leadership'], allUsers: UsersOutput['members'], teams: TeamOutput['teams'], + rosterIdsByTeamId: Map, eventTypes: ConfigDataOutput['eventTypes'], now: Date ) { @@ -190,19 +204,26 @@ export class EventProcess extends SeedProcess> if (shouldCreateMeetingAttendance(this.faker)) { const team = this.faker.helpers.arrayElement(teams); - const attendeeCount = generateAttendeeCount(this.faker, allUsers.length); - const attendees = this.faker.helpers.arrayElements(allUsers, attendeeCount); - await this.prisma.meeting_Attendance.create({ - data: meetingAttendanceCreateInput( - this.faker, - organizationId, - team.teamId, - creator.userId, - attendees.map((u) => u.userId), - initialDateScheduled - ) - }); + // Draw attendees from the team's own roster (members + leads + head) so the + // attendance-percentage statistics graphs report meaningful values. + const rosterIds = rosterIdsByTeamId.get(team.teamId) ?? []; + + if (rosterIds.length > 0) { + const attendeeCount = Math.max(1, Math.round(rosterIds.length * this.faker.number.float({ min: 0.4, max: 1 }))); + const attendees = this.faker.helpers.arrayElements(rosterIds, Math.min(attendeeCount, rosterIds.length)); + + await this.prisma.meeting_Attendance.create({ + data: meetingAttendanceCreateInput( + this.faker, + organizationId, + team.teamId, + creator.userId, + attendees, + initialDateScheduled + ) + }); + } } } } diff --git a/src/backend/src/services/attendance.services.ts b/src/backend/src/services/attendance.services.ts index 596dedc3f1..e6906b3461 100644 --- a/src/backend/src/services/attendance.services.ts +++ b/src/backend/src/services/attendance.services.ts @@ -18,6 +18,7 @@ import { sendMessage } from '../integrations/slack.js'; import { userHasPermission } from '../utils/users.utils.js'; +import { calculateTeamMemberAttendancePercent } from '../utils/attendance.utils.js'; export default class AttendanceService { static async takeAttendance( @@ -149,15 +150,8 @@ export default class AttendanceService { if (!attendance || attendance.closedAt) return; - const teamMemberIds = new Set([ - ...attendance.team.members.map((m) => m.userId), - ...attendance.team.leads.map((l) => l.userId), - attendance.team.headId - ]); - const attendeeIds = new Set(attendance.attendees.map((a) => a.userId)); const attendeesCount = attendance.attendees.length; - const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; - const teamMemberPercent = teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0; + const teamMemberPercent = calculateTeamMemberAttendancePercent(attendance.team, attendance.attendees); const closedMessage = `Attendance is now closed. ${attendeesCount} attended (${teamMemberPercent.toFixed(1)}% of team).`; await editMessage(attendance.slackChannelId, attendance.slackMessageTimestamp, closedMessage); diff --git a/src/backend/src/transformers/attendance.transformer.ts b/src/backend/src/transformers/attendance.transformer.ts index c9ca5edbcc..dbd8671133 100644 --- a/src/backend/src/transformers/attendance.transformer.ts +++ b/src/backend/src/transformers/attendance.transformer.ts @@ -5,18 +5,11 @@ import { MeetingAttendanceWithAttendeesQueryArgs } from '../prisma-query-args/attendance.query-args.js'; import { userTransformer } from './user.transformer.js'; +import { calculateTeamMemberAttendancePercent } from '../utils/attendance.utils.js'; export const meetingAttendanceTransformer = ( attendance: Prisma.Meeting_AttendanceGetPayload ): MeetingAttendance => { - const teamMemberIds = new Set([ - ...attendance.team.members.map((m) => m.userId), - ...attendance.team.leads.map((l) => l.userId), - attendance.team.headId - ]); - const attendeeIds = new Set(attendance.attendees.map((a) => a.userId)); - const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; - return { meetingAttendanceId: attendance.meetingAttendanceId, teamId: attendance.teamId, @@ -25,21 +18,13 @@ export const meetingAttendanceTransformer = ( openedAt: attendance.openedAt, closedAt: attendance.closedAt ?? undefined, attendeesCount: attendance.attendees.length, - teamMemberAttendancePercent: teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0 + teamMemberAttendancePercent: calculateTeamMemberAttendancePercent(attendance.team, attendance.attendees) }; }; export const meetingAttendanceWithAttendeesTransformer = ( attendance: Prisma.Meeting_AttendanceGetPayload ): MeetingAttendanceWithAttendees => { - const teamMemberIds = new Set([ - ...attendance.team.members.map((m) => m.userId), - ...attendance.team.leads.map((l) => l.userId), - attendance.team.headId - ]); - const attendeeIds = new Set(attendance.attendees.map((a) => a.userId)); - const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; - return { meetingAttendanceId: attendance.meetingAttendanceId, teamId: attendance.teamId, @@ -48,7 +33,7 @@ export const meetingAttendanceWithAttendeesTransformer = ( openedAt: attendance.openedAt, closedAt: attendance.closedAt ?? undefined, attendeesCount: attendance.attendees.length, - teamMemberAttendancePercent: teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0, + teamMemberAttendancePercent: calculateTeamMemberAttendancePercent(attendance.team, attendance.attendees), attendees: attendance.attendees.map(userTransformer) }; }; diff --git a/src/backend/src/utils/attendance.utils.ts b/src/backend/src/utils/attendance.utils.ts new file mode 100644 index 0000000000..53c4579054 --- /dev/null +++ b/src/backend/src/utils/attendance.utils.ts @@ -0,0 +1,24 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +interface TeamRoster { + headId: string; + members: { userId: string }[]; + leads: { userId: string }[]; +} + +/** + * Calculates what percentage of a team's roster (members, leads, and head) attended a meeting. + * + * @param team the team whose roster defines the denominator + * @param attendees the users who attended the meeting + * @returns the attendance percentage (0 - 100), or 0 if the team has no roster + */ +export const calculateTeamMemberAttendancePercent = (team: TeamRoster, attendees: { userId: string }[]): number => { + const teamMemberIds = new Set([...team.members.map((m) => m.userId), ...team.leads.map((l) => l.userId), team.headId]); + const attendeeIds = new Set(attendees.map((a) => a.userId)); + const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; + return teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0; +}; diff --git a/src/backend/src/utils/statistics.utils.ts b/src/backend/src/utils/statistics.utils.ts index 92ab34332d..3c0c1a3bc9 100644 --- a/src/backend/src/utils/statistics.utils.ts +++ b/src/backend/src/utils/statistics.utils.ts @@ -4,6 +4,7 @@ import prisma from '../prisma/prisma.js'; import { getGraphCollectionQueryArgs } from '../prisma-query-args/statistics.query-args.js'; import { AccessDeniedException, DeletedException, InvalidOrganizationException, NotFoundException } from './errors.utils.js'; import { userHasPermissionNew } from './users.utils.js'; +import { calculateTeamMemberAttendancePercent } from './attendance.utils.js'; interface CarSegmentedData { carIds: string[]; @@ -698,6 +699,118 @@ const getGraphDataForChangeRequestByStatus = async ( return data; }; +const getMeetingAttendanceDateWhereInput = ( + startDate: Date | null, + endDate: Date | null +): { closedAt: { not: null }; openedAt?: { gte?: Date; lte?: Date } } => { + const where: { closedAt: { not: null }; openedAt?: { gte?: Date; lte?: Date } } = { + closedAt: { not: null } + }; + + if (startDate) { + where.openedAt = { gte: startDate }; + } + + if (endDate) { + where.openedAt = { ...where.openedAt, lte: endDate }; + } + + return where; +}; + +const getGraphDataForAttendanceByTeam = async ( + measure: Measure, + organizationId: string, + startDate: Date | null, + endDate: Date | null, + _params: { carIds: string[] } +): Promise => { + const teams = await prisma.team.findMany({ + where: { organizationId, dateArchived: null }, + include: { + members: { select: { userId: true } }, + leads: { select: { userId: true } }, + meetingAttendances: { + where: getMeetingAttendanceDateWhereInput(startDate, endDate), + include: { attendees: { select: { userId: true } } } + } + } + }); + + const data: GraphData = { + tipLabel: '% Attendance', + values: teams.map((team) => { + let value = team.meetingAttendances.reduce((prev, session) => { + return prev + calculateTeamMemberAttendancePercent(team, session.attendees); + }, 0); + + if (measure === Measure.AVG && team.meetingAttendances.length > 0) { + value = value / team.meetingAttendances.length; + } + + return { + value, + label: team.teamName + }; + }) + }; + + return data; +}; + +const getGraphDataForAttendanceByDivision = async ( + measure: Measure, + organizationId: string, + startDate: Date | null, + endDate: Date | null, + _params: { carIds: string[] } +): Promise => { + const divisions = await prisma.team_Type.findMany({ + where: { organizationId, dateDeleted: null }, + include: { + teams: { + where: { dateArchived: null }, + include: { + members: { select: { userId: true } }, + leads: { select: { userId: true } }, + meetingAttendances: { + where: getMeetingAttendanceDateWhereInput(startDate, endDate), + include: { attendees: { select: { userId: true } } } + } + } + } + } + }); + + const data: GraphData = { + tipLabel: '% Attendance', + values: divisions.map((division) => { + let numSessions = 0; + + let value = division.teams.reduce((prev, team) => { + return ( + prev + + team.meetingAttendances.reduce((prev, session) => { + numSessions++; + return prev + calculateTeamMemberAttendancePercent(team, session.attendees); + }, 0) + ); + }, 0); + + if (measure === Measure.AVG && numSessions > 0) { + value = value / numSessions; + } + + return { + value, + label: division.name + }; + }) + }; + + return data; +}; + export const getGraphData = ( graphType: Graph_Type, measure: Measure, @@ -741,6 +854,10 @@ export const getGraphData = ( return getGraphDataForProjectBudgetVsReimbursedAmount(organizationId, startDate, endDate, params); case Graph_Type.CHANGE_REQUESTS_BY_STATUS: return getGraphDataForChangeRequestByStatus(organizationId, startDate, endDate, params).then((val) => [val]); + case Graph_Type.ATTENDANCE_BY_TEAM: + return getGraphDataForAttendanceByTeam(measure, organizationId, startDate, endDate, params).then((val) => [val]); + case Graph_Type.ATTENDANCE_BY_DIVISION: + return getGraphDataForAttendanceByDivision(measure, organizationId, startDate, endDate, params).then((val) => [val]); } }; @@ -790,5 +907,9 @@ export const getAxisLabels = (graphType: Graph_Type): { x: string; y: string } = return { x: 'Project', y: 'Dollars' }; case Graph_Type.CHANGE_REQUESTS_BY_STATUS: return { x: 'Status', y: '# Change Requests' }; + case Graph_Type.ATTENDANCE_BY_TEAM: + return { x: 'Team', y: '% Attendance' }; + case Graph_Type.ATTENDANCE_BY_DIVISION: + return { x: 'Division', y: '% Attendance' }; } }; diff --git a/src/backend/tests/unmocked/statistics.test.ts b/src/backend/tests/unmocked/statistics.test.ts index aca04c4bce..fcd44cab61 100644 --- a/src/backend/tests/unmocked/statistics.test.ts +++ b/src/backend/tests/unmocked/statistics.test.ts @@ -1,5 +1,16 @@ import { Graph_Type, Organization, User, Graph_Display_Type, Special_Permission } from '@prisma/client'; -import { batmanAppAdmin, supermanAdmin, theVisitorGuest, wonderwomanGuest } from '../test-data/users.test-data.js'; +import { + batmanAppAdmin, + supermanAdmin, + theVisitorGuest, + wonderwomanGuest, + member, + robinMember, + cyborgMember, + greenlanternHead, + aquamanLeadership, + flashAdmin +} from '../test-data/users.test-data.js'; import { createTestCar, createTestOrganization, @@ -550,4 +561,205 @@ describe('Statistics Tests', () => { ).rejects.toThrow(new HttpException(400, 'End date must be after start date')); }); }); + + describe('Attendance Graphs', () => { + it('Create graph works for getting sum and average attendance percent by team, excluding open sessions', async () => { + const division = await createTestTeamType('aDivision', orgId); + const team = await createTestTeam(user.userId, division.teamTypeId, orgId); + const m1 = await createTestUser(member, orgId); + const m2 = await createTestUser(robinMember, orgId); + const m3 = await createTestUser(cyborgMember, orgId); + await prisma.team.update({ + where: { teamId: team.teamId }, + data: { members: { connect: [m1, m2, m3].map((m) => ({ userId: m.userId })) } } + }); + + // 50% attendance: head + m1 out of head + m1 + m2 + m3 + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-01'), + closedAt: new Date('2024-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '1', + attendees: { connect: [user, m1].map((u) => ({ userId: u.userId })) } + } + }); + + // 100% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-08'), + closedAt: new Date('2024-01-08T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '2', + attendees: { connect: [user, m1, m2, m3].map((u) => ({ userId: u.userId })) } + } + }); + + // still open (no closedAt) - must be excluded regardless of attendees + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-15'), + closedAt: null, + slackChannelId: 'aChannel', + slackMessageTimestamp: '3', + attendees: { connect: [{ userId: user.userId }] } + } + }); + + // closed, but outside the queried date range - must be excluded + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2020-01-01'), + closedAt: new Date('2020-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '4', + attendees: { connect: [user, m1, m2, m3].map((u) => ({ userId: u.userId })) } + } + }); + + const sumResult = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_TEAM, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(sumResult.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: team.teamName, + value: 150 + } + ] + } + ]); + + const avgResult = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_TEAM, + Measure.AVG, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(avgResult.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: team.teamName, + value: 75 + } + ] + } + ]); + }); + + it('Create graph works for getting average attendance percent by division, aggregating across teams', async () => { + const division = await createTestTeamType('aDivision', orgId); + const teamA = await createTestTeam(user.userId, division.teamTypeId, orgId); + const teamAMember1 = await createTestUser(member, orgId); + const teamAMember2 = await createTestUser(robinMember, orgId); + const teamAMember3 = await createTestUser(cyborgMember, orgId); + await prisma.team.update({ + where: { teamId: teamA.teamId }, + data: { + members: { connect: [teamAMember1, teamAMember2, teamAMember3].map((m) => ({ userId: m.userId })) } + } + }); + + const teamBHead = await createTestUser(greenlanternHead, orgId); + const teamB = await createTestTeam(teamBHead.userId, division.teamTypeId, orgId); + const teamBMember1 = await createTestUser(aquamanLeadership, orgId); + const teamBMember2 = await createTestUser(wonderwomanGuest, orgId); + const teamBMember3 = await createTestUser(flashAdmin, orgId); + await prisma.team.update({ + where: { teamId: teamB.teamId }, + data: { + members: { connect: [teamBMember1, teamBMember2, teamBMember3].map((m) => ({ userId: m.userId })) } + } + }); + + // team A: 50% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamA.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-01'), + closedAt: new Date('2024-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '1', + attendees: { connect: [user, teamAMember1].map((u) => ({ userId: u.userId })) } + } + }); + + // team B: 100% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamB.teamId, + userCreatedId: teamBHead.userId, + openedAt: new Date('2024-01-08'), + closedAt: new Date('2024-01-08T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '2', + attendees: { + connect: [teamBHead, teamBMember1, teamBMember2, teamBMember3].map((u) => ({ userId: u.userId })) + } + } + }); + + const result = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_DIVISION, + Measure.AVG, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(result.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: division.name, + value: 75 + } + ] + } + ]); + }); + }); }); diff --git a/src/shared/src/types/statistics-types.ts b/src/shared/src/types/statistics-types.ts index 7ffbaeed65..4b04a4d869 100644 --- a/src/shared/src/types/statistics-types.ts +++ b/src/shared/src/types/statistics-types.ts @@ -17,7 +17,9 @@ export enum GraphType { REIMBURSEMENT_TOTAL_BY_PROJECT = 'REIMBURSEMENT_TOTAL_BY_PROJECT', REIMBURSEMENT_TOTAL_BY_TEAM = 'REIMBURSEMENT_TOTAL_BY_TEAM', PROJECT_BUDGET_VS_REIMBURSED_AMOUNT = 'PROJECT_BUDGET_VS_REIMBURSED_AMOUNT', - CHANGE_REQUESTS_BY_STATUS = 'CHANGE_REQUESTS_BY_STATUS' + CHANGE_REQUESTS_BY_STATUS = 'CHANGE_REQUESTS_BY_STATUS', + ATTENDANCE_BY_TEAM = 'ATTENDANCE_BY_TEAM', + ATTENDANCE_BY_DIVISION = 'ATTENDANCE_BY_DIVISION' } export enum SpecialPermission {