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
45 changes: 43 additions & 2 deletions src/backend/src/prisma/factories/graphs.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ const GRAPH_TITLE_BY_TYPE: Record<Graph_Type, string> = {
[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 = {
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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[],
Expand All @@ -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 = {
Expand All @@ -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++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions src/backend/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 34 additions & 13 deletions src/backend/src/prisma/seed/event.process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
DAYS_AFTER_NO_EVENT,
documentCreateInput,
eventCreateInput,
generateAttendeeCount,
generateConflictStatus,
generateEventCount,
generateEventDateCreated,
Expand Down Expand Up @@ -65,6 +64,19 @@ export class EventProcess extends SeedProcess<EventInput, Record<string, never>>
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<string, string[]>(
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;
Expand All @@ -79,6 +91,7 @@ export class EventProcess extends SeedProcess<EventInput, Record<string, never>>
creators,
allUsers,
teams,
rosterIdsByTeamId,
eventTypes,
now
);
Expand All @@ -96,6 +109,7 @@ export class EventProcess extends SeedProcess<EventInput, Record<string, never>>
creators: UsersOutput['leadership'],
allUsers: UsersOutput['members'],
teams: TeamOutput['teams'],
rosterIdsByTeamId: Map<string, string[]>,
eventTypes: ConfigDataOutput['eventTypes'],
now: Date
) {
Expand Down Expand Up @@ -190,19 +204,26 @@ export class EventProcess extends SeedProcess<EventInput, Record<string, never>>

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
)
});
}
}
}
}
Expand Down
10 changes: 2 additions & 8 deletions src/backend/src/services/attendance.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 3 additions & 18 deletions src/backend/src/transformers/attendance.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MeetingAttendanceQueryArgs>
): 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,
Expand All @@ -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<MeetingAttendanceWithAttendeesQueryArgs>
): 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,
Expand All @@ -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)
};
};
24 changes: 24 additions & 0 deletions src/backend/src/utils/attendance.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading