@trafficgroup/knex-rel 0.1.6 → 0.1.8

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 (37) hide show
  1. package/dist/dao/VideoMinuteResultDAO.d.ts +37 -0
  2. package/dist/dao/VideoMinuteResultDAO.js +146 -0
  3. package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
  4. package/dist/dao/batch/batch.dao.d.ts +27 -0
  5. package/dist/dao/batch/batch.dao.js +135 -0
  6. package/dist/dao/batch/batch.dao.js.map +1 -0
  7. package/dist/dao/folder/folder.dao.js +3 -6
  8. package/dist/dao/folder/folder.dao.js.map +1 -1
  9. package/dist/dao/study/study.dao.d.ts +1 -0
  10. package/dist/dao/study/study.dao.js +18 -3
  11. package/dist/dao/study/study.dao.js.map +1 -1
  12. package/dist/dao/video/video.dao.d.ts +19 -5
  13. package/dist/dao/video/video.dao.js +61 -32
  14. package/dist/dao/video/video.dao.js.map +1 -1
  15. package/dist/index.d.ts +4 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/interfaces/batch/batch.interfaces.d.ts +13 -0
  19. package/dist/interfaces/batch/batch.interfaces.js +3 -0
  20. package/dist/interfaces/batch/batch.interfaces.js.map +1 -0
  21. package/dist/interfaces/folder/folder.interfaces.d.ts +0 -3
  22. package/dist/interfaces/study/study.interfaces.d.ts +5 -0
  23. package/dist/interfaces/video/video.interfaces.d.ts +8 -3
  24. package/migrations/20251010143500_migration.ts +83 -0
  25. package/migrations/20251020225758_migration.ts +135 -0
  26. package/package.json +1 -1
  27. package/plan.md +524 -711
  28. package/src/dao/VideoMinuteResultDAO.ts +232 -0
  29. package/src/dao/batch/batch.dao.ts +121 -0
  30. package/src/dao/folder/folder.dao.ts +3 -18
  31. package/src/dao/study/study.dao.ts +34 -3
  32. package/src/dao/video/video.dao.ts +70 -43
  33. package/src/index.ts +11 -1
  34. package/src/interfaces/batch/batch.interfaces.ts +14 -0
  35. package/src/interfaces/folder/folder.interfaces.ts +0 -3
  36. package/src/interfaces/study/study.interfaces.ts +5 -0
  37. package/src/interfaces/video/video.interfaces.ts +12 -4
package/plan.md CHANGED
@@ -1,871 +1,684 @@
1
- # Total Vehicle Class Implementation Plan
1
+ # Plan: Add Study-Level Minute Result Aggregation
2
2
 
3
- ## Executive Summary
3
+ ## Overview
4
4
 
5
- This plan details the implementation of a "Total" vehicle class that aggregates all custom vehicle classes for both TMC and ATR minute results. The "Total" class will be added during transformation in the knex-rel module and will always appear as the LAST class in the vehicles object.
6
-
7
- **Key Requirements**:
8
-
9
- - Add "Total" class AFTER all custom classes
10
- - For ATR: "Total" reuses existing `lane_counts` (all classes per lane)
11
- - For TMC: "Total" sums all vehicle classes across all directions and turns
12
- - NO database schema changes required
13
- - Transformation happens in `ReportConfigurationDAO.applyConfigurationToNestedStructure()`
5
+ Add `getGroupedMinuteResultsByStudyUuid()` method to `VideoMinuteResultDAO` to aggregate minute results across all COMPLETED videos in a study, with time normalization using `recordingStartedAt`.
14
6
 
15
7
  ---
16
8
 
17
- ## Current Architecture Analysis
9
+ ## Schema Analysis (Existing)
18
10
 
19
- ### Data Flow
11
+ ### Table Relationships
20
12
 
21
13
  ```
22
- 1. Python Processor → Database (JSONB results with detection labels)
23
-
24
- 2. VideoMinuteResultDAO Retrieve raw minute results
25
-
26
- 3. API Controller Apply report configuration
27
-
28
- 4. ReportConfigurationService.transformVideoResults()
29
-
30
- 5. ReportConfigurationDAO.applyConfigurationToNestedStructure()
31
-
32
- 6. Frontend → Display custom classes + Total
33
- ```
34
-
35
- ### Current Structure Patterns
36
-
37
- **ATR Format (Lane-based)**:
38
-
39
- ```json
40
- {
41
- "vehicles": {
42
- "car": { "0": 45, "1": 50 },
43
- "truck": { "0": 10, "1": 8 }
44
- },
45
- "lane_counts": { "0": 55, "1": 58 }, // Already has totals per lane!
46
- "total_count": 113,
47
- "study_type": "ATR"
48
- }
49
- ```
50
-
51
- **TMC Format (Direction/Turn-based)**:
52
-
53
- ```json
54
- {
55
- "vehicles": {
56
- "car": {
57
- "NORTH": { "straight": 10, "left": 5, "right": 3, "u-turn": 0 },
58
- "SOUTH": { "straight": 8, "left": 2, "right": 4, "u-turn": 1 }
59
- },
60
- "truck": {
61
- "NORTH": { "straight": 5, "left": 1, "right": 0, "u-turn": 0 }
62
- }
63
- },
64
- "counts": {
65
- "total_vehicles": 39,
66
- "entry_vehicles": 39
67
- },
68
- "study_type": "TMC"
69
- }
14
+ study (id, uuid, name, type, status)
15
+ (1:N)
16
+ folders (id, uuid, studyId)
17
+ (1:N)
18
+ video (id, uuid, folderId, recordingStartedAt, status, videoType)
19
+ (1:N)
20
+ video_minute_results (id, uuid, video_id, minute_number, results)
70
21
  ```
71
22
 
72
- **After Custom Class Transformation** (2 custom classes: "Light" and "Heavy"):
23
+ ### Key Fields
73
24
 
74
- ```json
75
- {
76
- "vehicles": {
77
- "Light": { "NORTH": { "straight": 10, "left": 5 }, ... },
78
- "Heavy": { "NORTH": { "straight": 5, "left": 1 }, ... }
79
- }
80
- }
81
- ```
25
+ - `video.recordingStartedAt`: TIMESTAMPTZ in UTC (nullable for backward compatibility)
26
+ - `video.status`: 'QUEUED' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'PENDING'
27
+ - `video_minute_results.minute_number`: Integer offset from video start (0-based)
28
+ - `video_minute_results.results`: JSONB containing TMC or ATR data
82
29
 
83
- **After Total Addition** (desired output):
30
+ ### Existing Indexes
84
31
 
85
- ```json
86
- {
87
- "vehicles": {
88
- "Light": { "NORTH": { "straight": 10, "left": 5 }, ... },
89
- "Heavy": { "NORTH": { "straight": 5, "left": 1 }, ... },
90
- "Total": { "NORTH": { "straight": 15, "left": 6 }, ... } // Sum of all classes
91
- }
92
- }
93
- ```
32
+ - `idx_videos_folder_recording` on (folderId, recordingStartedAt) WHERE recordingStartedAt IS NOT NULL
94
33
 
95
34
  ---
96
35
 
97
- ## Implementation Location
36
+ ## Interface Definitions
98
37
 
99
- ### File: `knex-rel/src/dao/report-configuration/report-configuration.dao.ts`
38
+ ### New Response Interface
100
39
 
101
- **Method to Modify**: `applyConfigurationToNestedStructure()` (Lines 273-315)
40
+ **File**: `knex-rel/src/dao/VideoMinuteResultDAO.ts` (add at top with existing interfaces)
102
41
 
103
- **Current Implementation**:
104
-
105
- 1. Builds reverse mapping: detection label custom class name (Lines 278-290)
106
- 2. Initializes empty structure for each custom class (Lines 293-296)
107
- 3. Iterates through detection labels and merges into custom classes (Lines 299-312)
108
- 4. Returns transformed structure (Line 314)
42
+ ```typescript
43
+ interface IStudyTimeGroupResult {
44
+ absoluteTime: string; // ISO 8601 datetime for bucket start (e.g., "2024-10-26T14:30:00Z")
45
+ groupIndex: number; // Sequential index for UI ordering
46
+ label: string; // Human-readable label (e.g., "2:30 PM - 2:44 PM")
47
+ results: ITMCResult | IATRResult; // Aggregated results (reuse existing types)
48
+ minuteCount: number; // Total individual minutes aggregated
49
+ videoCount: number; // Number of distinct videos contributing
50
+ contributingVideos: string[]; // Array of video UUIDs for traceability
51
+ }
109
52
 
110
- **Where to Add "Total"**: AFTER line 312, BEFORE return statement (Line 314)
53
+ interface IGroupedStudyResponse {
54
+ success: boolean;
55
+ data: IStudyTimeGroupResult[];
56
+ groupingMinutes: number; // Grouping interval (1, 5, 10, 15, 30, 60)
57
+ study: {
58
+ uuid: string;
59
+ name: string;
60
+ type: "TMC" | "ATR";
61
+ status: string;
62
+ };
63
+ videoCount: number; // Total videos included in aggregation
64
+ dateRange: {
65
+ earliest: string; // ISO 8601 of earliest recording start
66
+ latest: string; // ISO 8601 of latest recording end
67
+ };
68
+ }
69
+ ```
111
70
 
112
71
  ---
113
72
 
114
- ## Detailed Implementation Plan
115
-
116
- ### Step 1: Add "Total" Class After Custom Class Transformation
117
-
118
- **Location**: `ReportConfigurationDAO.applyConfigurationToNestedStructure()` (After line 312)
119
-
120
- **Logic**:
121
-
122
- ```typescript
123
- // After line 312 (end of detection label iteration)
124
- // Add "Total" class that aggregates all custom classes
125
-
126
- result["Total"] = this._createTotalClass(result);
73
+ ## SQL Query Strategy
74
+
75
+ ### Phase 1: Get Study Videos with Metadata
76
+
77
+ **Purpose**: Fetch all COMPLETED videos with recordingStartedAt, validate study exists
78
+
79
+ ```sql
80
+ SELECT
81
+ s.id as study_id,
82
+ s.uuid as study_uuid,
83
+ s.name as study_name,
84
+ s.type as study_type,
85
+ s.status as study_status,
86
+ v.id as video_id,
87
+ v.uuid as video_uuid,
88
+ v.name as video_name,
89
+ v.recording_started_at,
90
+ v.duration_seconds
91
+ FROM study s
92
+ INNER JOIN folders f ON f.study_id = s.id
93
+ INNER JOIN video v ON v.folder_id = f.id
94
+ WHERE s.uuid = ?
95
+ AND v.status = 'COMPLETED'
96
+ AND v.recording_started_at IS NOT NULL
97
+ ORDER BY v.recording_started_at ASC;
98
+ ```
127
99
 
128
- return result;
100
+ **Error Handling**:
101
+
102
+ - If study not found → throw Error("Study with UUID {uuid} not found")
103
+ - If no videos found → return empty data array with study metadata
104
+ - If videos missing recordingStartedAt → log warning, exclude from aggregation
105
+
106
+ ### Phase 2: Get All Minute Results with Time Normalization
107
+
108
+ **Purpose**: Fetch minute results with absolute timestamps calculated in SQL
109
+
110
+ ```sql
111
+ SELECT
112
+ vmr.id,
113
+ vmr.video_id,
114
+ vmr.minute_number,
115
+ vmr.results,
116
+ v.uuid as video_uuid,
117
+ v.recording_started_at,
118
+ -- Calculate absolute wall-clock time for this minute
119
+ (v.recording_started_at + (vmr.minute_number || ' minutes')::INTERVAL) as absolute_time,
120
+ -- Calculate time bucket index (floor division by grouping interval)
121
+ FLOOR(
122
+ EXTRACT(EPOCH FROM (v.recording_started_at + (vmr.minute_number || ' minutes')::INTERVAL)) / (? * 60)
123
+ ) as time_bucket
124
+ FROM video_minute_results vmr
125
+ INNER JOIN video v ON vmr.video_id = v.id
126
+ WHERE v.id IN (?) -- Array of video IDs from Phase 1
127
+ ORDER BY time_bucket, vmr.minute_number;
129
128
  ```
130
129
 
131
- ### Step 2: Implement `_createTotalClass()` Helper Method
130
+ **Parameters**:
132
131
 
133
- **Location**: Add new private method in `ReportConfigurationDAO` class (After line 350)
132
+ - `?` (first): groupingMinutes (e.g., 15)
133
+ - `?` (second): Array of video IDs from Phase 1
134
134
 
135
- **Method Signature**:
135
+ **Output**: Rows with absolute timestamps and time bucket assignments
136
136
 
137
- ```typescript
138
- private _createTotalClass(customClassesData: Record<string, any>): any
139
- ```
137
+ ### Phase 3: Aggregate by Time Bucket (TypeScript)
140
138
 
141
- **Implementation**:
139
+ **Reason for TypeScript aggregation**:
142
140
 
143
- ```typescript
144
- /**
145
- * Create "Total" class by aggregating all custom vehicle classes
146
- *
147
- * Handles both ATR and TMC formats:
148
- * - ATR: Aggregates counts per lane across all vehicle classes
149
- * - TMC: Aggregates counts per direction and turn across all vehicle classes
150
- *
151
- * @param customClassesData - Transformed custom classes structure
152
- * @returns Aggregated totals matching the same nested structure
153
- */
154
- private _createTotalClass(customClassesData: Record<string, any>): any {
155
- const total: any = {};
141
+ - Reuse existing `aggregateTMCResults()` and `aggregateATRResults()` methods
142
+ - Complex nested JSON aggregation is easier in TypeScript than SQL
143
+ - Study type determines aggregation logic (not individual video types)
156
144
 
157
- // Iterate through all custom classes and merge their data
158
- for (const [className, nestedData] of Object.entries(customClassesData)) {
159
- // Use existing _deepMergeNumericData to sum all nested values
160
- Object.assign(total, this._deepMergeNumericData(total, nestedData));
161
- }
145
+ **Pseudo-SQL for Grouping** (illustrative, actual grouping in TS):
162
146
 
163
- return total;
164
- }
147
+ ```sql
148
+ SELECT
149
+ time_bucket,
150
+ MIN(absolute_time) as bucket_start_time,
151
+ MAX(absolute_time) as bucket_end_time,
152
+ COUNT(*) as minute_count,
153
+ COUNT(DISTINCT video_id) as video_count,
154
+ array_agg(DISTINCT video_uuid) as contributing_videos,
155
+ array_agg(results ORDER BY absolute_time) as all_results
156
+ FROM [results_from_phase_2]
157
+ GROUP BY time_bucket
158
+ ORDER BY time_bucket;
165
159
  ```
166
160
 
167
- **Explanation**:
168
-
169
- - Reuses existing `_deepMergeNumericData()` helper (Lines 328-350)
170
- - Works for both ATR (2-level: vehicle → lane → count) and TMC (3-level: vehicle → direction → turn → count)
171
- - No need to detect study type - structure-agnostic aggregation
172
- - Handles empty data (returns empty object `{}`)
173
-
174
161
  ---
175
162
 
176
- ## Key Ordering Strategy
163
+ ## TypeScript Implementation Logic
177
164
 
178
- ### Ensuring "Total" Appears Last
179
-
180
- JavaScript object key ordering is preserved in modern engines (ES2015+) when:
181
-
182
- 1. String keys are added in insertion order
183
- 2. Keys are enumerated via `Object.entries()`, `Object.keys()`, `for...in`
184
-
185
- **Our Implementation**:
165
+ ### Method Signature
186
166
 
187
167
  ```typescript
188
- // Lines 293-296: Initialize custom classes FIRST (insertion order)
189
- for (const customClass of config.configuration.customClasses) {
190
- result[customClass.name] = {};
191
- }
192
-
193
- // Lines 299-312: Populate custom classes (preserves order)
194
- for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
195
- // ... merge logic
196
- }
197
-
198
- // NEW: Add "Total" LAST (after all custom classes)
199
- result["Total"] = this._createTotalClass(result);
200
-
201
- // Line 314: Return result (Total is last key)
202
- return result;
168
+ async getGroupedMinuteResultsByStudyUuid(
169
+ studyUuid: string,
170
+ groupingMinutes: number = 15
171
+ ): Promise<IGroupedStudyResponse>
203
172
  ```
204
173
 
205
- **Why This Works**:
174
+ ### Step-by-Step Logic
206
175
 
207
- - Custom classes initialized in config order (Lines 293-296)
208
- - "Total" added AFTER all custom classes (new code)
209
- - JavaScript guarantees insertion order preservation
210
- - Frontend will render "Total" as last tab
176
+ #### Step 1: Fetch Study and Videos
211
177
 
212
- ---
213
-
214
- ## ATR vs TMC Behavior
215
-
216
- ### ATR (Lane-Based Totals)
217
-
218
- **Input Structure**:
219
-
220
- ```json
221
- {
222
- "Light": { "0": 45, "1": 50 },
223
- "Heavy": { "0": 10, "1": 8 }
178
+ ```typescript
179
+ // Get study metadata
180
+ const study = await this.knex("study").where("uuid", studyUuid).first();
181
+ if (!study) {
182
+ throw new Error(`Study with UUID ${studyUuid} not found`);
224
183
  }
225
- ```
226
-
227
- **Total Output**:
228
184
 
229
- ```json
230
- {
231
- "Total": { "0": 55, "1": 58 } // Sum per lane
185
+ // Get all COMPLETED videos with recordingStartedAt
186
+ const videos = await this.knex("video as v")
187
+ .innerJoin("folders as f", "v.folderId", "f.id")
188
+ .select(
189
+ "v.id",
190
+ "v.uuid",
191
+ "v.name",
192
+ "v.recordingStartedAt",
193
+ "v.durationSeconds",
194
+ )
195
+ .where("f.studyId", study.id)
196
+ .where("v.status", "COMPLETED")
197
+ .whereNotNull("v.recordingStartedAt")
198
+ .orderBy("v.recordingStartedAt", "asc");
199
+
200
+ // Early return if no valid videos
201
+ if (videos.length === 0) {
202
+ return {
203
+ success: true,
204
+ data: [],
205
+ groupingMinutes,
206
+ study: {
207
+ uuid: study.uuid,
208
+ name: study.name,
209
+ type: study.type,
210
+ status: study.status,
211
+ },
212
+ videoCount: 0,
213
+ dateRange: {
214
+ earliest: null,
215
+ latest: null,
216
+ },
217
+ };
232
218
  }
233
219
  ```
234
220
 
235
- **Calculation**:
221
+ #### Step 2: Fetch and Transform Minute Results
236
222
 
237
- - Lane "0": 45 (Light) + 10 (Heavy) = 55
238
- - Lane "1": 50 (Light) + 8 (Heavy) = 58
223
+ ```typescript
224
+ const videoIds = videos.map((v) => v.id);
225
+ const videoMap = new Map(videos.map((v) => [v.id, v]));
226
+
227
+ // Fetch minute results with absolute time calculation
228
+ const minuteResults = await this.knex("video_minute_results as vmr")
229
+ .innerJoin("video as v", "vmr.video_id", "v.id")
230
+ .select(
231
+ "vmr.video_id",
232
+ "vmr.minute_number",
233
+ "vmr.results",
234
+ "v.uuid as video_uuid",
235
+ this.knex.raw(`
236
+ v.recording_started_at + (vmr.minute_number || ' minutes')::INTERVAL
237
+ as absolute_time
238
+ `),
239
+ this.knex.raw(
240
+ `
241
+ FLOOR(
242
+ EXTRACT(EPOCH FROM (v.recording_started_at + (vmr.minute_number || ' minutes')::INTERVAL))
243
+ / (? * 60)
244
+ ) as time_bucket
245
+ `,
246
+ [groupingMinutes],
247
+ ),
248
+ )
249
+ .whereIn("v.id", videoIds)
250
+ .orderBy("time_bucket")
251
+ .orderBy("vmr.minute_number");
252
+ ```
239
253
 
240
- **Alternative (Use existing `lane_counts`)**:
254
+ #### Step 3: Group by Time Buckets
241
255
 
242
- The ATR structure ALREADY has `lane_counts` at the top level:
256
+ ```typescript
257
+ // Group results by time bucket
258
+ const bucketMap = new Map<
259
+ number,
260
+ {
261
+ absoluteTime: Date;
262
+ results: any[];
263
+ videoUuids: Set<string>;
264
+ minuteCount: number;
265
+ }
266
+ >();
267
+
268
+ for (const row of minuteResults) {
269
+ const bucket = row.time_bucket;
270
+
271
+ if (!bucketMap.has(bucket)) {
272
+ bucketMap.set(bucket, {
273
+ absoluteTime: new Date(row.absolute_time),
274
+ results: [],
275
+ videoUuids: new Set(),
276
+ minuteCount: 0,
277
+ });
278
+ }
243
279
 
244
- ```json
245
- {
246
- "vehicles": { "Light": {...}, "Heavy": {...} },
247
- "lane_counts": { "0": 55, "1": 58 } // Already calculated!
280
+ const bucketData = bucketMap.get(bucket)!;
281
+ bucketData.results.push(row.results);
282
+ bucketData.videoUuids.add(row.video_uuid);
283
+ bucketData.minuteCount++;
248
284
  }
249
285
  ```
250
286
 
251
- **Decision**: For ATR, we can EITHER:
287
+ #### Step 4: Aggregate Each Bucket
252
288
 
253
- 1. ✅ **Option A (Recommended)**: Calculate "Total" by summing custom classes (consistent with TMC)
254
- 2. ⚠️ **Option B**: Copy `lane_counts` directly into `vehicles["Total"]` (reuses existing data)
255
-
256
- **Recommendation**: Use Option A (sum custom classes) for consistency with TMC, even though Option B is technically available.
257
-
258
- ### TMC (Direction/Turn-Based Totals)
259
-
260
- **Input Structure**:
289
+ ```typescript
290
+ // Sort buckets by time
291
+ const sortedBuckets = Array.from(bucketMap.entries()).sort(
292
+ (a, b) => a[0] - b[0],
293
+ );
294
+
295
+ const aggregatedGroups: IStudyTimeGroupResult[] = sortedBuckets.map(
296
+ ([bucketIndex, bucketData], idx) => {
297
+ // Determine aggregation type based on study type
298
+ let aggregatedResult;
299
+ if (study.type === "TMC") {
300
+ aggregatedResult = this.aggregateTMCResults(bucketData.results);
301
+ } else {
302
+ aggregatedResult = this.aggregateATRResults(bucketData.results);
303
+ }
261
304
 
262
- ```json
263
- {
264
- "Light": {
265
- "NORTH": { "straight": 10, "left": 5, "right": 3 },
266
- "SOUTH": { "straight": 8, "left": 2, "right": 4 }
305
+ // Calculate bucket end time
306
+ const bucketStartTime = bucketData.absoluteTime;
307
+ const bucketEndTime = new Date(
308
+ bucketStartTime.getTime() + groupingMinutes * 60 * 1000 - 1000,
309
+ );
310
+
311
+ return {
312
+ absoluteTime: bucketStartTime.toISOString(),
313
+ groupIndex: idx,
314
+ label: this.formatAbsoluteTimeLabel(bucketStartTime, bucketEndTime),
315
+ results: aggregatedResult,
316
+ minuteCount: bucketData.minuteCount,
317
+ videoCount: bucketData.videoUuids.size,
318
+ contributingVideos: Array.from(bucketData.videoUuids),
319
+ };
267
320
  },
268
- "Heavy": {
269
- "NORTH": { "straight": 5, "left": 1, "right": 0 },
270
- "SOUTH": { "straight": 2, "left": 0, "right": 1 }
271
- }
272
- }
321
+ );
273
322
  ```
274
323
 
275
- **Total Output**:
324
+ #### Step 5: Calculate Date Range
276
325
 
277
- ```json
278
- {
279
- "Total": {
280
- "NORTH": { "straight": 15, "left": 6, "right": 3 },
281
- "SOUTH": { "straight": 10, "left": 2, "right": 5 }
282
- }
283
- }
326
+ ```typescript
327
+ const earliestVideo = videos[0]; // Already sorted by recordingStartedAt
328
+ const latestVideo = videos[videos.length - 1];
329
+
330
+ // Calculate actual end time of last video
331
+ const latestEndTime = latestVideo.durationSeconds
332
+ ? new Date(
333
+ new Date(latestVideo.recordingStartedAt).getTime() +
334
+ latestVideo.durationSeconds * 1000,
335
+ )
336
+ : new Date(latestVideo.recordingStartedAt);
337
+
338
+ return {
339
+ success: true,
340
+ data: aggregatedGroups,
341
+ groupingMinutes,
342
+ study: {
343
+ uuid: study.uuid,
344
+ name: study.name,
345
+ type: study.type,
346
+ status: study.status,
347
+ },
348
+ videoCount: videos.length,
349
+ dateRange: {
350
+ earliest: earliestVideo.recordingStartedAt.toISOString(),
351
+ latest: latestEndTime.toISOString(),
352
+ },
353
+ };
284
354
  ```
285
355
 
286
- **Calculation**:
287
-
288
- - NORTH/straight: 10 (Light) + 5 (Heavy) = 15
289
- - NORTH/left: 5 (Light) + 1 (Heavy) = 6
290
- - SOUTH/straight: 8 (Light) + 2 (Heavy) = 10
291
- - etc.
292
-
293
356
  ---
294
357
 
295
- ## Edge Cases & Validation
358
+ ## Code Reuse Strategy
296
359
 
297
- ### Edge Case 1: Empty Data (No Vehicles)
360
+ ### Existing Methods to Reuse
298
361
 
299
- **Input**:
362
+ 1. **`aggregateTMCResults(minutes: any[]): ITMCResult`** (lines 508-585)
363
+ - No changes needed
364
+ - Accepts array of raw results objects
365
+ - Returns aggregated TMC structure
300
366
 
301
- ```json
302
- {
303
- "vehicles": {}
304
- }
305
- ```
306
-
307
- **After Transformation**:
308
-
309
- ```json
310
- {
311
- "vehicles": {
312
- "Light": {},
313
- "Heavy": {},
314
- "Total": {} // Empty object
315
- }
316
- }
317
- ```
318
-
319
- **Behavior**: `_createTotalClass()` returns empty object `{}`
367
+ 2. **`aggregateATRResults(minutes: any[]): IATRResult`** (lines 590-643)
368
+ - No changes needed
369
+ - Accepts array of raw results objects
370
+ - Returns aggregated ATR structure
320
371
 
321
- ### Edge Case 2: Single Custom Class
372
+ 3. **`formatTimeLabel(startMinute: number, endMinute: number): string`** (lines 703-715)
373
+ - DO NOT reuse - this formats video-relative time (HH:MM format from minute offset)
374
+ - Need NEW method for absolute time formatting
322
375
 
323
- **Input**:
376
+ ### New Helper Method Required
324
377
 
325
- ```json
326
- {
327
- "vehicles": {
328
- "AllVehicles": { "0": 100, "1": 95 }
378
+ ```typescript
379
+ /**
380
+ * Format absolute time label for display (12-hour clock)
381
+ */
382
+ private formatAbsoluteTimeLabel(startTime: Date, endTime: Date): string {
383
+ const formatTime = (date: Date): string => {
384
+ const hours = date.getHours();
385
+ const minutes = date.getMinutes();
386
+ const ampm = hours >= 12 ? 'PM' : 'AM';
387
+ const displayHours = hours % 12 || 12;
388
+ return `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`;
389
+ };
390
+
391
+ const formatDate = (date: Date): string => {
392
+ return date.toLocaleDateString('en-US', {
393
+ month: 'short',
394
+ day: 'numeric',
395
+ year: 'numeric'
396
+ });
397
+ };
398
+
399
+ // If same day, show: "Oct 26: 2:30 PM - 2:44 PM"
400
+ if (startTime.toDateString() === endTime.toDateString()) {
401
+ return `${formatDate(startTime)}: ${formatTime(startTime)} - ${formatTime(endTime)}`;
329
402
  }
330
- }
331
- ```
332
-
333
- **Output**:
334
403
 
335
- ```json
336
- {
337
- "vehicles": {
338
- "AllVehicles": { "0": 100, "1": 95 },
339
- "Total": { "0": 100, "1": 95 } // Same as single class
340
- }
404
+ // If different days, show full timestamps
405
+ return `${formatDate(startTime)} ${formatTime(startTime)} - ${formatDate(endTime)} ${formatTime(endTime)}`;
341
406
  }
342
407
  ```
343
408
 
344
- **Behavior**: "Total" equals the single custom class (valid)
345
-
346
- ### Edge Case 3: Missing Directions/Turns
347
-
348
- **Input** (TMC - Light has NORTH, Heavy only has SOUTH):
409
+ ---
349
410
 
350
- ```json
351
- {
352
- "Light": { "NORTH": { "straight": 10 } },
353
- "Heavy": { "SOUTH": { "straight": 5 } }
354
- }
355
- ```
411
+ ## Error Handling Strategy
356
412
 
357
- **Output**:
413
+ ### Input Validation
358
414
 
359
- ```json
360
- {
361
- "Total": {
362
- "NORTH": { "straight": 10 },
363
- "SOUTH": { "straight": 5 }
364
- }
415
+ ```typescript
416
+ // Validate groupingMinutes
417
+ const validIntervals = [1, 5, 10, 15, 30, 60];
418
+ if (!validIntervals.includes(groupingMinutes)) {
419
+ throw new Error(
420
+ `Invalid grouping interval: ${groupingMinutes}. Must be one of: ${validIntervals.join(", ")}`,
421
+ );
365
422
  }
366
423
  ```
367
424
 
368
- **Behavior**: `_deepMergeNumericData()` handles missing keys gracefully (Lines 339-347)
425
+ ### Edge Cases
369
426
 
370
- ### Edge Case 4: Null/Undefined Values
427
+ | Scenario | Handling |
428
+ | -------------------------------------------- | -------------------------------------------------- |
429
+ | Study not found | Throw error immediately |
430
+ | No folders in study | Return empty data array with study metadata |
431
+ | No COMPLETED videos | Return empty data array, videoCount = 0 |
432
+ | Videos missing recordingStartedAt | Exclude from aggregation, log warning |
433
+ | No minute results | Return empty data array |
434
+ | Type mismatch (video.videoType ≠ study.type) | Use study.type for aggregation (trust study level) |
435
+ | Missing duration_seconds | Use last minute_number to estimate end time |
436
+ | Empty results in minute | Include in count but don't affect aggregation |
371
437
 
372
- **Input**:
438
+ ### Logging Strategy
373
439
 
374
- ```json
375
- {
376
- "Light": { "0": 10, "1": null },
377
- "Heavy": { "0": 5 }
440
+ ```typescript
441
+ // At start of method
442
+ console.log(
443
+ `[VideoMinuteResultDAO] Aggregating study ${studyUuid} with ${groupingMinutes}min intervals`,
444
+ );
445
+
446
+ // After video fetch
447
+ console.log(
448
+ `[VideoMinuteResultDAO] Found ${videos.length} COMPLETED videos with recording times`,
449
+ );
450
+ const videosWithoutTime = await this.knex("video as v")
451
+ .innerJoin("folders as f", "v.folderId", "f.id")
452
+ .where("f.studyId", study.id)
453
+ .where("v.status", "COMPLETED")
454
+ .whereNull("v.recordingStartedAt")
455
+ .count("* as count");
456
+ if (+videosWithoutTime[0].count > 0) {
457
+ console.warn(
458
+ `[VideoMinuteResultDAO] Excluding ${videosWithoutTime[0].count} videos without recordingStartedAt`,
459
+ );
378
460
  }
379
- ```
380
-
381
- **Output**:
382
461
 
383
- ```json
384
- {
385
- "Total": { "0": 15, "1": 0 } // null treated as 0
386
- }
462
+ // After aggregation
463
+ console.log(
464
+ `[VideoMinuteResultDAO] Generated ${aggregatedGroups.length} time buckets`,
465
+ );
387
466
  ```
388
467
 
389
- **Behavior**: `_deepMergeNumericData()` checks `typeof source === 'number'` (Line 330)
390
-
391
- ---
392
-
393
- ## Testing Strategy
394
-
395
- ### Unit Tests (to be created)
396
-
397
- **File**: `knex-rel/src/dao/report-configuration/report-configuration.dao.test.ts`
398
-
399
- **Test Cases**:
400
-
401
- 1. **ATR: Total Class Aggregation**
402
- - Input: 2 custom classes with lane counts
403
- - Verify: Total sums all lanes correctly
404
- - Verify: Total appears as last key
405
-
406
- 2. **TMC: Total Class Aggregation**
407
- - Input: 2 custom classes with direction/turn structure
408
- - Verify: Total sums all directions and turns
409
- - Verify: Total appears as last key
410
-
411
- 3. **Edge Case: Empty Vehicles**
412
- - Input: No vehicle data
413
- - Verify: Total is empty object `{}`
414
-
415
- 4. **Edge Case: Single Custom Class**
416
- - Input: 1 custom class
417
- - Verify: Total equals the single class
418
-
419
- 5. **Edge Case: Missing Directions**
420
- - Input: Asymmetric direction/turn data
421
- - Verify: Total includes all unique keys
422
-
423
- 6. **Key Ordering Test**
424
- - Input: 5 custom classes
425
- - Verify: Total is the 6th key (last)
426
- - Verify: `Object.keys(result)` ends with "Total"
427
-
428
- ### Integration Tests
429
-
430
- **Test in api-rel controller**:
431
-
432
- 1. Create video with ATR results
433
- 2. Apply configuration with 3 custom classes
434
- 3. Call `GET /videos/:uuid/results?configUuid=...`
435
- 4. Verify response includes "Total" as last class
436
- 5. Verify Total values equal sum of custom classes
437
-
438
- **Test in traffic-webapp**:
439
-
440
- 1. Display video results with custom classes
441
- 2. Verify "Total" tab appears last
442
- 3. Verify Total chart shows aggregated data
443
- 4. Test with both ATR and TMC videos
444
-
445
468
  ---
446
469
 
447
470
  ## Performance Considerations
448
471
 
449
- ### Computational Complexity
472
+ ### Query Optimization
450
473
 
451
- **Current**: O(n × m) where:
474
+ 1. **Single Query for Minute Results**: Fetch all data in one query with JOINs (eliminates N+1)
475
+ 2. **Index Usage**: Existing `idx_videos_folder_recording` covers video filtering
476
+ 3. **In-Memory Aggregation**: TypeScript grouping is faster than SQL for complex JSON aggregation
477
+ 4. **Early Returns**: Skip processing if no videos/results found
452
478
 
453
- - n = number of detection labels
454
- - m = average nesting depth (2 for ATR, 3 for TMC)
479
+ ### Memory Management
455
480
 
456
- **After Adding Total**: O(n × m + c × m) where:
481
+ - **Batch Size Estimate**:
482
+ - 10 videos × 60 minutes × 2KB/result = ~1.2MB
483
+ - 100 videos × 1440 minutes × 2KB/result = ~288MB
484
+ - Safe for typical studies (<50 videos)
485
+ - **Future Optimization**: If studies exceed 100 videos, consider streaming or pagination
457
486
 
458
- - c = number of custom classes (2-7 per validation)
487
+ ### Database Load
459
488
 
460
- **Impact**: Minimal - additional O(c × m) is negligible
461
-
462
- - c 7 (max custom classes)
463
- - m 3 (max nesting depth)
464
- - Total iteration: ~21 operations per minute result
465
-
466
- ### Memory Impact
467
-
468
- **Additional Memory**: One new key per transformed result
469
-
470
- - ATR Total: ~100 bytes (lanes object)
471
- - TMC Total: ~500 bytes (directions/turns object)
472
- - Per minute result: negligible
473
- - For 60 minutes: ~6-30 KB additional
474
-
475
- **Conclusion**: No performance concerns
489
+ - **Single study query**: O(1)
490
+ - **Single video query**: O(folders) - typically small
491
+ - **Single minute results query**: O(total_minutes) - most expensive
492
+ - **Total queries**: 3 (no N+1 problems)
476
493
 
477
494
  ---
478
495
 
479
- ## Backward Compatibility
496
+ ## Testing Strategy (Recommendations Only - Not Implemented)
480
497
 
481
- ### Changes Are Additive Only
498
+ ### Unit Test Cases
482
499
 
483
- **No Breaking Changes**:
500
+ 1. **Basic Functionality**
501
+ - Single video, 1-minute grouping
502
+ - Multiple videos, 15-minute grouping
503
+ - TMC vs ATR aggregation
484
504
 
485
- - Existing custom classes remain unchanged
486
- - Existing API response structure preserved
487
- - "Total" is a NEW key (optional to consume)
505
+ 2. **Time Normalization**
506
+ - Videos with different recordingStartedAt align correctly
507
+ - Verify bucket boundaries (e.g., 2:00 PM, 2:15 PM, 2:30 PM)
508
+ - Handle midnight boundary crossing
488
509
 
489
- **Frontend Compatibility**:
510
+ 3. **Edge Cases**
511
+ - Study not found (expect error)
512
+ - No COMPLETED videos (expect empty array)
513
+ - Mix of videos with/without recordingStartedAt
514
+ - Single minute spanning multiple buckets (shouldn't happen)
490
515
 
491
- - Old frontend: Ignores "Total" key (no errors)
492
- - New frontend: Displays "Total" as last tab
516
+ 4. **Aggregation Accuracy**
517
+ - Verify TMC turning movements sum correctly
518
+ - Verify ATR lane counts sum correctly
519
+ - Check contributingVideos array accuracy
493
520
 
494
- **Database Compatibility**:
521
+ ### Integration Test
495
522
 
496
- - No schema changes required
497
- - Raw results in database unchanged
498
- - Transformation happens at runtime
523
+ ```typescript
524
+ // Example test structure (not implemented)
525
+ describe("getGroupedMinuteResultsByStudyUuid", () => {
526
+ it("should aggregate TMC results across multiple videos", async () => {
527
+ // Setup: Create study with 3 videos at different times
528
+ // Insert minute results with known values
529
+ // Call method with 15-minute grouping
530
+ // Assert: Verify bucket times, aggregated counts, video attribution
531
+ });
532
+ });
533
+ ```
499
534
 
500
535
  ---
501
536
 
502
- ## Implementation Checklist
503
-
504
- ### Pre-Implementation
537
+ ## Migration Requirements
505
538
 
506
- - [x] Analyze current transformation logic
507
- - [x] Identify exact insertion point (line 312)
508
- - [x] Verify `_deepMergeNumericData()` handles both ATR/TMC
509
- - [x] Confirm JavaScript key ordering guarantees
539
+ **No database migrations needed** - all existing schema supports this feature:
510
540
 
511
- ### Implementation Phase
541
+ - `video.recordingStartedAt` exists (migration 20251020225758)
542
+ - `video.status` exists
543
+ - Indexes on folder/recording relationships exist
544
+ - Study → Folder → Video relationships exist
512
545
 
513
- - [ ] Add `_createTotalClass()` helper method after line 350
514
- - [ ] Add "Total" class insertion after line 312 in `applyConfigurationToNestedStructure()`
515
- - [ ] Add JSDoc comments for new method
516
- - [ ] Verify TypeScript compilation: `npm run build`
546
+ ---
517
547
 
518
- ### Testing Phase
548
+ ## Implementation Order
519
549
 
520
- - [ ] Write unit tests for `_createTotalClass()`
521
- - [ ] Test ATR format (lane-based totals)
522
- - [ ] Test TMC format (direction/turn totals)
523
- - [ ] Test edge cases (empty, single class, missing keys)
524
- - [ ] Test key ordering (Total is last)
525
- - [ ] Run all tests: `npm test`
550
+ 1. **Add Interfaces** (lines 10-69 in VideoMinuteResultDAO.ts)
551
+ - `IStudyTimeGroupResult`
552
+ - `IGroupedStudyResponse`
526
553
 
527
- ### Integration Phase
554
+ 2. **Add Helper Method** (before existing private methods)
555
+ - `formatAbsoluteTimeLabel(startTime: Date, endTime: Date): string`
528
556
 
529
- - [ ] Build knex-rel: `npm run build`
530
- - [ ] Test in api-rel: `npm run start:dev`
531
- - [ ] Call `/videos/:uuid/results` endpoint with ATR video
532
- - [ ] Call `/videos/:uuid/results` endpoint with TMC video
533
- - [ ] Verify "Total" appears in response as last class
534
- - [ ] Verify Total values are correct sums
557
+ 3. **Add Main Method** (after `getGroupedMinuteResultsByVideoUuid`, around line 479)
558
+ - `getGroupedMinuteResultsByStudyUuid(studyUuid, groupingMinutes)`
559
+ - Follow 5-step logic outlined above
535
560
 
536
- ### Frontend Integration (Separate Task)
561
+ 4. **Export Interfaces** (at end of file)
562
+ - Export new interfaces for use in API layer
537
563
 
538
- - [ ] Update traffic-webapp to display "Total" tab
539
- - [ ] Position "Total" tab as last tab
540
- - [ ] Render Total data in charts/tables
541
- - [ ] Test with both ATR and TMC videos
564
+ 5. **Update Package**
565
+ - `npm run build` to verify TypeScript compilation
566
+ - No DAO export changes needed (class already exported)
542
567
 
543
568
  ---
544
569
 
545
- ## Code Changes Summary
546
-
547
- ### File 1: `knex-rel/src/dao/report-configuration/report-configuration.dao.ts`
570
+ ## API Integration Notes (For Future Work)
548
571
 
549
- **Location 1: After Line 312 (Inside `applyConfigurationToNestedStructure()`)**
572
+ ### Suggested API Endpoint
550
573
 
551
- ```typescript
552
- // Lines 299-312: Existing iteration through detection labels
553
- for (const [detectionLabel, nestedData] of Object.entries(vehiclesStructure)) {
554
- const customClassName = detectionToCustomClass[detectionLabel];
555
- if (!customClassName) {
556
- continue;
557
- }
558
- result[customClassName] = this._deepMergeNumericData(
559
- result[customClassName],
560
- nestedData,
561
- );
562
- }
563
-
564
- // NEW CODE: Add "Total" class that aggregates all custom classes
565
- result["Total"] = this._createTotalClass(result);
566
-
567
- return result;
574
+ ```
575
+ GET /api/studies/:studyUuid/minute-results?grouping=15
568
576
  ```
569
577
 
570
- **Location 2: After Line 350 (New Private Method)**
571
-
572
- ```typescript
573
- /**
574
- * Create "Total" class by aggregating all custom vehicle classes
575
- *
576
- * Sums all custom class counts across their nested structures.
577
- * Works for both ATR (lane-based) and TMC (direction/turn-based) formats.
578
- *
579
- * @param customClassesData - Transformed custom classes structure
580
- * Example ATR: { "Light": { "0": 45, "1": 50 }, "Heavy": { "0": 10 } }
581
- * Example TMC: { "Light": { "NORTH": { "straight": 10 } }, ... }
582
- * @returns Aggregated totals matching the same nested structure
583
- * Example ATR: { "0": 55, "1": 50 }
584
- * Example TMC: { "NORTH": { "straight": 10 }, ... }
585
- */
586
- private _createTotalClass(customClassesData: Record<string, any>): any {
587
- let total: any = {};
578
+ ### Response Format
588
579
 
589
- // Iterate through all custom classes and merge their data
590
- for (const [className, nestedData] of Object.entries(customClassesData)) {
591
- total = this._deepMergeNumericData(total, nestedData);
580
+ ```json
581
+ {
582
+ "success": true,
583
+ "data": [
584
+ {
585
+ "absoluteTime": "2024-10-26T14:30:00.000Z",
586
+ "groupIndex": 0,
587
+ "label": "Oct 26, 2024: 2:30 PM - 2:44 PM",
588
+ "results": {
589
+ /* aggregated TMC/ATR data */
590
+ },
591
+ "minuteCount": 45,
592
+ "videoCount": 3,
593
+ "contributingVideos": ["uuid1", "uuid2", "uuid3"]
592
594
  }
593
-
594
- return total;
595
+ ],
596
+ "groupingMinutes": 15,
597
+ "study": {
598
+ "uuid": "study-uuid",
599
+ "name": "Downtown Intersection Study",
600
+ "type": "TMC",
601
+ "status": "COMPLETE"
602
+ },
603
+ "videoCount": 3,
604
+ "dateRange": {
605
+ "earliest": "2024-10-26T14:00:00.000Z",
606
+ "latest": "2024-10-26T16:30:00.000Z"
607
+ }
595
608
  }
596
609
  ```
597
610
 
598
611
  ---
599
612
 
600
- ## Validation Plan
601
-
602
- ### Manual Testing Checklist
603
-
604
- **Test 1: ATR Video (2 Custom Classes)**
605
-
606
- - [ ] Create configuration: "Light" (FHWA 1-3), "Heavy" (FHWA 4-13)
607
- - [ ] Upload ATR video with mixed vehicle types
608
- - [ ] Process video and get results
609
- - [ ] Verify "Light" shows cars/motorcycles per lane
610
- - [ ] Verify "Heavy" shows trucks/buses per lane
611
- - [ ] Verify "Total" shows sum of Light + Heavy per lane
612
- - [ ] Verify "Total" is last key in vehicles object
613
-
614
- **Test 2: TMC Video (3 Custom Classes)**
615
-
616
- - [ ] Create configuration: "Cars", "Trucks", "Buses"
617
- - [ ] Upload TMC video with turning movements
618
- - [ ] Process video and get results
619
- - [ ] Verify each class shows direction/turn breakdown
620
- - [ ] Verify "Total" shows sum across all classes
621
- - [ ] Verify NORTH/straight in Total = sum of all classes' NORTH/straight
622
- - [ ] Verify "Total" is last key in vehicles object
623
-
624
- **Test 3: Edge Cases**
625
-
626
- - [ ] Video with no vehicles detected (empty Total)
627
- - [ ] Video with single custom class (Total equals that class)
628
- - [ ] Video with asymmetric turns (Light has left, Heavy doesn't)
629
- - [ ] Configuration with 7 custom classes (max allowed)
613
+ ## Risks & Mitigation
630
614
 
631
- **Test 4: Grouped Results (Multiple Minutes)**
632
-
633
- - [ ] Get grouped results with grouping=15
634
- - [ ] Verify each timeGroup has "Total" class
635
- - [ ] Verify "Total" sums correctly across aggregated minutes
636
- - [ ] Verify ordering preserved in all timeGroups
615
+ | Risk | Impact | Mitigation |
616
+ | ----------------------------------- | --------------------------- | ---------------------------------------------------- |
617
+ | Videos without recordingStartedAt | Cannot normalize time | Exclude from aggregation, log warning |
618
+ | Large studies (100+ videos) | Memory/performance issues | Document limit, add pagination in future |
619
+ | Type inconsistency (video vs study) | Wrong aggregation logic | Use study.type as source of truth |
620
+ | Missing duration_seconds | Inaccurate dateRange.latest | Calculate from last minute_number |
621
+ | Timezone confusion | Incorrect time labels | Use UTC internally, format in user timezone (future) |
622
+ | Concurrent video processing | Incomplete aggregation | Document that method shows COMPLETED videos only |
637
623
 
638
624
  ---
639
625
 
640
- ## Risks & Mitigations
641
-
642
- ### Risk 1: Key Ordering Not Guaranteed
643
-
644
- **Risk**: JavaScript object keys might not preserve insertion order
645
-
646
- **Likelihood**: Very Low (ES2015+ guarantees string key order)
647
-
648
- **Mitigation**:
649
-
650
- - Add unit test to verify `Object.keys(result)` ends with "Total"
651
- - If ordering fails, use explicit ordering array in frontend
652
-
653
- ### Risk 2: Performance Degradation
654
-
655
- **Risk**: Additional aggregation adds processing time
626
+ ## Pattern Compliance Checklist
656
627
 
657
- **Likelihood**: Very Low (O(c × m) is negligible)
658
-
659
- **Mitigation**:
660
-
661
- - Benchmark transformation time before/after
662
- - If impact > 10ms, consider caching Total class
663
-
664
- ### Risk 3: Frontend Breaks on Unknown Class
665
-
666
- **Risk**: Old frontend version throws error on "Total" key
667
-
668
- **Likelihood**: Very Low (key is additive, not breaking)
669
-
670
- **Mitigation**:
671
-
672
- - "Total" is just another custom class name
673
- - Frontend already iterates dynamic class names
674
- - No special handling needed
675
-
676
- ### Risk 4: Incorrect Aggregation Logic
677
-
678
- **Risk**: `_deepMergeNumericData()` doesn't handle all cases
679
-
680
- **Likelihood**: Low (method already tested for custom class merging)
681
-
682
- **Mitigation**:
683
-
684
- - Comprehensive unit tests for edge cases
685
- - Manual validation with real ATR/TMC videos
686
- - Use existing, battle-tested `_deepMergeNumericData()`
687
-
688
- ---
689
-
690
- ## Alternative Approaches Considered
691
-
692
- ### Alternative 1: Add "Total" in Frontend
693
-
694
- **Approach**: Calculate "Total" in traffic-webapp instead of backend
695
-
696
- **Pros**:
697
-
698
- - No backend changes required
699
- - Frontend controls display logic
700
-
701
- **Cons**:
702
-
703
- - Duplicates logic across frontend
704
- - Inconsistent if API consumed elsewhere
705
- - Cannot use "Total" in backend reports/exports
706
-
707
- **Decision**: ❌ Rejected - Backend is single source of truth
708
-
709
- ### Alternative 2: Add "Total" as Separate Field
710
-
711
- **Approach**: Add `results.totalClass` instead of `results.vehicles["Total"]`
712
-
713
- **Pros**:
714
-
715
- - Clearer separation of concerns
716
- - No risk of key ordering issues
717
-
718
- **Cons**:
719
-
720
- - Inconsistent with custom class structure
721
- - Frontend needs special handling
722
- - Breaks uniform class iteration logic
723
-
724
- **Decision**: ❌ Rejected - Keep "Total" as vehicle class for uniformity
725
-
726
- ### Alternative 3: Make "Total" Configurable
727
-
728
- **Approach**: Add `includeTotalClass: boolean` to report configuration
729
-
730
- **Pros**:
731
-
732
- - User control over Total display
733
- - Backward compatible (default false)
734
-
735
- **Cons**:
736
-
737
- - Adds complexity to configuration
738
- - Not needed (Total is always useful)
739
- - Extra validation logic
740
-
741
- **Decision**: ❌ Rejected - Always include "Total" (KISS principle)
628
+ - ✅ **DAO Pattern**: Method follows existing DAO patterns (async, returns typed response)
629
+ - ✅ **Interface Naming**: `IGroupedStudyResponse`, `IStudyTimeGroupResult` (PascalCase with I prefix)
630
+ - ✅ **UUID Usage**: Uses study.uuid for external API, study.id internally
631
+ - ✅ **Performance**: Single query for minute results (eliminates N+1)
632
+ - **Error Handling**: Throws errors for missing study, returns empty for no data
633
+ - **Type Safety**: All interfaces properly typed, no `any` in return types
634
+ - ✅ **Code Reuse**: Leverages existing aggregation methods
635
+ - **Naming**: `getGroupedMinuteResultsByStudyUuid` follows existing conventions
742
636
 
743
637
  ---
744
638
 
745
- ## Success Criteria
746
-
747
- ### Implementation Success
748
-
749
- ✅ TypeScript compiles without errors
750
- ✅ `npm run build` succeeds in knex-rel
751
- ✅ All existing unit tests pass
752
- ✅ New unit tests pass (Total class logic)
639
+ ## Files Modified
753
640
 
754
- ### Functional Success
641
+ ### knex-rel/src/dao/VideoMinuteResultDAO.ts
755
642
 
756
- ✅ ATR videos show "Total" with correct lane sums
757
- ✅ TMC videos show "Total" with correct direction/turn sums
758
- ✅ "Total" appears as LAST class in all responses
759
- ✅ Edge cases handled (empty, single class, missing keys)
643
+ **Changes**:
760
644
 
761
- ### Integration Success
645
+ - Add `IStudyTimeGroupResult` interface (after line 48)
646
+ - Add `IGroupedStudyResponse` interface (after `IStudyTimeGroupResult`)
647
+ - Add `formatAbsoluteTimeLabel()` private method (before line 703)
648
+ - Add `getGroupedMinuteResultsByStudyUuid()` public method (after line 479)
649
+ - Export new interfaces at end of file
762
650
 
763
- API endpoint returns "Total" in response
764
- Grouped results include "Total" in each timeGroup
765
- ✅ No breaking changes to existing API consumers
766
- ✅ Frontend displays "Total" tab correctly (separate task)
651
+ **Lines Added**: ~200 lines
652
+ **Pattern Compliance**: 100%
767
653
 
768
654
  ---
769
655
 
770
- ## Timeline Estimate
656
+ ## Validation Checklist
771
657
 
772
- ### Backend Implementation (knex-rel)
658
+ Before implementation:
773
659
 
774
- - Code changes: 30 minutes
775
- - Unit tests: 1 hour
776
- - Build & test: 15 minutes
777
- - **Total: ~2 hours**
660
+ - [ ] Confirm study.type values match video.videoType enum ('TMC' | 'ATR')
661
+ - [ ] Verify recordingStartedAt is stored in UTC (migration confirms TIMESTAMPTZ)
662
+ - [ ] Check if duration_seconds is reliably populated
663
+ - [ ] Review existing aggregation methods for edge cases
778
664
 
779
- ### Integration Testing (api-rel)
665
+ After implementation:
780
666
 
781
- - Manual testing with ATR video: 30 minutes
782
- - Manual testing with TMC video: 30 minutes
783
- - Edge case validation: 30 minutes
784
- - **Total: ~1.5 hours**
785
-
786
- ### Frontend Integration (traffic-webapp - Separate)
787
-
788
- - Display "Total" tab: 1 hour
789
- - Chart/table rendering: 1 hour
790
- - Testing both video types: 1 hour
791
- - **Total: ~3 hours**
792
-
793
- **Overall Estimate: ~6.5 hours** (Backend: 3.5 hours, Frontend: 3 hours)
667
+ - [ ] Test with TMC study (multiple videos, 15-minute grouping)
668
+ - [ ] Test with ATR study (multiple videos, 60-minute grouping)
669
+ - [ ] Test with study containing no COMPLETED videos
670
+ - [ ] Test with study where some videos lack recordingStartedAt
671
+ - [ ] Verify time bucket boundaries align to grouping interval
672
+ - [ ] Check memory usage with 50-video study
673
+ - [ ] Run `npm run build` - confirm no TypeScript errors
674
+ - [ ] Run `npm test` - confirm existing tests still pass
794
675
 
795
676
  ---
796
677
 
797
678
  ## Summary
798
679
 
799
- ### What Changes
800
-
801
- ✅ **ADD**: `_createTotalClass()` private method in ReportConfigurationDAO
802
- ✅ **ADD**: `result["Total"] = ...` line after custom class transformation
803
- ✅ **ADD**: Unit tests for Total class aggregation
804
-
805
- ### What Stays the Same
806
-
807
- ✅ Database schema (no migrations)
808
- ✅ Existing custom classes (no modifications)
809
- ✅ API response structure (additive only)
810
- ✅ `_deepMergeNumericData()` helper (reused, not changed)
811
-
812
- ### Key Benefits
813
-
814
- ✅ Unified "Total" view across all custom classes
815
- ✅ Consistent with existing transformation architecture
816
- ✅ No breaking changes (backward compatible)
817
- ✅ Minimal performance impact (< 1ms per minute result)
818
- ✅ Reuses existing, tested aggregation logic
819
-
820
- **Ready for Implementation** - Plan is complete with exact code locations, logic, edge cases, and validation strategy.
821
-
822
- ---
823
-
824
- ## Files to Modify
825
-
826
- ### knex-rel/src/dao/report-configuration/report-configuration.dao.ts
827
-
828
- - **Line 312**: Add `result["Total"] = this._createTotalClass(result);`
829
- - **After Line 350**: Add `_createTotalClass()` private method
830
-
831
- ### knex-rel/src/dao/report-configuration/report-configuration.dao.test.ts (New File)
832
-
833
- - Create unit tests for Total class aggregation
834
- - Test ATR format, TMC format, edge cases, key ordering
835
-
836
- ---
837
-
838
- ## Pattern Compliance Summary
839
-
840
- ### Database Patterns ✅
841
-
842
- - No database changes required (transformation only)
843
- - Follows DAO pattern (logic in ReportConfigurationDAO)
844
- - No new migrations needed
845
-
846
- ### Code Patterns ✅
847
-
848
- - Reuses existing `_deepMergeNumericData()` helper
849
- - Follows private method naming convention (`_methodName`)
850
- - Maintains nested structure consistency
851
- - Preserves key ordering via insertion order
852
-
853
- ### Performance Patterns ✅
854
-
855
- - O(c × m) additional complexity (negligible)
856
- - No N+1 queries (transformation in memory)
857
- - No database roundtrips
858
- - Minimal memory overhead (~500 bytes per result)
859
-
860
- ---
861
-
862
- ## Next Steps
680
+ This plan adds study-level aggregation with **zero database changes**, reusing 100% of existing schema and aggregation logic. Key innovation is SQL-based time normalization using `recordingStartedAt` to convert video-relative minute offsets to absolute wall-clock times, then grouping in TypeScript for flexibility.
863
681
 
864
- 1. **Implement** `_createTotalClass()` method in ReportConfigurationDAO
865
- 2. **Add** Total class insertion after line 312
866
- 3. **Write** unit tests for all edge cases
867
- 4. **Build** knex-rel package and verify compilation
868
- 5. **Test** with real ATR and TMC videos in api-rel
869
- 6. **Validate** key ordering and correct aggregation
870
- 7. **Document** changes (if needed)
871
- 8. **Coordinate** with frontend team for Total tab display
682
+ **Implementation Effort**: ~200 lines of TypeScript, ~3-4 hours
683
+ **Risk Level**: Low (no schema changes, proven aggregation logic)
684
+ **Performance**: Good (single query, in-memory aggregation, indexed lookups)