@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.
- package/dist/dao/VideoMinuteResultDAO.d.ts +37 -0
- package/dist/dao/VideoMinuteResultDAO.js +146 -0
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/batch/batch.dao.d.ts +27 -0
- package/dist/dao/batch/batch.dao.js +135 -0
- package/dist/dao/batch/batch.dao.js.map +1 -0
- package/dist/dao/folder/folder.dao.js +3 -6
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/study/study.dao.d.ts +1 -0
- package/dist/dao/study/study.dao.js +18 -3
- package/dist/dao/study/study.dao.js.map +1 -1
- package/dist/dao/video/video.dao.d.ts +19 -5
- package/dist/dao/video/video.dao.js +61 -32
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/batch/batch.interfaces.d.ts +13 -0
- package/dist/interfaces/batch/batch.interfaces.js +3 -0
- package/dist/interfaces/batch/batch.interfaces.js.map +1 -0
- package/dist/interfaces/folder/folder.interfaces.d.ts +0 -3
- package/dist/interfaces/study/study.interfaces.d.ts +5 -0
- package/dist/interfaces/video/video.interfaces.d.ts +8 -3
- package/migrations/20251010143500_migration.ts +83 -0
- package/migrations/20251020225758_migration.ts +135 -0
- package/package.json +1 -1
- package/plan.md +524 -711
- package/src/dao/VideoMinuteResultDAO.ts +232 -0
- package/src/dao/batch/batch.dao.ts +121 -0
- package/src/dao/folder/folder.dao.ts +3 -18
- package/src/dao/study/study.dao.ts +34 -3
- package/src/dao/video/video.dao.ts +70 -43
- package/src/index.ts +11 -1
- package/src/interfaces/batch/batch.interfaces.ts +14 -0
- package/src/interfaces/folder/folder.interfaces.ts +0 -3
- package/src/interfaces/study/study.interfaces.ts +5 -0
- package/src/interfaces/video/video.interfaces.ts +12 -4
package/plan.md
CHANGED
|
@@ -1,871 +1,684 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Plan: Add Study-Level Minute Result Aggregation
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Overview
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
##
|
|
9
|
+
## Schema Analysis (Existing)
|
|
18
10
|
|
|
19
|
-
###
|
|
11
|
+
### Table Relationships
|
|
20
12
|
|
|
21
13
|
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
23
|
+
### Key Fields
|
|
73
24
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
30
|
+
### Existing Indexes
|
|
84
31
|
|
|
85
|
-
|
|
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
|
-
##
|
|
36
|
+
## Interface Definitions
|
|
98
37
|
|
|
99
|
-
###
|
|
38
|
+
### New Response Interface
|
|
100
39
|
|
|
101
|
-
**
|
|
40
|
+
**File**: `knex-rel/src/dao/VideoMinuteResultDAO.ts` (add at top with existing interfaces)
|
|
102
41
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
115
|
-
|
|
116
|
-
###
|
|
117
|
-
|
|
118
|
-
**
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
**Parameters**:
|
|
132
131
|
|
|
133
|
-
|
|
132
|
+
- `?` (first): groupingMinutes (e.g., 15)
|
|
133
|
+
- `?` (second): Array of video IDs from Phase 1
|
|
134
134
|
|
|
135
|
-
**
|
|
135
|
+
**Output**: Rows with absolute timestamps and time bucket assignments
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
private _createTotalClass(customClassesData: Record<string, any>): any
|
|
139
|
-
```
|
|
137
|
+
### Phase 3: Aggregate by Time Bucket (TypeScript)
|
|
140
138
|
|
|
141
|
-
**
|
|
139
|
+
**Reason for TypeScript aggregation**:
|
|
142
140
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
163
|
+
## TypeScript Implementation Logic
|
|
177
164
|
|
|
178
|
-
###
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
174
|
+
### Step-by-Step Logic
|
|
206
175
|
|
|
207
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
"
|
|
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
|
-
|
|
221
|
+
#### Step 2: Fetch and Transform Minute Results
|
|
236
222
|
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
254
|
+
#### Step 3: Group by Time Buckets
|
|
241
255
|
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
287
|
+
#### Step 4: Aggregate Each Bucket
|
|
252
288
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
"NORTH": { "straight": 5, "left": 1, "right": 0 },
|
|
270
|
-
"SOUTH": { "straight": 2, "left": 0, "right": 1 }
|
|
271
|
-
}
|
|
272
|
-
}
|
|
321
|
+
);
|
|
273
322
|
```
|
|
274
323
|
|
|
275
|
-
|
|
324
|
+
#### Step 5: Calculate Date Range
|
|
276
325
|
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
##
|
|
358
|
+
## Code Reuse Strategy
|
|
296
359
|
|
|
297
|
-
###
|
|
360
|
+
### Existing Methods to Reuse
|
|
298
361
|
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
+
### New Helper Method Required
|
|
324
377
|
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
### Edge Case 3: Missing Directions/Turns
|
|
347
|
-
|
|
348
|
-
**Input** (TMC - Light has NORTH, Heavy only has SOUTH):
|
|
409
|
+
---
|
|
349
410
|
|
|
350
|
-
|
|
351
|
-
{
|
|
352
|
-
"Light": { "NORTH": { "straight": 10 } },
|
|
353
|
-
"Heavy": { "SOUTH": { "straight": 5 } }
|
|
354
|
-
}
|
|
355
|
-
```
|
|
411
|
+
## Error Handling Strategy
|
|
356
412
|
|
|
357
|
-
|
|
413
|
+
### Input Validation
|
|
358
414
|
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
425
|
+
### Edge Cases
|
|
369
426
|
|
|
370
|
-
|
|
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
|
-
|
|
438
|
+
### Logging Strategy
|
|
373
439
|
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
###
|
|
472
|
+
### Query Optimization
|
|
450
473
|
|
|
451
|
-
**
|
|
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
|
-
|
|
454
|
-
- m = average nesting depth (2 for ATR, 3 for TMC)
|
|
479
|
+
### Memory Management
|
|
455
480
|
|
|
456
|
-
**
|
|
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
|
-
|
|
487
|
+
### Database Load
|
|
459
488
|
|
|
460
|
-
**
|
|
461
|
-
|
|
462
|
-
-
|
|
463
|
-
-
|
|
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
|
-
##
|
|
496
|
+
## Testing Strategy (Recommendations Only - Not Implemented)
|
|
480
497
|
|
|
481
|
-
###
|
|
498
|
+
### Unit Test Cases
|
|
482
499
|
|
|
483
|
-
|
|
500
|
+
1. **Basic Functionality**
|
|
501
|
+
- Single video, 1-minute grouping
|
|
502
|
+
- Multiple videos, 15-minute grouping
|
|
503
|
+
- TMC vs ATR aggregation
|
|
484
504
|
|
|
485
|
-
|
|
486
|
-
-
|
|
487
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
-
|
|
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
|
-
|
|
521
|
+
### Integration Test
|
|
495
522
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
##
|
|
503
|
-
|
|
504
|
-
### Pre-Implementation
|
|
537
|
+
## Migration Requirements
|
|
505
538
|
|
|
506
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
+
## Implementation Order
|
|
519
549
|
|
|
520
|
-
|
|
521
|
-
-
|
|
522
|
-
-
|
|
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
|
-
|
|
554
|
+
2. **Add Helper Method** (before existing private methods)
|
|
555
|
+
- `formatAbsoluteTimeLabel(startTime: Date, endTime: Date): string`
|
|
528
556
|
|
|
529
|
-
|
|
530
|
-
-
|
|
531
|
-
-
|
|
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
|
-
|
|
561
|
+
4. **Export Interfaces** (at end of file)
|
|
562
|
+
- Export new interfaces for use in API layer
|
|
537
563
|
|
|
538
|
-
|
|
539
|
-
-
|
|
540
|
-
-
|
|
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
|
-
##
|
|
546
|
-
|
|
547
|
-
### File 1: `knex-rel/src/dao/report-configuration/report-configuration.dao.ts`
|
|
570
|
+
## API Integration Notes (For Future Work)
|
|
548
571
|
|
|
549
|
-
|
|
572
|
+
### Suggested API Endpoint
|
|
550
573
|
|
|
551
|
-
```
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
##
|
|
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
|
-
**
|
|
658
|
-
|
|
659
|
-
**
|
|
660
|
-
|
|
661
|
-
-
|
|
662
|
-
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
##
|
|
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
|
-
###
|
|
641
|
+
### knex-rel/src/dao/VideoMinuteResultDAO.ts
|
|
755
642
|
|
|
756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
##
|
|
656
|
+
## Validation Checklist
|
|
771
657
|
|
|
772
|
-
|
|
658
|
+
Before implementation:
|
|
773
659
|
|
|
774
|
-
-
|
|
775
|
-
-
|
|
776
|
-
-
|
|
777
|
-
-
|
|
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
|
-
|
|
665
|
+
After implementation:
|
|
780
666
|
|
|
781
|
-
-
|
|
782
|
-
-
|
|
783
|
-
-
|
|
784
|
-
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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)
|