Skip to content

Commit ddc19d8

Browse files
authored
Background Work - Quiz pass status (#168)
* Rename getFullQuizResults for equipment * Tests for getFullQuizResultsForMember * Implement getting quiz results on a per-member basis * Tests pass * Typecheck pass * Fix merge conflict issue
1 parent c480fc0 commit ddc19d8

File tree

18 files changed

+279
-80
lines changed

18 files changed

+279
-80
lines changed

.devcontainer/devcontainer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"customizations": {
1414
"vscode": {
1515
"extensions": [
16-
"dbaeumer.vscode-eslint"
16+
"dbaeumer.vscode-eslint",
17+
"Orta.vscode-jest"
1718
]
1819
}
1920
}

src/dependencies.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export type Dependencies = {
4646
sheetId: string,
4747
from: O.Option<Date>
4848
) => TE.TaskEither<string, SheetDataTable['rows']>;
49+
getSheetDataByMemberNumber: (
50+
memberNumber: number,
51+
) => TE.TaskEither<string, SheetDataTable['rows']>,
4952
getTroubleTicketData: (
5053
from: O.Option<Date>
5154
) => TE.TaskEither<string, O.Option<TroubleTicketDataTable['rows']>>;

src/init-dependencies/init-dependencies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {Client} from '@libsql/client';
1212

1313
import {initSharedReadModel} from '../read-models/shared-state';
1414
import {lastSync} from '../sync-worker/db/last_sync';
15-
import {getSheetData} from '../sync-worker/db/get_sheet_data';
15+
import {getSheetData, getSheetDataByMemberNumber} from '../sync-worker/db/get_sheet_data';
1616
import {getTroubleTicketData} from '../sync-worker/db/get_trouble_ticket_data';
1717

1818
export const initLogger = (conf: Config) => {
@@ -79,6 +79,7 @@ export const initDependencies = (
7979
logger,
8080
lastQuizSync: lastSync(googleDB),
8181
getSheetData: getSheetData(googleDB),
82+
getSheetDataByMemberNumber: getSheetDataByMemberNumber(googleDB),
8283
getTroubleTicketData: getTroubleTicketData(
8384
googleDB,
8485
O.fromNullable(conf.TROUBLE_TICKET_SHEET)

src/queries/equipment/construct-view-model.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {User} from '../../types';
1111
import {UUID} from 'io-ts-types';
1212
import {StatusCodes} from 'http-status-codes';
1313
import {
14-
FullQuizResults,
15-
getFullQuizResults,
14+
FullQuizResultsForEquipment,
15+
getFullQuizResultsForEquipment,
1616
} from '../../read-models/external-state/equipment-quiz';
1717

1818
export const constructViewModel =
@@ -63,8 +63,8 @@ export const constructViewModel =
6363
return TE.right(O.none);
6464
}
6565
return pipe(
66-
getFullQuizResults(deps, equipment.trainingSheetId.value, equipment),
67-
TE.map<FullQuizResults, O.Option<FullQuizResults>>(O.some),
66+
getFullQuizResultsForEquipment(deps, equipment.trainingSheetId.value, equipment),
67+
TE.map<FullQuizResultsForEquipment, O.Option<FullQuizResultsForEquipment>>(O.some),
6868
TE.mapLeft(err_str => {
6969
deps.logger.error(
7070
'Failed to read sheet sync metadata: %s',

src/queries/equipment/view-model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {FullQuizResults} from '../../read-models/external-state/equipment-quiz';
1+
import {FullQuizResultsForEquipment} from '../../read-models/external-state/equipment-quiz';
22
import {Equipment} from '../../read-models/shared-state/return-types';
33
import {User} from '../../types';
44
import * as O from 'fp-ts/Option';
@@ -9,5 +9,5 @@ export type ViewModel = {
99
isSuperUserOrTrainerOfArea: boolean;
1010
isSuperUser: boolean;
1111
equipment: Equipment;
12-
quizResults: O.Option<FullQuizResults>;
12+
quizResults: O.Option<FullQuizResultsForEquipment>;
1313
};

src/read-models/external-state/equipment-quiz.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import * as O from 'fp-ts/Option';
22
import * as TE from 'fp-ts/TaskEither';
33
import * as RA from 'fp-ts/ReadonlyArray';
4+
import * as RR from 'fp-ts/ReadonlyRecord';
45

56
import {Dependencies} from '../../dependencies';
67
import {SheetDataTable} from '../../sync-worker/google/sheet-data-table';
78
import {pipe} from 'fp-ts/lib/function';
89
import {Equipment, MemberCoreInfo} from '../shared-state/return-types';
910
import {DateTime, Duration} from 'luxon';
11+
import { ReadonlyRecord } from 'fp-ts/lib/ReadonlyRecord';
12+
import { EquipmentId } from '../../types/equipment-id';
1013

1114
export type EquipmentQuizResults = {
1215
passedQuizes: SheetDataTable['rows'];
@@ -27,20 +30,23 @@ export type MemberAwaitingTraining = Pick<
2730
waitingSince: Date;
2831
};
2932

33+
const isPassed = (row: SheetDataTable['rows'][0]) => row.percentage >= 100;
34+
const isFailed = (row: SheetDataTable['rows'][0]) => !isPassed(row);
35+
3036
const extractPassedQuizes = (
3137
sheetData: SheetDataTable['rows']
3238
): SheetDataTable['rows'] =>
3339
pipe(
3440
sheetData,
35-
RA.filter(row => row.percentage === 100)
41+
RA.filter(isPassed)
3642
);
3743

3844
const extractFailedQuizes = (
3945
sheetData: SheetDataTable['rows']
4046
): SheetDataTable['rows'] =>
4147
pipe(
4248
sheetData,
43-
RA.filter(row => row.percentage < 100)
49+
RA.filter(isFailed)
4450
);
4551

4652
const getQuizResults = (
@@ -70,18 +76,18 @@ const getQuizResults = (
7076
);
7177
};
7278

73-
export type FullQuizResults = {
79+
export type FullQuizResultsForEquipment = {
7480
lastQuizSync: O.Option<Date>;
7581
membersAwaitingTraining: ReadonlyArray<MemberAwaitingTraining>;
7682
unknownMembersAwaitingTraining: ReadonlyArray<OrphanedPassedQuiz>;
7783
failedQuizes: SheetDataTable['rows'];
7884
};
7985

80-
export const getFullQuizResults = (
86+
export const getFullQuizResultsForEquipment = (
8187
deps: Pick<Dependencies, 'sharedReadModel' | 'lastQuizSync' | 'getSheetData'>,
8288
sheetId: string,
8389
equipment: Equipment
84-
): TE.TaskEither<string, FullQuizResults> =>
90+
): TE.TaskEither<string, FullQuizResultsForEquipment> =>
8591
pipe(
8692
getQuizResults(deps, sheetId, equipment.trainedMembers),
8793
TE.map(qr => {
@@ -124,3 +130,71 @@ export const getFullQuizResults = (
124130
};
125131
})
126132
);
133+
134+
export type FullQuizResultsForMember = {
135+
equipmentQuizPassedAt: ReadonlyRecord<EquipmentId, ReadonlyArray<Date>>,
136+
equipmentQuizAttempted: ReadonlyRecord<EquipmentId, ReadonlyArray<{
137+
response_submitted: Date,
138+
sheet_id: string;
139+
score: number;
140+
max_score: number;
141+
percentage: number;
142+
}>>,
143+
orphanedQuizAttempts: ReadonlyArray<{
144+
response_submitted: Date,
145+
sheet_id: string;
146+
score: number;
147+
max_score: number;
148+
percentage: number;
149+
}>,
150+
};
151+
152+
export const getFullQuizResultsForMember = (
153+
deps: Pick<Dependencies, 'sharedReadModel' | 'getSheetDataByMemberNumber'>,
154+
memberNumber: number
155+
): TE.TaskEither<string, FullQuizResultsForMember> => pipe(
156+
deps.getSheetDataByMemberNumber(memberNumber),
157+
TE.map(
158+
qr => {
159+
const equipmentQuizPassedAt: Record<EquipmentId, Date[]> = {};
160+
const equipmentQuizAttempted: Record<EquipmentId, {
161+
response_submitted: Date,
162+
sheet_id: string;
163+
score: number;
164+
max_score: number;
165+
percentage: number;
166+
}[]> = {};
167+
const orphanedQuizAttempts: {
168+
response_submitted: Date,
169+
sheet_id: string;
170+
score: number;
171+
max_score: number;
172+
percentage: number;
173+
}[] = [];
174+
const trainingSheetMapping = deps.sharedReadModel.equipment.getTrainingSheetIdMapping();
175+
for (const row of qr) {
176+
const equipmentId = RR.lookup(row.sheet_id)(trainingSheetMapping);
177+
if (O.isNone(equipmentId)) {
178+
orphanedQuizAttempts.push(row);
179+
} else {
180+
if (isPassed(row)) {
181+
if (!equipmentQuizPassedAt[equipmentId.value]) {
182+
equipmentQuizPassedAt[equipmentId.value] = [];
183+
}
184+
equipmentQuizPassedAt[equipmentId.value].push(row.response_submitted);
185+
} else {
186+
if (!equipmentQuizAttempted[equipmentId.value]) {
187+
equipmentQuizAttempted[equipmentId.value] = [];
188+
}
189+
equipmentQuizAttempted[equipmentId.value].push(row);
190+
}
191+
}
192+
}
193+
return {
194+
equipmentQuizPassedAt,
195+
equipmentQuizAttempted,
196+
orphanedQuizAttempts,
197+
}
198+
}
199+
)
200+
);

src/read-models/shared-state/equipment/get.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import {pipe} from 'fp-ts/lib/function';
22
import {BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
3-
import {eq} from 'drizzle-orm';
3+
import {eq, isNotNull} from 'drizzle-orm';
44
import * as O from 'fp-ts/Option';
55
import * as RA from 'fp-ts/ReadonlyArray';
6+
import * as RR from 'fp-ts/ReadonlyRecord';
67
import {equipmentTable} from '../state';
78
import {MinimalEquipment} from '../return-types';
89
import {UUID} from 'io-ts-types';
10+
import { ReadonlyRecord } from 'fp-ts/lib/ReadonlyRecord';
11+
import { TrainingSheetId } from '../../../types/training-sheet';
12+
import { EquipmentId } from '../../../types/equipment-id';
913

1014
const transformRow = <
1115
R extends {
@@ -47,3 +51,18 @@ export const getAllEquipmentMinimal = (
4751
db: BetterSQLite3Database
4852
): ReadonlyArray<MinimalEquipment> =>
4953
pipe(db.select().from(equipmentTable).all(), RA.map(transformRow));
54+
55+
export const getTrainingSheetIdMapping = (
56+
db: BetterSQLite3Database
57+
) => (): ReadonlyRecord<TrainingSheetId, EquipmentId> =>
58+
pipe(
59+
db.select({
60+
trainingSheetId: equipmentTable.trainingSheetId,
61+
id: equipmentTable.id,
62+
}).from(equipmentTable).where(isNotNull(equipmentTable.trainingSheetId)).all(),
63+
RA.map(
64+
row => ([row.trainingSheetId!, row.id as UUID])
65+
),
66+
(x: ReadonlyArray<[string, UUID]>) => x,
67+
RR.fromEntries
68+
)

src/read-models/shared-state/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import {dumpCurrentState, SharedDatabaseDump} from './debug/dump';
2727
import {MemberLinking} from './member-linking';
2828
import {DateTime} from 'luxon';
2929
import {getLastSent} from './training-stat-notifications/get-last-sent';
30+
import { ReadonlyRecord } from 'fp-ts/lib/ReadonlyRecord';
31+
import { TrainingSheetId } from '../../types/training-sheet';
32+
import { EquipmentId } from '../../types/equipment-id';
33+
import { getTrainingSheetIdMapping } from './equipment/get';
3034

3135
export type SharedReadModel = {
3236
db: BetterSQLite3Database;
@@ -44,6 +48,7 @@ export type SharedReadModel = {
4448
equipment: {
4549
get: (id: UUID) => O.Option<Equipment>;
4650
getAll: () => ReadonlyArray<Equipment>;
51+
getTrainingSheetIdMapping: () => ReadonlyRecord<TrainingSheetId, EquipmentId>;
4752
};
4853
area: {
4954
get: (id: UUID) => O.Option<Area>;
@@ -94,6 +99,7 @@ export const initSharedReadModel = (
9499
equipment: {
95100
get: getEquipmentFull(readModelDb, linking),
96101
getAll: getAllEquipmentFull(readModelDb, linking),
102+
getTrainingSheetIdMapping: getTrainingSheetIdMapping(readModelDb),
97103
},
98104
area: {
99105
get: getAreaFull(readModelDb, linking),

src/read-models/shared-state/member-linking.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type MemberNumber = number;
1+
import { MemberNumber } from "../../types/member-number";
22

33
export class MemberLinking {
44
// Stores the linking between member numbers.

src/sync-worker/db/get_sheet_data.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,33 @@ export const getSheetData =
4949
),
5050
TE.map(data => data.rows)
5151
);
52+
53+
export const getSheetDataByMemberNumber =
54+
(googleDB: Client) =>
55+
(
56+
memberNumber: number,
57+
): TE.TaskEither<string, SheetDataTable['rows']> =>
58+
pipe(
59+
TE.tryCatch<string, ResultSet>(
60+
() => googleDB.execute(
61+
` SELECT *
62+
FROM sheet_data
63+
WHERE member_number_provided = ?
64+
`, [memberNumber]
65+
),
66+
reason =>
67+
`Failed to get sheet data for memberNumber '${memberNumber}': ${(reason as Error).message}`
68+
),
69+
TE.flatMapEither<ResultSet, string, SheetDataTable>(data =>
70+
pipe(
71+
data,
72+
SheetDataTable.decode,
73+
E.mapLeft(
74+
e =>
75+
'Failed to pull sheet data due to malformed data: ' +
76+
formatValidationErrors(e).join(',')
77+
)
78+
)
79+
),
80+
TE.map(data => data.rows)
81+
);

0 commit comments

Comments
 (0)