@trafficgroup/knex-rel 0.1.8 → 0.1.10

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 (115) hide show
  1. package/.claude/settings.local.json +5 -2
  2. package/.env.prod +5 -0
  3. package/CLAUDE.md +2 -11
  4. package/dist/constants/video.constants.d.ts +12 -0
  5. package/dist/constants/video.constants.js +18 -0
  6. package/dist/constants/video.constants.js.map +1 -0
  7. package/dist/dao/VideoMinuteResultDAO.d.ts +4 -1
  8. package/dist/dao/VideoMinuteResultDAO.js +28 -31
  9. package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
  10. package/dist/dao/auth/auth.dao.js +1 -4
  11. package/dist/dao/auth/auth.dao.js.map +1 -1
  12. package/dist/dao/batch/batch.dao.js +14 -13
  13. package/dist/dao/batch/batch.dao.js.map +1 -1
  14. package/dist/dao/camera/camera.dao.d.ts +17 -7
  15. package/dist/dao/camera/camera.dao.js +38 -56
  16. package/dist/dao/camera/camera.dao.js.map +1 -1
  17. package/dist/dao/chat/chat.dao.d.ts +1 -1
  18. package/dist/dao/chat/chat.dao.js +35 -25
  19. package/dist/dao/chat/chat.dao.js.map +1 -1
  20. package/dist/dao/folder/folder.dao.js +4 -8
  21. package/dist/dao/folder/folder.dao.js.map +1 -1
  22. package/dist/dao/location/location.dao.d.ts +17 -0
  23. package/dist/dao/location/location.dao.js +116 -0
  24. package/dist/dao/location/location.dao.js.map +1 -0
  25. package/dist/dao/message/message.dao.d.ts +1 -1
  26. package/dist/dao/message/message.dao.js +26 -18
  27. package/dist/dao/message/message.dao.js.map +1 -1
  28. package/dist/dao/report-configuration/report-configuration.dao.js +31 -32
  29. package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
  30. package/dist/dao/study/study.dao.d.ts +1 -1
  31. package/dist/dao/study/study.dao.js +12 -17
  32. package/dist/dao/study/study.dao.js.map +1 -1
  33. package/dist/dao/user/user.dao.js +1 -4
  34. package/dist/dao/user/user.dao.js.map +1 -1
  35. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +8 -26
  36. package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
  37. package/dist/dao/video/video.dao.d.ts +9 -1
  38. package/dist/dao/video/video.dao.js +73 -27
  39. package/dist/dao/video/video.dao.js.map +1 -1
  40. package/dist/index.d.ts +8 -4
  41. package/dist/index.js +8 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/interfaces/batch/batch.interfaces.d.ts +1 -1
  44. package/dist/interfaces/camera/camera.interfaces.d.ts +4 -2
  45. package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
  46. package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
  47. package/dist/interfaces/location/location.interfaces.d.ts +9 -0
  48. package/dist/interfaces/location/location.interfaces.js +3 -0
  49. package/dist/interfaces/location/location.interfaces.js.map +1 -0
  50. package/dist/interfaces/message/message.interfaces.d.ts +2 -2
  51. package/dist/interfaces/study/study.interfaces.d.ts +6 -5
  52. package/dist/interfaces/user/user.interfaces.d.ts +1 -1
  53. package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
  54. package/dist/interfaces/video/video.interfaces.d.ts +3 -2
  55. package/migrations/20250717160737_migration.ts +1 -1
  56. package/migrations/20250717160908_migration.ts +2 -5
  57. package/migrations/20250717161310_migration.ts +1 -1
  58. package/migrations/20250717161406_migration.ts +3 -3
  59. package/migrations/20250717162431_migration.ts +1 -1
  60. package/migrations/20250717173228_migration.ts +2 -2
  61. package/migrations/20250717204731_migration.ts +1 -1
  62. package/migrations/20250722210109_migration.ts +4 -8
  63. package/migrations/20250722211019_migration.ts +1 -1
  64. package/migrations/20250723153852_migration.ts +10 -13
  65. package/migrations/20250723162257_migration.ts +7 -4
  66. package/migrations/20250723171109_migration.ts +7 -4
  67. package/migrations/20250723205331_migration.ts +9 -6
  68. package/migrations/20250724191345_migration.ts +11 -8
  69. package/migrations/20250730180932_migration.ts +13 -14
  70. package/migrations/20250730213625_migration.ts +11 -8
  71. package/migrations/20250804124509_migration.ts +21 -26
  72. package/migrations/20250804132053_migration.ts +8 -5
  73. package/migrations/20250804164518_migration.ts +7 -7
  74. package/migrations/20250823223016_migration.ts +21 -32
  75. package/migrations/20250910015452_migration.ts +6 -18
  76. package/migrations/20250911000000_migration.ts +4 -18
  77. package/migrations/20250917144153_migration.ts +7 -14
  78. package/migrations/20250930200521_migration.ts +4 -8
  79. package/migrations/20251010143500_migration.ts +6 -27
  80. package/migrations/20251020225758_migration.ts +15 -51
  81. package/migrations/20251112120000_migration.ts +81 -0
  82. package/migrations/20251112120100_migration.ts +21 -0
  83. package/migrations/20251112120200_migration.ts +38 -0
  84. package/migrations/20251112120300_migration.ts +22 -0
  85. package/package.json +1 -1
  86. package/src/constants/video.constants.ts +19 -0
  87. package/src/d.types.ts +14 -18
  88. package/src/dao/VideoMinuteResultDAO.ts +54 -72
  89. package/src/dao/auth/auth.dao.ts +55 -58
  90. package/src/dao/batch/batch.dao.ts +98 -101
  91. package/src/dao/camera/camera.dao.ts +125 -145
  92. package/src/dao/chat/chat.dao.ts +43 -45
  93. package/src/dao/folder/folder.dao.ts +56 -59
  94. package/src/dao/location/location.dao.ts +101 -0
  95. package/src/dao/message/message.dao.ts +32 -32
  96. package/src/dao/report-configuration/report-configuration.dao.ts +342 -370
  97. package/src/dao/study/study.dao.ts +63 -88
  98. package/src/dao/user/user.dao.ts +50 -52
  99. package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +49 -83
  100. package/src/dao/video/video.dao.ts +339 -313
  101. package/src/entities/BaseEntity.ts +1 -1
  102. package/src/index.ts +24 -26
  103. package/src/interfaces/auth/auth.interfaces.ts +10 -10
  104. package/src/interfaces/batch/batch.interfaces.ts +1 -1
  105. package/src/interfaces/camera/camera.interfaces.ts +9 -7
  106. package/src/interfaces/chat/chat.interfaces.ts +4 -4
  107. package/src/interfaces/folder/folder.interfaces.ts +2 -2
  108. package/src/interfaces/location/location.interfaces.ts +9 -0
  109. package/src/interfaces/message/message.interfaces.ts +3 -3
  110. package/src/interfaces/report-configuration/report-configuration.interfaces.ts +16 -16
  111. package/src/interfaces/study/study.interfaces.ts +7 -6
  112. package/src/interfaces/user/user.interfaces.ts +9 -9
  113. package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
  114. package/src/interfaces/video/video.interfaces.ts +34 -33
  115. package/plan.md +0 -684
@@ -13,49 +13,28 @@ import type { Knex } from "knex";
13
13
  export async function up(knex: Knex): Promise<void> {
14
14
  // Step 1: Create video_batch table with proper id/uuid pattern
15
15
  await knex.schema.createTable("video_batch", (table) => {
16
- table
17
- .increments("id")
18
- .primary()
16
+ table.increments("id").primary()
19
17
  .comment("Primary key for internal foreign key relationships");
20
18
 
21
- table
22
- .uuid("uuid")
23
- .notNullable()
24
- .unique()
25
- .defaultTo(knex.raw("uuid_generate_v4()"))
19
+ table.uuid("uuid").notNullable().unique().defaultTo(knex.raw('uuid_generate_v4()'))
26
20
  .comment("UUID for external API communication");
27
21
 
28
- table
29
- .integer("folderId")
30
- .unsigned()
31
- .notNullable()
32
- .references("id")
33
- .inTable("folders")
34
- .onDelete("CASCADE")
22
+ table.integer("folderId").unsigned().notNullable()
23
+ .references("id").inTable("folders").onDelete("CASCADE")
35
24
  .comment("Foreign key to folders table");
36
25
 
37
- table
38
- .enu("status", ["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"])
26
+ table.enu("status", ["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"])
39
27
  .notNullable()
40
28
  .defaultTo("PENDING")
41
29
  .comment("Status of the batch upload");
42
30
 
43
- table
44
- .integer("totalVideos")
45
- .notNullable()
46
- .defaultTo(0)
31
+ table.integer("totalVideos").notNullable().defaultTo(0)
47
32
  .comment("Total number of videos in batch");
48
33
 
49
- table
50
- .integer("completedVideos")
51
- .notNullable()
52
- .defaultTo(0)
34
+ table.integer("completedVideos").notNullable().defaultTo(0)
53
35
  .comment("Number of successfully created videos");
54
36
 
55
- table
56
- .integer("failedVideos")
57
- .notNullable()
58
- .defaultTo(0)
37
+ table.integer("failedVideos").notNullable().defaultTo(0)
59
38
  .comment("Number of failed videos");
60
39
 
61
40
  table.timestamps(true, true);
@@ -63,32 +42,17 @@ export async function up(knex: Knex): Promise<void> {
63
42
 
64
43
  // Step 2: Add recording time, trimming, and batch columns to video table
65
44
  await knex.schema.alterTable("video", (table) => {
66
- table
67
- .timestamp("recordingStartedAt", { useTz: true })
68
- .nullable()
45
+ table.timestamp("recordingStartedAt", { useTz: true }).nullable()
69
46
  .comment("Recording start time in UTC - null for backward compatibility");
70
47
 
71
- table
72
- .boolean("trimEnabled")
73
- .notNullable()
74
- .defaultTo(false)
48
+ table.boolean("trimEnabled").notNullable().defaultTo(false)
75
49
  .comment("Whether video trimming is enabled");
76
50
 
77
- table
78
- .jsonb("trimPeriods")
79
- .nullable()
80
- .comment(
81
- "Array of trim periods with startTime and endTime in ISO 8601 format",
82
- );
83
-
84
- table
85
- .integer("batchId")
86
- .unsigned()
87
- .nullable()
88
- .references("id")
89
- .inTable("video_batch")
90
- .withKeyName("fk_video_batch")
91
- .onDelete("SET NULL")
51
+ table.jsonb("trimPeriods").nullable()
52
+ .comment("Array of trim periods with startTime and endTime in ISO 8601 format");
53
+
54
+ table.integer("batchId").unsigned().nullable()
55
+ .references("id").inTable("video_batch").withKeyName("fk_video_batch").onDelete("SET NULL")
92
56
  .comment("Foreign key to video_batch table (numerical ID, NOT UUID)");
93
57
  });
94
58
 
@@ -0,0 +1,81 @@
1
+ import { Knex } from "knex";
2
+
3
+ /**
4
+ * Migration: Rename cameras table to locations
5
+ *
6
+ * Purpose: Rename existing 'cameras' table to 'locations' to represent physical study sites.
7
+ * This is the first step in separating locations (physical sites) from cameras (devices at those sites).
8
+ */
9
+ export async function up(knex: Knex): Promise<void> {
10
+ // 1. Rename the cameras table to locations
11
+ await knex.schema.renameTable("cameras", "locations");
12
+
13
+ // 2. Rename the uuid unique constraint to match new table name (frees up name for new cameras table)
14
+ await knex.raw(`
15
+ ALTER TABLE locations
16
+ RENAME CONSTRAINT cameras_uuid_unique TO locations_uuid_unique
17
+ `);
18
+
19
+ // 3. Rename the foreign key column in study table
20
+ await knex.schema.alterTable("study", (table) => {
21
+ table.renameColumn("cameraId", "locationId");
22
+ });
23
+
24
+ // 4. Drop the old foreign key constraint
25
+ await knex.raw(`
26
+ ALTER TABLE study
27
+ DROP CONSTRAINT IF EXISTS study_cameraid_foreign
28
+ `);
29
+
30
+ // 5. Add new foreign key constraint with updated name
31
+ await knex.schema.alterTable("study", (table) => {
32
+ table.foreign("locationId").references("id").inTable("locations").onDelete("SET NULL");
33
+ });
34
+
35
+ // 6. Drop old index if it exists
36
+ await knex.raw(`
37
+ DROP INDEX IF EXISTS study_cameraid_index
38
+ `);
39
+
40
+ // 7. Create new index with updated name
41
+ await knex.schema.alterTable("study", (table) => {
42
+ table.index(["locationId"], "study_locationid_index");
43
+ });
44
+ }
45
+
46
+ export async function down(knex: Knex): Promise<void> {
47
+ // 1. Drop the new foreign key constraint
48
+ await knex.raw(`
49
+ ALTER TABLE study
50
+ DROP CONSTRAINT IF EXISTS study_locationid_foreign
51
+ `);
52
+
53
+ // 2. Drop new index
54
+ await knex.raw(`
55
+ DROP INDEX IF EXISTS study_locationid_index
56
+ `);
57
+
58
+ // 3. Rename column back
59
+ await knex.schema.alterTable("study", (table) => {
60
+ table.renameColumn("locationId", "cameraId");
61
+ });
62
+
63
+ // 4. Restore original foreign key constraint
64
+ await knex.schema.alterTable("study", (table) => {
65
+ table.foreign("cameraId").references("id").inTable("locations").onDelete("SET NULL");
66
+ });
67
+
68
+ // 5. Restore original index
69
+ await knex.schema.alterTable("study", (table) => {
70
+ table.index(["cameraId"], "study_cameraid_index");
71
+ });
72
+
73
+ // 6. Rename constraint back to original name
74
+ await knex.raw(`
75
+ ALTER TABLE locations
76
+ RENAME CONSTRAINT locations_uuid_unique TO cameras_uuid_unique
77
+ `);
78
+
79
+ // 7. Rename the table back to cameras
80
+ await knex.schema.renameTable("locations", "cameras");
81
+ }
@@ -0,0 +1,21 @@
1
+ import { Knex } from "knex";
2
+
3
+ /**
4
+ * Migration: Add isMultiCamera field to study table
5
+ *
6
+ * Purpose: Add boolean flag to indicate whether a study uses multiple cameras.
7
+ * Defaults to false for backward compatibility with existing single-camera studies.
8
+ */
9
+ export async function up(knex: Knex): Promise<void> {
10
+ await knex.schema.alterTable("study", (table) => {
11
+ table.boolean("isMultiCamera").notNullable().defaultTo(false);
12
+ table.index(["isMultiCamera"], "study_ismulticamera_index");
13
+ });
14
+ }
15
+
16
+ export async function down(knex: Knex): Promise<void> {
17
+ await knex.schema.alterTable("study", (table) => {
18
+ table.dropIndex(["isMultiCamera"], "study_ismulticamera_index");
19
+ table.dropColumn("isMultiCamera");
20
+ });
21
+ }
@@ -0,0 +1,38 @@
1
+ import { Knex } from "knex";
2
+
3
+ /**
4
+ * Migration: Create cameras table
5
+ *
6
+ * Purpose: Create new 'cameras' table to represent camera devices at locations.
7
+ * This allows multiple cameras to be associated with a single location.
8
+ */
9
+ export async function up(knex: Knex): Promise<void> {
10
+ await knex.schema.createTable("cameras", (table) => {
11
+ table.increments("id").primary();
12
+ table.uuid("uuid").defaultTo(knex.raw("uuid_generate_v4()")).unique().notNullable();
13
+ table.integer("locationId").unsigned().notNullable()
14
+ .references("id").inTable("locations").onDelete("CASCADE");
15
+ table.string("name", 100).notNullable();
16
+ table.text("description").nullable();
17
+ table.enu("status", ["ACTIVE", "INACTIVE", "MAINTENANCE"], {
18
+ useNative: true,
19
+ enumName: "camera_status_enum"
20
+ }).defaultTo("ACTIVE").notNullable();
21
+ table.jsonb("metadata").defaultTo("{}").notNullable();
22
+ table.timestamps(true, true);
23
+
24
+ // Unique constraint: camera names must be unique within a location
25
+ table.unique(["locationId", "name"], "cameras_locationid_name_unique");
26
+
27
+ // Index for faster lookups
28
+ table.index(["locationId"], "cameras_locationid_index");
29
+ table.index(["status"], "cameras_status_index");
30
+ });
31
+ }
32
+
33
+ export async function down(knex: Knex): Promise<void> {
34
+ await knex.schema.dropTableIfExists("cameras");
35
+
36
+ // Drop the enum type
37
+ await knex.raw("DROP TYPE IF EXISTS camera_status_enum");
38
+ }
@@ -0,0 +1,22 @@
1
+ import { Knex } from "knex";
2
+
3
+ /**
4
+ * Migration: Add cameraId column to video table
5
+ *
6
+ * Purpose: Link videos to specific cameras in multi-camera studies.
7
+ * Nullable to maintain backward compatibility with existing videos.
8
+ */
9
+ export async function up(knex: Knex): Promise<void> {
10
+ await knex.schema.alterTable("video", (table) => {
11
+ table.integer("cameraId").unsigned().nullable()
12
+ .references("id").inTable("cameras").onDelete("SET NULL");
13
+ table.index(["cameraId"], "video_cameraid_index");
14
+ });
15
+ }
16
+
17
+ export async function down(knex: Knex): Promise<void> {
18
+ await knex.schema.alterTable("video", (table) => {
19
+ table.dropIndex(["cameraId"], "video_cameraid_index");
20
+ table.dropColumn("cameraId");
21
+ });
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trafficgroup/knex-rel",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Knex Module",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Video sorting constants
3
+ * Shared between DAO and API controller to ensure validation consistency
4
+ */
5
+
6
+ export const VALID_VIDEO_SORT_FIELDS = ['created_at', 'name', 'status'] as const;
7
+ export type VideoSortField = typeof VALID_VIDEO_SORT_FIELDS[number];
8
+
9
+ export const VALID_SORT_ORDERS = ['ASC', 'DESC'] as const;
10
+ export type SortOrder = typeof VALID_SORT_ORDERS[number];
11
+
12
+ /**
13
+ * Maps API sort field names to database column names with table aliases
14
+ */
15
+ export const VIDEO_SORT_COLUMN_MAP: Record<VideoSortField, string> = {
16
+ 'created_at': 'v.created_at',
17
+ 'name': 'v.name',
18
+ 'status': 'v.status'
19
+ };
package/src/d.types.ts CHANGED
@@ -1,22 +1,18 @@
1
1
  export interface IDataPaginator<T> {
2
- success: boolean;
3
- data: T[];
4
- page: number;
5
- limit: number;
6
- count: number;
7
- totalCount: number;
8
- totalPages: number;
2
+ success: boolean;
3
+ data: T[];
4
+ page: number;
5
+ limit: number;
6
+ count: number;
7
+ totalCount: number;
8
+ totalPages: number;
9
9
  }
10
10
 
11
11
  export interface IBaseDAO<T> {
12
- create(item: T): Promise<T>;
13
- getById(id: number): Promise<T | null>;
14
- getByUuid(uuid: string): Promise<T | null>;
15
- getAll(
16
- page: number,
17
- limit: number,
18
- entityId?: any | null,
19
- ): Promise<IDataPaginator<T>>;
20
- update(id: number, item: T): Promise<T | null>;
21
- delete(id: number): Promise<boolean>;
22
- }
12
+ create(item: T): Promise<T>;
13
+ getById(id: number): Promise<T | null>;
14
+ getByUuid(uuid: string): Promise<T | null>;
15
+ getAll(page: number, limit: number, entityId?: any | null): Promise<IDataPaginator<T>>;
16
+ update(id: number, item: T): Promise<T | null>;
17
+ delete(id: number): Promise<boolean>;
18
+ }
@@ -69,13 +69,15 @@ interface IGroupedResponse {
69
69
  }
70
70
 
71
71
  interface IStudyTimeGroupResult {
72
- absoluteTime: string; // ISO 8601 start of bucket
72
+ absoluteTime: string; // ISO 8601 start of bucket
73
73
  groupIndex: number;
74
- label: string; // Formatted label
74
+ startMinute: number; // Start minute number (0-based from video start)
75
+ endMinute: number; // End minute number (0-based from video start)
76
+ label: string; // Formatted label
75
77
  results: ITMCResult | IATRResult;
76
78
  minuteCount: number;
77
79
  videoCount: number;
78
- contributingVideos: string[]; // Video UUIDs
80
+ contributingVideos: string[]; // Video UUIDs
79
81
  }
80
82
 
81
83
  interface IGroupedStudyResponse {
@@ -85,7 +87,7 @@ interface IGroupedStudyResponse {
85
87
  study: {
86
88
  uuid: string;
87
89
  name: string;
88
- type: "TMC" | "ATR";
90
+ type: 'TMC' | 'ATR';
89
91
  status: string;
90
92
  };
91
93
  videoCount: number;
@@ -421,41 +423,37 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
421
423
  // Use Knex query builder for safe parameter binding
422
424
  const groupingQuery = this.knex(this.tableName)
423
425
  .select(
424
- this.knex.raw("FLOOR(minute_number / ?) as group_index", [
425
- groupingMinutes,
426
- ]),
427
- this.knex.raw("MIN(minute_number) as start_minute"),
428
- this.knex.raw("MAX(minute_number) as end_minute"),
429
- this.knex.raw("COUNT(*) as minute_count"),
430
- this.knex.raw(
431
- "array_agg(results ORDER BY minute_number) as all_results",
432
- ),
426
+ this.knex.raw('FLOOR(minute_number / ?) as group_index', [groupingMinutes]),
427
+ this.knex.raw('MIN(minute_number) as start_minute'),
428
+ this.knex.raw('MAX(minute_number) as end_minute'),
429
+ this.knex.raw('COUNT(*) as minute_count'),
430
+ this.knex.raw('array_agg(results ORDER BY minute_number) as all_results')
433
431
  )
434
- .where("video_id", video.id);
432
+ .where('video_id', video.id);
435
433
 
436
434
  if (startMinute !== undefined) {
437
- groupingQuery.where("minute_number", ">=", startMinute);
435
+ groupingQuery.where('minute_number', '>=', startMinute);
438
436
  }
439
437
 
440
438
  if (endMinute !== undefined) {
441
- groupingQuery.where("minute_number", "<=", endMinute);
439
+ groupingQuery.where('minute_number', '<=', endMinute);
442
440
  }
443
441
 
444
442
  const rows = await groupingQuery
445
- .groupBy("group_index")
446
- .orderBy("group_index");
443
+ .groupBy('group_index')
444
+ .orderBy('group_index');
447
445
 
448
446
  // Aggregate the results in TypeScript based on video type
449
447
  const aggregatedGroups: IGroupedResult[] = rows.map((row: any) => {
450
- if (!row || typeof row !== "object") {
451
- throw new Error("Invalid row data received from database query");
448
+ if (!row || typeof row !== 'object') {
449
+ throw new Error('Invalid row data received from database query');
452
450
  }
453
451
 
454
452
  const allResults = Array.isArray(row.all_results) ? row.all_results : [];
455
-
453
+
456
454
  // Determine video type based on multiple factors
457
- let studyType = video.videoType || "ATR"; // Default fallback to ATR
458
-
455
+ let studyType = video.videoType || 'ATR'; // Default fallback to ATR
456
+
459
457
  // Check if minute data has study_type field (ATR usually does)
460
458
  if (allResults.length > 0 && allResults[0].study_type) {
461
459
  studyType = allResults[0].study_type;
@@ -467,24 +465,20 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
467
465
  const vehicleKeys = Object.keys(sampleResult.vehicles);
468
466
  if (vehicleKeys.length > 0) {
469
467
  const firstVehicleType = sampleResult.vehicles[vehicleKeys[0]];
470
- if (firstVehicleType && typeof firstVehicleType === "object") {
468
+ if (firstVehicleType && typeof firstVehicleType === 'object') {
471
469
  const directions = Object.keys(firstVehicleType);
472
- if (
473
- directions.includes("NORTH") ||
474
- directions.includes("SOUTH") ||
475
- directions.includes("EAST") ||
476
- directions.includes("WEST")
477
- ) {
478
- studyType = "TMC";
470
+ if (directions.includes('NORTH') || directions.includes('SOUTH') ||
471
+ directions.includes('EAST') || directions.includes('WEST')) {
472
+ studyType = 'TMC';
479
473
  }
480
474
  }
481
475
  }
482
476
  }
483
477
  }
484
-
478
+
485
479
  // Aggregate based on determined video type
486
480
  let aggregatedResult;
487
- if (studyType === "TMC") {
481
+ if (studyType === 'TMC') {
488
482
  aggregatedResult = this.aggregateTMCResults(allResults);
489
483
  } else {
490
484
  aggregatedResult = this.aggregateATRResults(allResults);
@@ -575,8 +569,8 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
575
569
  "v.uuid as videoUuid",
576
570
  "v.recordingStartedAt",
577
571
  this.knex.raw(
578
- '"v"."recordingStartedAt" + (vmr.minute_number || \' minutes\')::INTERVAL as "absoluteTime"',
579
- ),
572
+ '"v"."recordingStartedAt" + (vmr.minute_number || \' minutes\')::INTERVAL as "absoluteTime"'
573
+ )
580
574
  )
581
575
  .whereIn("v.id", videoIds)
582
576
  .orderBy("absoluteTime", "asc");
@@ -614,7 +608,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
614
608
  for (const minute of minuteResults) {
615
609
  const absoluteTime = new Date(minute.absoluteTime);
616
610
  const minutesSinceEarliest = Math.floor(
617
- (absoluteTime.getTime() - earliestTime.getTime()) / (1000 * 60),
611
+ (absoluteTime.getTime() - earliestTime.getTime()) / (1000 * 60)
618
612
  );
619
613
  const bucketIndex = Math.floor(minutesSinceEarliest / groupingMinutes);
620
614
 
@@ -622,7 +616,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
622
616
  // Calculate bucket start time
623
617
  const bucketStartMinutes = bucketIndex * groupingMinutes;
624
618
  const bucketStartTime = new Date(
625
- earliestTime.getTime() + bucketStartMinutes * 60 * 1000,
619
+ earliestTime.getTime() + bucketStartMinutes * 60 * 1000
626
620
  );
627
621
 
628
622
  buckets.set(bucketIndex, {
@@ -639,14 +633,12 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
639
633
  }
640
634
 
641
635
  // Step 5: Aggregate using existing methods based on study type
642
- const aggregatedGroups: IStudyTimeGroupResult[] = Array.from(
643
- buckets.values(),
644
- )
636
+ const aggregatedGroups: IStudyTimeGroupResult[] = Array.from(buckets.values())
645
637
  .sort((a, b) => a.groupIndex - b.groupIndex)
646
638
  .map((bucket) => {
647
639
  let aggregatedResult: ITMCResult | IATRResult;
648
640
 
649
- if (study.type === "TMC") {
641
+ if (study.type === 'TMC') {
650
642
  aggregatedResult = this.aggregateTMCResults(bucket.results);
651
643
  } else {
652
644
  aggregatedResult = this.aggregateATRResults(bucket.results);
@@ -655,10 +647,9 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
655
647
  return {
656
648
  absoluteTime: bucket.absoluteTime.toISOString(),
657
649
  groupIndex: bucket.groupIndex,
658
- label: this.formatStudyTimeLabel(
659
- bucket.absoluteTime,
660
- groupingMinutes,
661
- ),
650
+ startMinute: bucket.groupIndex * groupingMinutes,
651
+ endMinute: (bucket.groupIndex + 1) * groupingMinutes - 1,
652
+ label: this.formatStudyTimeLabel(bucket.absoluteTime, groupingMinutes),
662
653
  results: aggregatedResult,
663
654
  minuteCount: bucket.results.length,
664
655
  videoCount: bucket.videoUuids.size,
@@ -721,27 +712,25 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
721
712
  vehicles: {},
722
713
  counts: {
723
714
  total_vehicles: 0,
724
- entry_vehicles: 0,
715
+ entry_vehicles: 0
725
716
  },
726
717
  total: 0,
727
718
  totalcount: 0,
728
719
  detected_classes: {},
729
- study_type: "TMC",
720
+ study_type: "TMC"
730
721
  };
731
722
 
732
723
  for (const minute of minutes) {
733
724
  const results = minute; // minute is already the results object from array_agg
734
725
 
735
726
  // Aggregate vehicle movements by class and direction
736
- if (results.vehicles && typeof results.vehicles === "object") {
737
- for (const [vehicleClass, directions] of Object.entries(
738
- results.vehicles,
739
- )) {
727
+ if (results.vehicles && typeof results.vehicles === 'object') {
728
+ for (const [vehicleClass, directions] of Object.entries(results.vehicles)) {
740
729
  // Skip the 'total' pseudo vehicle class - validate it's actually aggregate data
741
- if (vehicleClass === "total" && typeof directions === "number") {
730
+ if (vehicleClass === 'total' && typeof directions === 'number') {
742
731
  continue; // This is aggregate total data, not a vehicle class
743
732
  }
744
-
733
+
745
734
  if (!aggregated.vehicles[vehicleClass]) {
746
735
  aggregated.vehicles[vehicleClass] = {};
747
736
  }
@@ -764,12 +753,11 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
764
753
  for (const [turnType, count] of Object.entries(turns)) {
765
754
  const turnCount = (count as number) || 0;
766
755
  aggregated.vehicles[vehicleClass][direction][turnType] =
767
- (aggregated.vehicles[vehicleClass][direction][turnType] ||
768
- 0) + turnCount;
769
-
756
+ (aggregated.vehicles[vehicleClass][direction][turnType] || 0) + turnCount;
757
+
770
758
  // Add to detected_classes count for this vehicle type
771
759
  aggregated.detected_classes[vehicleClass] += turnCount;
772
-
760
+
773
761
  // Add to total counts
774
762
  aggregated.total += turnCount;
775
763
  aggregated.totalcount += turnCount;
@@ -777,12 +765,10 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
777
765
  }
778
766
  }
779
767
  }
780
-
768
+
781
769
  // Also process the 'total' entry for validation but don't count it as a vehicle class
782
770
  if (results.vehicles.total) {
783
- for (const [direction, turns] of Object.entries(
784
- results.vehicles.total as any,
785
- )) {
771
+ for (const [direction, turns] of Object.entries(results.vehicles.total as any)) {
786
772
  if (typeof turns === "object" && turns !== null) {
787
773
  for (const [turnType, count] of Object.entries(turns)) {
788
774
  const turnCount = (count as number) || 0;
@@ -795,8 +781,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
795
781
  }
796
782
 
797
783
  // Set entry_vehicles same as total_vehicles for TMC
798
- aggregated.counts.entry_vehicles =
799
- aggregated.counts.total_vehicles || aggregated.total;
784
+ aggregated.counts.entry_vehicles = aggregated.counts.total_vehicles || aggregated.total;
800
785
 
801
786
  return aggregated;
802
787
  }
@@ -820,7 +805,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
820
805
  if (results.vehicles) {
821
806
  for (const [vehicleClass, lanes] of Object.entries(results.vehicles)) {
822
807
  // Skip 'total' pseudo-class if present
823
- if (vehicleClass === "total") {
808
+ if (vehicleClass === 'total') {
824
809
  continue;
825
810
  }
826
811
 
@@ -829,8 +814,7 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
829
814
  }
830
815
 
831
816
  for (const [laneId, count] of Object.entries(lanes as any)) {
832
- const numericCount =
833
- typeof count === "number" ? count : parseInt(String(count)) || 0;
817
+ const numericCount = typeof count === 'number' ? count : (parseInt(String(count)) || 0);
834
818
  aggregated.vehicles[vehicleClass][laneId] =
835
819
  (aggregated.vehicles[vehicleClass][laneId] || 0) + numericCount;
836
820
  }
@@ -935,16 +919,14 @@ export class VideoMinuteResultDAO implements IBaseDAO<IVideoMinuteResult> {
935
919
  /**
936
920
  * Format time label for study-level results (time only, no dates)
937
921
  * Used when results are already grouped by date in the UI
922
+ * Uses UTC time for consistency with absoluteTime field
938
923
  */
939
- private formatStudyTimeLabel(
940
- startTime: Date,
941
- durationMinutes: number,
942
- ): string {
924
+ private formatStudyTimeLabel(startTime: Date, durationMinutes: number): string {
943
925
  const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000);
944
926
 
945
927
  const formatTime = (date: Date): string => {
946
- const hours = date.getHours().toString().padStart(2, "0");
947
- const minutes = date.getMinutes().toString().padStart(2, "0");
928
+ const hours = date.getUTCHours().toString().padStart(2, "0");
929
+ const minutes = date.getUTCMinutes().toString().padStart(2, "0");
948
930
  return `${hours}:${minutes}`;
949
931
  };
950
932