@trafficgroup/knex-rel 0.0.28 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.claude/settings.local.json +2 -5
  2. package/CLAUDE.md +72 -63
  3. package/dist/dao/VideoMinuteResultDAO.d.ts +149 -0
  4. package/dist/dao/VideoMinuteResultDAO.js +604 -0
  5. package/dist/dao/VideoMinuteResultDAO.js.map +1 -0
  6. package/dist/dao/auth/auth.dao.js +4 -1
  7. package/dist/dao/auth/auth.dao.js.map +1 -1
  8. package/dist/dao/chat/chat.dao.d.ts +1 -1
  9. package/dist/dao/chat/chat.dao.js +25 -35
  10. package/dist/dao/chat/chat.dao.js.map +1 -1
  11. package/dist/dao/folder/folder.dao.js +7 -2
  12. package/dist/dao/folder/folder.dao.js.map +1 -1
  13. package/dist/dao/message/message.dao.d.ts +1 -1
  14. package/dist/dao/message/message.dao.js +18 -26
  15. package/dist/dao/message/message.dao.js.map +1 -1
  16. package/dist/dao/study/study.dao.js +7 -2
  17. package/dist/dao/study/study.dao.js.map +1 -1
  18. package/dist/dao/user/user.dao.js +4 -1
  19. package/dist/dao/user/user.dao.js.map +1 -1
  20. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +26 -8
  21. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
  22. package/dist/dao/video/video.dao.d.ts +8 -0
  23. package/dist/dao/video/video.dao.js +54 -7
  24. package/dist/dao/video/video.dao.js.map +1 -1
  25. package/dist/entities/BaseEntity.d.ts +4 -0
  26. package/dist/entities/BaseEntity.js +3 -0
  27. package/dist/entities/BaseEntity.js.map +1 -0
  28. package/dist/entities/VideoMinuteResult.d.ts +21 -0
  29. package/dist/entities/VideoMinuteResult.js +3 -0
  30. package/dist/entities/VideoMinuteResult.js.map +1 -0
  31. package/dist/index.d.ts +4 -2
  32. package/dist/index.js +3 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
  35. package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
  36. package/dist/interfaces/message/message.interfaces.d.ts +2 -2
  37. package/dist/interfaces/study/study.interfaces.d.ts +1 -1
  38. package/dist/interfaces/user/user.interfaces.d.ts +1 -1
  39. package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
  40. package/dist/interfaces/video/video.interfaces.d.ts +7 -2
  41. package/migrations/20250717160737_migration.ts +1 -1
  42. package/migrations/20250717160908_migration.ts +5 -2
  43. package/migrations/20250717161310_migration.ts +1 -1
  44. package/migrations/20250717161406_migration.ts +3 -3
  45. package/migrations/20250717162431_migration.ts +1 -1
  46. package/migrations/20250717173228_migration.ts +2 -2
  47. package/migrations/20250717204731_migration.ts +1 -1
  48. package/migrations/20250722210109_migration.ts +8 -4
  49. package/migrations/20250722211019_migration.ts +1 -1
  50. package/migrations/20250723153852_migration.ts +13 -10
  51. package/migrations/20250723162257_migration.ts +4 -7
  52. package/migrations/20250723171109_migration.ts +4 -7
  53. package/migrations/20250723205331_migration.ts +6 -9
  54. package/migrations/20250724191345_migration.ts +8 -11
  55. package/migrations/20250730180932_migration.ts +14 -13
  56. package/migrations/20250730213625_migration.ts +8 -11
  57. package/migrations/20250804124509_migration.ts +26 -21
  58. package/migrations/20250804132053_migration.ts +5 -8
  59. package/migrations/20250804164518_migration.ts +7 -7
  60. package/migrations/20250823223016_migration.ts +46 -0
  61. package/migrations/20250910015452_migration.ts +34 -0
  62. package/package.json +47 -47
  63. package/src/d.types.ts +22 -18
  64. package/src/dao/VideoMinuteResultDAO.ts +790 -0
  65. package/src/dao/auth/auth.dao.ts +58 -55
  66. package/src/dao/chat/chat.dao.ts +45 -43
  67. package/src/dao/folder/folder.dao.ts +75 -66
  68. package/src/dao/message/message.dao.ts +32 -32
  69. package/src/dao/study/study.dao.ts +75 -66
  70. package/src/dao/user/user.dao.ts +59 -57
  71. package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +83 -49
  72. package/src/dao/video/video.dao.ts +199 -120
  73. package/src/entities/BaseEntity.ts +4 -0
  74. package/src/entities/VideoMinuteResult.ts +24 -0
  75. package/src/index.ts +37 -23
  76. package/src/interfaces/auth/auth.interfaces.ts +10 -10
  77. package/src/interfaces/chat/chat.interfaces.ts +4 -4
  78. package/src/interfaces/folder/folder.interfaces.ts +13 -13
  79. package/src/interfaces/message/message.interfaces.ts +3 -3
  80. package/src/interfaces/study/study.interfaces.ts +12 -12
  81. package/src/interfaces/user/user.interfaces.ts +11 -11
  82. package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
  83. package/src/interfaces/video/video.interfaces.ts +31 -21
@@ -0,0 +1,790 @@
1
+ import { IBaseDAO, IDataPaginator } from "../d.types";
2
+ import {
3
+ IVideoMinuteResult,
4
+ IVideoMinuteResultInput,
5
+ IVideoMinuteBatch,
6
+ } from "../entities/VideoMinuteResult";
7
+ import KnexManager from "../KnexConnection";
8
+
9
+ // Type definitions for aggregated results
10
+ interface ITMCTurnMovements {
11
+ straight: number;
12
+ left: number;
13
+ right: number;
14
+ "u-turn": number;
15
+ [key: string]: number; // Allow dynamic turn types
16
+ }
17
+
18
+ interface ITMCDirections {
19
+ [direction: string]: ITMCTurnMovements;
20
+ }
21
+
22
+ interface ITMCVehicles {
23
+ [vehicleClass: string]: ITMCDirections;
24
+ }
25
+
26
+ interface ITMCResult {
27
+ vehicles: ITMCVehicles;
28
+ counts: {
29
+ total_vehicles: number;
30
+ entry_vehicles: number;
31
+ };
32
+ total: number;
33
+ totalcount: number;
34
+ detected_classes: { [vehicleClass: string]: number };
35
+ study_type: "TMC";
36
+ }
37
+
38
+ interface IATRVehicles {
39
+ [vehicleClass: string]: { [lane: string]: number };
40
+ }
41
+
42
+ interface IATRResult {
43
+ vehicles: IATRVehicles;
44
+ lane_counts: { [lane: string]: number };
45
+ total_count: number;
46
+ detected_classes: { [vehicleClass: string]: number };
47
+ study_type: "ATR";
48
+ }
49
+
50
+ interface IGroupedResult {
51
+ groupIndex: number;
52
+ startMinute: number;
53
+ endMinute: number;
54
+ label: string;
55
+ results: ITMCResult | IATRResult;
56
+ minuteCount: number;
57
+ }
58
+
59
+ interface IGroupedResponse {
60
+ success: boolean;
61
+ data: IGroupedResult[];
62
+ groupingMinutes: number;
63
+ video: {
64
+ uuid: string;
65
+ name: string;
66
+ videoType: string;
67
+ status: string;
68
+ };
69
+ }
70
+
71
+ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
72
+ private knex = KnexManager.getConnection();
73
+ private tableName = "video_minute_results";
74
+
75
+ async getAll(
76
+ page: number = 1,
77
+ limit: number = 10,
78
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
79
+ const offset = (page - 1) * limit;
80
+
81
+ const [countResult] = await this.knex(this.tableName).count("* as count");
82
+ const totalCount = +countResult.count;
83
+
84
+ const data = await this.knex(this.tableName)
85
+ .select("*")
86
+ .orderBy("created_at", "desc")
87
+ .limit(limit)
88
+ .offset(offset);
89
+
90
+ return {
91
+ success: true,
92
+ data: this.mapDbRowsToEntities(data),
93
+ page,
94
+ limit,
95
+ count: data.length,
96
+ totalCount,
97
+ totalPages: Math.ceil(totalCount / limit),
98
+ };
99
+ }
100
+
101
+ async getById(id: number): Promise<IVideoMinuteResult | null> {
102
+ const result = await this.knex(this.tableName).where("id", id).first();
103
+
104
+ return result ? this.mapDbRowToEntity(result) : null;
105
+ }
106
+
107
+ async getByUuid(uuid: string): Promise<IVideoMinuteResult | null> {
108
+ const result = await this.knex(this.tableName).where("uuid", uuid).first();
109
+
110
+ return result ? this.mapDbRowToEntity(result) : null;
111
+ }
112
+
113
+ async create(data: IVideoMinuteResultInput): Promise<IVideoMinuteResult> {
114
+ const dbData = this.mapEntityToDbRow(data);
115
+ const [result] = await this.knex(this.tableName)
116
+ .insert(dbData)
117
+ .returning("*");
118
+
119
+ return this.mapDbRowToEntity(result);
120
+ }
121
+
122
+ async update(
123
+ id: number,
124
+ data: IVideoMinuteResult,
125
+ ): Promise<IVideoMinuteResult | null> {
126
+ const dbData = this.mapEntityToDbRow(data);
127
+ const [result] = await this.knex(this.tableName)
128
+ .where("id", id)
129
+ .update({ ...dbData, updated_at: this.knex.fn.now() })
130
+ .returning("*");
131
+
132
+ return result ? this.mapDbRowToEntity(result) : null;
133
+ }
134
+
135
+ async delete(id: number): Promise<boolean> {
136
+ const result = await this.knex(this.tableName).where("id", id).del();
137
+
138
+ return result > 0;
139
+ }
140
+
141
+ /**
142
+ * Get minute results for a specific video with optional time range filtering
143
+ */
144
+ async getMinuteResultsForVideo(
145
+ videoId: number,
146
+ startMinute?: number,
147
+ endMinute?: number,
148
+ page: number = 1,
149
+ limit: number = 100,
150
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
151
+ const offset = (page - 1) * limit;
152
+
153
+ const query = this.knex(this.tableName + " as vmr")
154
+ .innerJoin("video as v", "vmr.video_id", "v.id")
155
+ .select("vmr.*", "v.name as video_name", "v.uuid as video_uuid")
156
+ .where("vmr.video_id", videoId);
157
+
158
+ if (startMinute !== undefined) {
159
+ query.where("vmr.minute_number", ">=", startMinute);
160
+ }
161
+
162
+ if (endMinute !== undefined) {
163
+ query.where("vmr.minute_number", "<=", endMinute);
164
+ }
165
+
166
+ // Standard pagination logic with count optimization
167
+ const [{ count }] = await query.clone().clearSelect().count("* as count");
168
+ const data = await query
169
+ .orderBy("vmr.minute_number", "asc")
170
+ .limit(limit)
171
+ .offset(offset);
172
+
173
+ return {
174
+ success: true,
175
+ data: this.mapDbRowsToEntities(data),
176
+ page,
177
+ limit,
178
+ count: data.length,
179
+ totalCount: parseInt(count as string),
180
+ totalPages: Math.ceil(parseInt(count as string) / limit),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Create batch of minute results for efficient bulk insertion
186
+ */
187
+ async createBatch(
188
+ videoId: number,
189
+ minuteResults: IVideoMinuteResultInput[],
190
+ ): Promise<IVideoMinuteResult[]> {
191
+ const batchData = minuteResults.map((result) =>
192
+ this.mapEntityToDbRow({
193
+ ...result,
194
+ videoId,
195
+ }),
196
+ );
197
+
198
+ const results = await this.knex
199
+ .batchInsert(this.tableName, batchData, 50)
200
+ .returning("*");
201
+ return this.mapDbRowsToEntities(results);
202
+ }
203
+
204
+ /**
205
+ * Delete all minute results for a specific video
206
+ */
207
+ async deleteByVideoId(videoId: number): Promise<boolean> {
208
+ const result = await this.knex(this.tableName)
209
+ .where("video_id", videoId)
210
+ .del();
211
+
212
+ return result > 0;
213
+ }
214
+
215
+ /**
216
+ * Get the minute range for a video (min and max minute numbers)
217
+ */
218
+ async getVideoMinuteRange(
219
+ videoId: number,
220
+ ): Promise<{ minMinute: number; maxMinute: number } | null> {
221
+ const result = await this.knex(this.tableName)
222
+ .where("video_id", videoId)
223
+ .min("minute_number as minMinute")
224
+ .max("minute_number as maxMinute")
225
+ .first();
226
+
227
+ if (!result || result.minMinute === null) {
228
+ return null;
229
+ }
230
+
231
+ return {
232
+ minMinute: result.minMinute,
233
+ maxMinute: result.maxMinute,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Map database row to entity (snake_case -> camelCase)
239
+ */
240
+ private mapDbRowToEntity(row: any): IVideoMinuteResult {
241
+ return {
242
+ id: row.id,
243
+ uuid: row.uuid,
244
+ videoId: row.video_id,
245
+ minuteNumber: row.minute_number,
246
+ results: row.results,
247
+ createdAt: row.created_at,
248
+ updatedAt: row.updated_at,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Map multiple database rows to entities
254
+ */
255
+ private mapDbRowsToEntities(rows: any[]): IVideoMinuteResult[] {
256
+ return rows.map((row) => this.mapDbRowToEntity(row));
257
+ }
258
+
259
+ /**
260
+ * Map entity to database row (camelCase -> snake_case)
261
+ */
262
+ private mapEntityToDbRow(
263
+ entity: Partial<IVideoMinuteResult | IVideoMinuteResultInput>,
264
+ ): any {
265
+ const dbRow: any = {};
266
+
267
+ if (entity.videoId !== undefined) dbRow.video_id = entity.videoId;
268
+ if ("minuteNumber" in entity && entity.minuteNumber !== undefined)
269
+ dbRow.minute_number = entity.minuteNumber;
270
+ if (entity.results !== undefined) dbRow.results = entity.results;
271
+ if ("createdAt" in entity && entity.createdAt !== undefined)
272
+ dbRow.created_at = entity.createdAt;
273
+ if ("updatedAt" in entity && entity.updatedAt !== undefined)
274
+ dbRow.updated_at = entity.updatedAt;
275
+
276
+ return dbRow;
277
+ }
278
+
279
+ /**
280
+ * Get minute results by video UUID (convenience method)
281
+ */
282
+ async getMinuteResultsByVideoUuid(
283
+ videoUuid: string,
284
+ startMinute?: number,
285
+ endMinute?: number,
286
+ page: number = 1,
287
+ limit: number = 100,
288
+ ): Promise<IDataPaginator<IVideoMinuteResult>> {
289
+ const offset = (page - 1) * limit;
290
+
291
+ const query = this.knex(this.tableName + " as vmr")
292
+ .innerJoin("video as v", "vmr.video_id", "v.id")
293
+ .select("vmr.*", "v.name as video_name", "v.uuid as video_uuid")
294
+ .where("v.uuid", videoUuid);
295
+
296
+ if (startMinute !== undefined) {
297
+ query.where("vmr.minute_number", ">=", startMinute);
298
+ }
299
+
300
+ if (endMinute !== undefined) {
301
+ query.where("vmr.minute_number", "<=", endMinute);
302
+ }
303
+
304
+ // Check if video exists and get count in single query
305
+ const [{ count }] = await query.clone().clearSelect().count("* as count");
306
+ const totalCount = parseInt(count as string);
307
+
308
+ if (totalCount === 0) {
309
+ // Check if video exists by trying to find it
310
+ const videoExists = await this.knex("video")
311
+ .where("uuid", videoUuid)
312
+ .first();
313
+ if (!videoExists) {
314
+ throw new Error(`Video with UUID ${videoUuid} not found`);
315
+ }
316
+ }
317
+
318
+ const data = await query
319
+ .orderBy("vmr.minute_number", "asc")
320
+ .limit(limit)
321
+ .offset(offset);
322
+
323
+ return {
324
+ success: true,
325
+ data: this.mapDbRowsToEntities(data),
326
+ page,
327
+ limit,
328
+ count: data.length,
329
+ totalCount,
330
+ totalPages: Math.ceil(totalCount / limit),
331
+ };
332
+ }
333
+
334
+ /**
335
+ * Get grouped minute results by video UUID with time block aggregation
336
+ * @param videoUuid - The UUID of the video
337
+ * @param groupingMinutes - Number of minutes to group together (1, 5, 10, 15, 30, 60)
338
+ * @param startMinute - Optional start minute filter
339
+ * @param endMinute - Optional end minute filter
340
+ */
341
+ async getGroupedMinuteResultsByVideoUuid(
342
+ videoUuid: string,
343
+ groupingMinutes: number = 1,
344
+ startMinute?: number,
345
+ endMinute?: number,
346
+ ): Promise<IGroupedResponse> {
347
+ // First, get the video to ensure it exists and get metadata
348
+ const video = await this.knex("video").where("uuid", videoUuid).first();
349
+
350
+ if (!video) {
351
+ throw new Error(`Video with UUID ${videoUuid} not found`);
352
+ }
353
+
354
+ // Build the base query for minute results
355
+ const query = this.knex(this.tableName + " as vmr").where(
356
+ "vmr.video_id",
357
+ video.id,
358
+ );
359
+
360
+ if (startMinute !== undefined) {
361
+ query.where("vmr.minute_number", ">=", startMinute);
362
+ }
363
+
364
+ if (endMinute !== undefined) {
365
+ query.where("vmr.minute_number", "<=", endMinute);
366
+ }
367
+
368
+ // If grouping is 1 minute, just return the regular results
369
+ if (groupingMinutes === 1) {
370
+ const data = await query
371
+ .select("vmr.*")
372
+ .orderBy("vmr.minute_number", "asc");
373
+
374
+ return {
375
+ success: true,
376
+ data: data.map((row) => ({
377
+ groupIndex: row.minute_number,
378
+ startMinute: row.minute_number,
379
+ endMinute: row.minute_number,
380
+ label: this.formatTimeLabel(row.minute_number, row.minute_number),
381
+ results: row.results,
382
+ minuteCount: 1,
383
+ })),
384
+ groupingMinutes,
385
+ video: {
386
+ uuid: video.uuid,
387
+ name: video.name,
388
+ videoType: video.videoType,
389
+ status: video.status,
390
+ },
391
+ };
392
+ }
393
+
394
+ // Use Knex query builder for safe parameter binding
395
+ const groupingQuery = this.knex(this.tableName)
396
+ .select(
397
+ this.knex.raw("FLOOR(minute_number / ?) as group_index", [
398
+ groupingMinutes,
399
+ ]),
400
+ this.knex.raw("MIN(minute_number) as start_minute"),
401
+ this.knex.raw("MAX(minute_number) as end_minute"),
402
+ this.knex.raw("COUNT(*) as minute_count"),
403
+ this.knex.raw(
404
+ "array_agg(results ORDER BY minute_number) as all_results",
405
+ ),
406
+ )
407
+ .where("video_id", video.id);
408
+
409
+ if (startMinute !== undefined) {
410
+ groupingQuery.where("minute_number", ">=", startMinute);
411
+ }
412
+
413
+ if (endMinute !== undefined) {
414
+ groupingQuery.where("minute_number", "<=", endMinute);
415
+ }
416
+
417
+ const rows = await groupingQuery
418
+ .groupBy("group_index")
419
+ .orderBy("group_index");
420
+
421
+ // Aggregate the results in TypeScript based on video type
422
+ const aggregatedGroups: IGroupedResult[] = rows.map((row: any) => {
423
+ if (!row || typeof row !== "object") {
424
+ throw new Error("Invalid row data received from database query");
425
+ }
426
+
427
+ const allResults = Array.isArray(row.all_results) ? row.all_results : [];
428
+
429
+ // Determine video type based on multiple factors
430
+ let studyType = video.videoType || "ATR"; // Default fallback to ATR
431
+
432
+ // Check if minute data has study_type field (ATR usually does)
433
+ if (allResults.length > 0 && allResults[0].study_type) {
434
+ studyType = allResults[0].study_type;
435
+ } else if (allResults.length > 0) {
436
+ // Check data structure to determine type
437
+ const sampleResult = allResults[0];
438
+ if (sampleResult.vehicles) {
439
+ // Check if vehicles structure has directions (NORTH, SOUTH, etc.) - TMC pattern
440
+ const vehicleKeys = Object.keys(sampleResult.vehicles);
441
+ if (vehicleKeys.length > 0) {
442
+ const firstVehicleType = sampleResult.vehicles[vehicleKeys[0]];
443
+ if (firstVehicleType && typeof firstVehicleType === "object") {
444
+ const directions = Object.keys(firstVehicleType);
445
+ if (
446
+ directions.includes("NORTH") ||
447
+ directions.includes("SOUTH") ||
448
+ directions.includes("EAST") ||
449
+ directions.includes("WEST")
450
+ ) {
451
+ studyType = "TMC";
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }
457
+
458
+ // Aggregate based on determined video type
459
+ let aggregatedResult;
460
+ if (studyType === "TMC") {
461
+ aggregatedResult = this.aggregateTMCResults(allResults);
462
+ } else {
463
+ aggregatedResult = this.aggregateATRResults(allResults);
464
+ }
465
+
466
+ return {
467
+ groupIndex: row.group_index,
468
+ startMinute: row.start_minute,
469
+ endMinute: row.end_minute,
470
+ label: this.formatTimeLabel(row.start_minute, row.end_minute),
471
+ results: aggregatedResult,
472
+ minuteCount: row.minute_count,
473
+ };
474
+ });
475
+
476
+ return {
477
+ success: true,
478
+ data: aggregatedGroups,
479
+ groupingMinutes,
480
+ video: {
481
+ uuid: video.uuid,
482
+ name: video.name,
483
+ videoType: video.videoType,
484
+ status: video.status,
485
+ },
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Aggregate minute results based on video type (TMC or ATR)
491
+ */
492
+ private aggregateMinuteResults(minutes: any[], videoType: string): any {
493
+ if (minutes.length === 0) return {};
494
+
495
+ // Initialize the aggregated result structure based on the first minute
496
+ const firstMinuteResults = minutes[0].results;
497
+ const aggregated = this.deepCloneStructure(firstMinuteResults);
498
+
499
+ // For TMC videos, aggregate turning movements
500
+ if (videoType === "TMC") {
501
+ return this.aggregateTMCResults(minutes);
502
+ }
503
+
504
+ // For ATR videos, aggregate lane counts
505
+ if (videoType === "ATR" || videoType === "ATP") {
506
+ return this.aggregateATRResults(minutes);
507
+ }
508
+
509
+ // Default aggregation for other types
510
+ return this.aggregateGenericResults(minutes);
511
+ }
512
+
513
+ /**
514
+ * Aggregate TMC (Turning Movement Count) results
515
+ */
516
+ private aggregateTMCResults(minutes: any[]): ITMCResult {
517
+ const aggregated: ITMCResult = {
518
+ vehicles: {},
519
+ counts: {
520
+ total_vehicles: 0,
521
+ entry_vehicles: 0,
522
+ },
523
+ total: 0,
524
+ totalcount: 0,
525
+ detected_classes: {},
526
+ study_type: "TMC",
527
+ };
528
+
529
+ for (const minute of minutes) {
530
+ const results = minute; // minute is already the results object from array_agg
531
+
532
+ // Aggregate vehicle movements by class and direction
533
+ if (results.vehicles && typeof results.vehicles === "object") {
534
+ for (const [vehicleClass, directions] of Object.entries(
535
+ results.vehicles,
536
+ )) {
537
+ // Skip the 'total' pseudo vehicle class - validate it's actually aggregate data
538
+ if (vehicleClass === "total" && typeof directions === "number") {
539
+ continue; // This is aggregate total data, not a vehicle class
540
+ }
541
+
542
+ if (!aggregated.vehicles[vehicleClass]) {
543
+ aggregated.vehicles[vehicleClass] = {};
544
+ }
545
+
546
+ if (!aggregated.detected_classes[vehicleClass]) {
547
+ aggregated.detected_classes[vehicleClass] = 0;
548
+ }
549
+
550
+ for (const [direction, turns] of Object.entries(directions as any)) {
551
+ if (!aggregated.vehicles[vehicleClass][direction]) {
552
+ aggregated.vehicles[vehicleClass][direction] = {
553
+ straight: 0,
554
+ left: 0,
555
+ right: 0,
556
+ "u-turn": 0,
557
+ };
558
+ }
559
+
560
+ if (typeof turns === "object" && turns !== null) {
561
+ for (const [turnType, count] of Object.entries(turns)) {
562
+ const turnCount = (count as number) || 0;
563
+ aggregated.vehicles[vehicleClass][direction][turnType] =
564
+ (aggregated.vehicles[vehicleClass][direction][turnType] ||
565
+ 0) + turnCount;
566
+
567
+ // Add to detected_classes count for this vehicle type
568
+ aggregated.detected_classes[vehicleClass] += turnCount;
569
+
570
+ // Add to total counts
571
+ aggregated.total += turnCount;
572
+ aggregated.totalcount += turnCount;
573
+ }
574
+ }
575
+ }
576
+ }
577
+
578
+ // Also process the 'total' entry for validation but don't count it as a vehicle class
579
+ if (results.vehicles.total) {
580
+ for (const [direction, turns] of Object.entries(
581
+ results.vehicles.total as any,
582
+ )) {
583
+ if (typeof turns === "object" && turns !== null) {
584
+ for (const [turnType, count] of Object.entries(turns)) {
585
+ const turnCount = (count as number) || 0;
586
+ aggregated.counts.total_vehicles += turnCount;
587
+ }
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
+
594
+ // Set entry_vehicles same as total_vehicles for TMC
595
+ aggregated.counts.entry_vehicles =
596
+ aggregated.counts.total_vehicles || aggregated.total;
597
+
598
+ return aggregated;
599
+ }
600
+
601
+ /**
602
+ * Aggregate ATR (Automatic Traffic Recorder) results
603
+ */
604
+ private aggregateATRResults(minutes: any[]): IATRResult {
605
+ const aggregated: IATRResult = {
606
+ vehicles: {},
607
+ lane_counts: {},
608
+ total_count: 0,
609
+ detected_classes: {},
610
+ study_type: "ATR",
611
+ };
612
+
613
+ for (const minute of minutes) {
614
+ const results = minute; // minute is already the results object from array_agg
615
+
616
+ // Aggregate vehicle counts by class and lane
617
+ if (results.vehicles) {
618
+ for (const [rawVehicleClass, lanes] of Object.entries(
619
+ results.vehicles,
620
+ )) {
621
+ // Normalize vehicle class names to standard format for frontend compatibility
622
+ const vehicleClass = this.normalizeATRVehicleClass(rawVehicleClass);
623
+
624
+ if (!aggregated.vehicles[vehicleClass]) {
625
+ aggregated.vehicles[vehicleClass] = {};
626
+ }
627
+
628
+ for (const [laneId, count] of Object.entries(lanes as any)) {
629
+ const numericCount =
630
+ typeof count === "number" ? count : parseInt(String(count)) || 0;
631
+ aggregated.vehicles[vehicleClass][laneId] =
632
+ (aggregated.vehicles[vehicleClass][laneId] || 0) + numericCount;
633
+ }
634
+ }
635
+ }
636
+
637
+ // Aggregate lane counts
638
+ if (results.lane_counts) {
639
+ for (const [laneId, count] of Object.entries(results.lane_counts)) {
640
+ aggregated.lane_counts[laneId] =
641
+ (aggregated.lane_counts[laneId] || 0) + ((count as number) || 0);
642
+ }
643
+ }
644
+
645
+ // Aggregate total count
646
+ aggregated.total_count += results.total_count || 0;
647
+
648
+ // Aggregate detected classes with normalized names
649
+ if (results.detected_classes) {
650
+ for (const [rawCls, count] of Object.entries(
651
+ results.detected_classes,
652
+ )) {
653
+ const cls = this.normalizeATRVehicleClass(rawCls);
654
+ aggregated.detected_classes[cls] =
655
+ (aggregated.detected_classes[cls] || 0) + ((count as number) || 0);
656
+ }
657
+ }
658
+ }
659
+
660
+ return aggregated;
661
+ }
662
+
663
+ /**
664
+ * Generic aggregation for other video types
665
+ */
666
+ private aggregateGenericResults(minutes: any[]): any {
667
+ if (minutes.length === 0) return {};
668
+
669
+ // For generic aggregation, sum all numeric values
670
+ const aggregated = JSON.parse(JSON.stringify(minutes[0].results));
671
+
672
+ for (let i = 1; i < minutes.length; i++) {
673
+ this.sumNumericValues(aggregated, minutes[i].results);
674
+ }
675
+
676
+ return aggregated;
677
+ }
678
+
679
+ /**
680
+ * Recursively sum numeric values in nested objects
681
+ */
682
+ private sumNumericValues(target: any, source: any): void {
683
+ for (const key in source) {
684
+ if (typeof source[key] === "number") {
685
+ target[key] = (target[key] || 0) + source[key];
686
+ } else if (
687
+ typeof source[key] === "object" &&
688
+ source[key] !== null &&
689
+ !Array.isArray(source[key])
690
+ ) {
691
+ if (!target[key]) {
692
+ target[key] = {};
693
+ }
694
+ this.sumNumericValues(target[key], source[key]);
695
+ }
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Deep clone a result structure, setting all numeric values to 0
701
+ */
702
+ private deepCloneStructure(obj: any): any {
703
+ if (typeof obj !== "object" || obj === null) {
704
+ return typeof obj === "number" ? 0 : obj;
705
+ }
706
+
707
+ if (Array.isArray(obj)) {
708
+ return obj.map((item) => this.deepCloneStructure(item));
709
+ }
710
+
711
+ const cloned: any = {};
712
+ for (const key in obj) {
713
+ cloned[key] = this.deepCloneStructure(obj[key]);
714
+ }
715
+ return cloned;
716
+ }
717
+
718
+ /**
719
+ * Normalize ATR vehicle class names to standard format for frontend compatibility
720
+ */
721
+ private normalizeATRVehicleClass(rawVehicleClass: string): string {
722
+ const normalized = rawVehicleClass.toLowerCase().replace(/[_\s-]/g, "");
723
+
724
+ // Map raw vehicle classes to standard classes
725
+ if (
726
+ normalized.includes("car") ||
727
+ normalized === "vehicle" ||
728
+ normalized === "automobiles"
729
+ ) {
730
+ return "cars";
731
+ }
732
+ if (
733
+ normalized.includes("medium") ||
734
+ normalized.includes("pickup") ||
735
+ (normalized.includes("truck") && !normalized.includes("heavy"))
736
+ ) {
737
+ return "mediums";
738
+ }
739
+ if (
740
+ normalized.includes("heavy") ||
741
+ normalized.includes("largetruck") ||
742
+ normalized.includes("bigtruck")
743
+ ) {
744
+ return "heavy_trucks";
745
+ }
746
+ if (
747
+ normalized.includes("pedestrian") ||
748
+ normalized.includes("person") ||
749
+ normalized.includes("people")
750
+ ) {
751
+ return "pedestrians";
752
+ }
753
+ if (
754
+ normalized.includes("bicycle") ||
755
+ normalized.includes("bike") ||
756
+ normalized.includes("cyclist")
757
+ ) {
758
+ return "bicycles";
759
+ }
760
+ if (normalized.includes("total") || normalized.includes("all")) {
761
+ return "total";
762
+ }
763
+
764
+ // Handle specific known ATR classes
765
+ if (rawVehicleClass === "mediums") return "mediums";
766
+ if (rawVehicleClass === "heavy_trucks") return "heavy_trucks";
767
+
768
+ // Default fallback for unknown classes
769
+ return "cars";
770
+ }
771
+
772
+ /**
773
+ * Format time label for display (HH:MM:SS format)
774
+ */
775
+ private formatTimeLabel(startMinute: number, endMinute: number): string {
776
+ const formatMinute = (min: number): string => {
777
+ const hours = Math.floor(min / 60);
778
+ const minutes = min % 60;
779
+ return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
780
+ };
781
+
782
+ if (startMinute === endMinute) {
783
+ return `${formatMinute(startMinute)}:00 - ${formatMinute(startMinute)}:59`;
784
+ }
785
+
786
+ return `${formatMinute(startMinute)}:00 - ${formatMinute(endMinute)}:59`;
787
+ }
788
+ }
789
+
790
+ export default VideoMinuteResultDAO;