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
40 changes: 40 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ model ScoutReport {
disrupts Boolean
endgameClimb EndgameClimb
autoClimb AutoClimb

customFieldAnswers CustomFieldAnswer[]
}

model CustomField {
uuid String @id @default(uuid())
teamNumber Int
name String
type CustomFieldType
options String[] @default([])
order Int
archived Boolean @default(false)
createdAt DateTime @default(now())
sourceTeam RegisteredTeam @relation(fields: [teamNumber], references: [number], onDelete: Cascade)
answers CustomFieldAnswer[]

@@index([teamNumber, archived])
}

model CustomFieldAnswer {
uuid String @id @default(uuid())
scoutReportUuid String
fieldUuid String
textValue String?
numberValue Float?
selections String[] @default([])
scoutReport ScoutReport @relation(fields: [scoutReportUuid], references: [uuid], onDelete: Cascade)
field CustomField @relation(fields: [fieldUuid], references: [uuid], onDelete: Cascade)

@@unique([scoutReportUuid, fieldUuid])
@@index([fieldUuid])
}

model ScouterScheduleShift {
Expand Down Expand Up @@ -131,6 +162,7 @@ model SharedPicklist {
totalDefensiveTime Float
totalFuelFed Float
totalFuelThroughput Float
customFieldWeights Json @default("{}")
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}

Expand All @@ -153,6 +185,7 @@ model RegisteredTeam {
scouterScheduleShifts ScouterScheduleShift[]
slackChannels SlackWorkspace[]
users User[]
customFields CustomField[]
}

model EmailVerificationRequest {
Expand Down Expand Up @@ -335,3 +368,10 @@ enum MatchType {
QUALIFICATION
ELIMINATION
}

enum CustomFieldType {
TEXT
NUMBER
SINGLE_SELECT
MULTI_SELECT
}
52 changes: 43 additions & 9 deletions src/handler/analysis/analysisHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@
params: AnalysisHandlerParamsSchema<T, U, V>;
createKey: (
params: AnalysisHandlerParams<T, U, V>,
ctx: AnalysisContext,
) => Promise<CreateKeyResult> | CreateKeyResult;
calculateAnalysis: (
params: AnalysisHandlerParams<T, U, V>,
ctx: AnalysisContext,
) => Promise<any>;

Check warning on line 42 in src/handler/analysis/analysisHandler.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

Unexpected any. Specify a different type
// Optional hook run after the cache read on both hit and miss paths (and on
// the shouldCache:false path) whenever the result has no .error. Its return
// value is sent to the client but is NEVER written to the cache — the cache
// always stores the raw calculated result.
augmentResponse?: (
params: AnalysisHandlerParams<T, U, V>,
ctx: AnalysisContext,
result: any,

Check warning on line 50 in src/handler/analysis/analysisHandler.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

Unexpected any. Specify a different type
) => Promise<any> | any;

Check warning on line 51 in src/handler/analysis/analysisHandler.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

Unexpected any. Specify a different type

Check warning on line 51 in src/handler/analysis/analysisHandler.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

Unexpected any. Specify a different type
usesDataSource: boolean;
shouldCache: boolean;
};
Expand Down Expand Up @@ -75,7 +85,16 @@
let calculatedAnalysis = null;
calculatedAnalysis = await args.calculateAnalysis(params, context);

res.status(200).send(calculatedAnalysis.error ?? calculatedAnalysis);
let responseBody = calculatedAnalysis.error ?? calculatedAnalysis;
if (!calculatedAnalysis.error && args.augmentResponse) {
responseBody = await args.augmentResponse(
params,
context,
calculatedAnalysis,
);
}

res.status(200).send(responseBody);
} catch (error) {
res.status(500).send("Error calculating analysis");
console.error(error);
Expand All @@ -89,7 +108,7 @@
key: keyFragments,
teamDependencies: teamDeps,
tournamentDependencies: tournamentDeps,
} = await args.createKey(params);
} = await args.createKey(params, context);

const teamSourceRule = dataSourceRuleSchema(z.number()).parse(
context.dataSource.teams,
Expand Down Expand Up @@ -119,8 +138,17 @@
context,
);

let responseBody = calculatedAnalysis.error ?? calculatedAnalysis;
if (!calculatedAnalysis.error && args.augmentResponse) {
responseBody = await args.augmentResponse(
params,
context,
calculatedAnalysis,
);
}

res.set("X-Lovat-Cache", "miss");
res.status(200).send(calculatedAnalysis.error ?? calculatedAnalysis);
res.status(200).send(responseBody);

try {
await kv.set(key, JSON.stringify(calculatedAnalysis));
Expand All @@ -133,7 +161,7 @@
tournamentDependencies: tournamentDeps ?? [],
},
});
} catch (e: any) {

Check warning on line 164 in src/handler/analysis/analysisHandler.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

Unexpected any. Specify a different type
if (e?.code !== "P2002") throw e;
// Ignore duplicate key; another request already created the row
}
Expand All @@ -147,13 +175,19 @@
return;
}
} else {
res.set("X-Lovat-Cache", "hit");
res
.status(200)
.send(
JSON.parse(cacheRow.toString()).error ??
JSON.parse(cacheRow.toString()),
const cachedAnalysis = JSON.parse(cacheRow.toString());

let responseBody = cachedAnalysis.error ?? cachedAnalysis;
if (!cachedAnalysis.error && args.augmentResponse) {
responseBody = await args.augmentResponse(
params,
context,
cachedAnalysis,
);
}

res.set("X-Lovat-Cache", "hit");
res.status(200).send(responseBody);
}
} catch (error) {
if (error instanceof z.ZodError) {
Expand Down
57 changes: 55 additions & 2 deletions src/handler/analysis/csv/getReportCSV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import {
dataSourceRuleSchema,
} from "../dataSourceRule.js";
import { averageScoutReport } from "../coreAnalysis/averageScoutReport.js";
import {
buildCustomColumnLabels,
formatAnswerForCsv,
getActiveCustomFields,
sanitizeCsv,
} from "../customFields/customFieldShared.js";

// Scouting report condensed into a single dimension that can be pushed to a row in the csv
export interface CondensedReport {
Expand Down Expand Up @@ -238,11 +244,58 @@ export const getReportCSV = async (
),
);

// Append one column per active custom field (all types) after the notes
// key, in field order, on EVERY row so Object.keys stays stable. Values
// are blank for reports from other teams or predating the field.
let rows: object[] = condensed;
if (req.user.teamNumber !== null && req.user.teamNumber !== undefined) {
const customFields = await getActiveCustomFields(req.user.teamNumber);
if (customFields.length > 0) {
const labels = buildCustomColumnLabels(
customFields.map((field) => ({
uuid: field.uuid,
name: sanitizeCsv(field.name),
})),
);

const answers = await prismaClient.customFieldAnswer.findMany({
where: {
scoutReportUuid: { in: datapoints.map((r) => r.uuid) },
fieldUuid: { in: customFields.map((field) => field.uuid) },
},
});

const answersByReport = new Map<
string,
Map<string, (typeof answers)[number]>
>();
for (const answer of answers) {
if (!answersByReport.has(answer.scoutReportUuid)) {
answersByReport.set(answer.scoutReportUuid, new Map());
}
answersByReport.get(answer.scoutReportUuid).set(answer.fieldUuid, answer);
}

// condensed is index-parallel to datapoints
rows = condensed.map((row, i) => {
const reportAnswers = answersByReport.get(datapoints[i].uuid);
const withCustom: Record<string, unknown> = { ...row };
for (const field of customFields) {
withCustom[`${labels[field.uuid]} (Custom)`] = formatAnswerForCsv(
field,
reportAnswers?.get(field.uuid) ?? null,
);
}
return withCustom;
});
}
}

// Create and send the csv string through express
const csvString = stringify(condensed, {
const csvString = stringify(rows, {
header: true,
// Creates column headers from data properties
columns: condensed.length ? Object.keys(condensed[0]) : [],
columns: rows.length ? Object.keys(rows[0]) : [],
// Required for excel viewing
bom: true,
// Rename boolean values to TRUE and FALSE
Expand Down
66 changes: 60 additions & 6 deletions src/handler/analysis/csv/getTeamCSV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
ClimbSide,
FeederType,
IntakeType,
CustomFieldType,
User,
} from "@prisma/client";
import { autoEnd, endgameToPoints, Metric } from "../analysisConstants.js";
import { z } from "zod";
Expand All @@ -22,6 +24,12 @@ import {
dataSourceRuleSchema,
} from "../dataSourceRule.js";
import { averageManyFast } from "../coreAnalysis/averageManyFast.js";
import { customFieldNumberManyFast } from "../customFields/customFieldNumberAverages.js";
import {
buildCustomColumnLabels,
getActiveCustomFields,
sanitizeCsv,
} from "../customFields/customFieldShared.js";

interface AggregatedTeamData {
teamNumber: number;
Expand Down Expand Up @@ -365,11 +373,11 @@ export const getTeamCSV = async (
}),
);

const csvString = stringify(aggregatedData, {
const rows = await appendCustomFieldColumns(req.user, aggregatedData);

const csvString = stringify(rows, {
header: true,
columns: aggregatedData.length
? Object.keys(aggregatedData[0])
: [],
columns: rows.length ? Object.keys(rows[0]) : [],
bom: true,
cast: {
boolean: (b) => (b ? "TRUE" : "FALSE"),
Expand Down Expand Up @@ -459,10 +467,12 @@ export const getTeamCSV = async (
);
}),
);
const csvString = stringify(aggregatedData, {
const rows = await appendCustomFieldColumns(req.user, aggregatedData);

const csvString = stringify(rows, {
header: true,
// Creates column headers from data properties
columns: aggregatedData.length ? Object.keys(aggregatedData[0]) : [],
columns: rows.length ? Object.keys(rows[0]) : [],
// Required for excel viewing
bom: true,
// Rename boolean values to TRUE and FALSE
Expand All @@ -483,6 +493,50 @@ export const getTeamCSV = async (
}
};

/**
* Appends one "Avg <name> (Custom)" column per active custom NUMBER field for
* the viewer's team, on every row so Object.keys stays stable. Cells are blank
* when a team has no answers for the field. Rows are returned unchanged when
* the viewer has no team or no active NUMBER fields.
*/
async function appendCustomFieldColumns(
user: User,
aggregatedData: AggregatedTeamData[],
): Promise<object[]> {
if (user?.teamNumber === null || user?.teamNumber === undefined) {
return aggregatedData;
}

const customFields = await getActiveCustomFields(user.teamNumber, [
CustomFieldType.NUMBER,
]);
if (customFields.length === 0) {
return aggregatedData;
}

const labels = buildCustomColumnLabels(
customFields.map((field) => ({
uuid: field.uuid,
name: sanitizeCsv(field.name),
})),
);

const averages = await customFieldNumberManyFast(user, {
viewerTeam: user.teamNumber,
teams: aggregatedData.map((row) => row.teamNumber),
fieldUuids: customFields.map((field) => field.uuid),
});

return aggregatedData.map((row) => {
const withCustom: Record<string, unknown> = { ...row };
for (const field of customFields) {
const average = averages[field.uuid]?.[String(row.teamNumber)] ?? null;
withCustom[`Avg ${labels[field.uuid]} (Custom)`] = average ?? "";
}
return withCustom;
});
}

async function aggregateTeamReports(
teamNum: number,
numMatches: number,
Expand Down
Loading
Loading