@trafficgroup/knex-rel 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +5 -2
- package/.env.prod +5 -0
- package/CLAUDE.md +2 -11
- package/dist/constants/video.constants.d.ts +12 -0
- package/dist/constants/video.constants.js +18 -0
- package/dist/constants/video.constants.js.map +1 -0
- package/dist/dao/VideoMinuteResultDAO.d.ts +4 -1
- package/dist/dao/VideoMinuteResultDAO.js +28 -31
- package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
- package/dist/dao/auth/auth.dao.js +1 -4
- package/dist/dao/auth/auth.dao.js.map +1 -1
- package/dist/dao/batch/batch.dao.js +14 -13
- package/dist/dao/batch/batch.dao.js.map +1 -1
- package/dist/dao/camera/camera.dao.d.ts +17 -7
- package/dist/dao/camera/camera.dao.js +38 -56
- package/dist/dao/camera/camera.dao.js.map +1 -1
- package/dist/dao/chat/chat.dao.d.ts +1 -1
- package/dist/dao/chat/chat.dao.js +35 -25
- package/dist/dao/chat/chat.dao.js.map +1 -1
- package/dist/dao/folder/folder.dao.js +4 -8
- package/dist/dao/folder/folder.dao.js.map +1 -1
- package/dist/dao/location/location.dao.d.ts +17 -0
- package/dist/dao/location/location.dao.js +116 -0
- package/dist/dao/location/location.dao.js.map +1 -0
- package/dist/dao/message/message.dao.d.ts +1 -1
- package/dist/dao/message/message.dao.js +26 -18
- package/dist/dao/message/message.dao.js.map +1 -1
- package/dist/dao/report-configuration/report-configuration.dao.js +31 -32
- package/dist/dao/report-configuration/report-configuration.dao.js.map +1 -1
- package/dist/dao/study/study.dao.d.ts +1 -1
- package/dist/dao/study/study.dao.js +12 -17
- package/dist/dao/study/study.dao.js.map +1 -1
- package/dist/dao/user/user.dao.js +1 -4
- package/dist/dao/user/user.dao.js.map +1 -1
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js +8 -26
- package/dist/dao/user-push-notification-token/user-push-notification-token.dao.js.map +1 -1
- package/dist/dao/video/video.dao.d.ts +9 -1
- package/dist/dao/video/video.dao.js +73 -27
- package/dist/dao/video/video.dao.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/batch/batch.interfaces.d.ts +1 -1
- package/dist/interfaces/camera/camera.interfaces.d.ts +4 -2
- package/dist/interfaces/chat/chat.interfaces.d.ts +3 -3
- package/dist/interfaces/folder/folder.interfaces.d.ts +1 -1
- package/dist/interfaces/location/location.interfaces.d.ts +9 -0
- package/dist/interfaces/location/location.interfaces.js +3 -0
- package/dist/interfaces/location/location.interfaces.js.map +1 -0
- package/dist/interfaces/message/message.interfaces.d.ts +2 -2
- package/dist/interfaces/study/study.interfaces.d.ts +6 -5
- package/dist/interfaces/user/user.interfaces.d.ts +1 -1
- package/dist/interfaces/user-push-notification-token/user-push-notification-token.interfaces.d.ts +1 -1
- package/dist/interfaces/video/video.interfaces.d.ts +3 -2
- package/migrations/20250717160737_migration.ts +1 -1
- package/migrations/20250717160908_migration.ts +2 -5
- package/migrations/20250717161310_migration.ts +1 -1
- package/migrations/20250717161406_migration.ts +3 -3
- package/migrations/20250717162431_migration.ts +1 -1
- package/migrations/20250717173228_migration.ts +2 -2
- package/migrations/20250717204731_migration.ts +1 -1
- package/migrations/20250722210109_migration.ts +4 -8
- package/migrations/20250722211019_migration.ts +1 -1
- package/migrations/20250723153852_migration.ts +10 -13
- package/migrations/20250723162257_migration.ts +7 -4
- package/migrations/20250723171109_migration.ts +7 -4
- package/migrations/20250723205331_migration.ts +9 -6
- package/migrations/20250724191345_migration.ts +11 -8
- package/migrations/20250730180932_migration.ts +13 -14
- package/migrations/20250730213625_migration.ts +11 -8
- package/migrations/20250804124509_migration.ts +21 -26
- package/migrations/20250804132053_migration.ts +8 -5
- package/migrations/20250804164518_migration.ts +7 -7
- package/migrations/20250823223016_migration.ts +21 -32
- package/migrations/20250910015452_migration.ts +6 -18
- package/migrations/20250911000000_migration.ts +4 -18
- package/migrations/20250917144153_migration.ts +7 -14
- package/migrations/20250930200521_migration.ts +4 -8
- package/migrations/20251010143500_migration.ts +6 -27
- package/migrations/20251020225758_migration.ts +15 -51
- package/migrations/20251112120000_migration.ts +81 -0
- package/migrations/20251112120100_migration.ts +21 -0
- package/migrations/20251112120200_migration.ts +38 -0
- package/migrations/20251112120300_migration.ts +22 -0
- package/package.json +1 -1
- package/src/constants/video.constants.ts +19 -0
- package/src/d.types.ts +14 -18
- package/src/dao/VideoMinuteResultDAO.ts +54 -72
- package/src/dao/auth/auth.dao.ts +55 -58
- package/src/dao/batch/batch.dao.ts +98 -101
- package/src/dao/camera/camera.dao.ts +125 -145
- package/src/dao/chat/chat.dao.ts +43 -45
- package/src/dao/folder/folder.dao.ts +56 -59
- package/src/dao/location/location.dao.ts +101 -0
- package/src/dao/message/message.dao.ts +32 -32
- package/src/dao/report-configuration/report-configuration.dao.ts +342 -370
- package/src/dao/study/study.dao.ts +63 -88
- package/src/dao/user/user.dao.ts +50 -52
- package/src/dao/user-push-notification-token/user-push-notification-token.dao.ts +49 -83
- package/src/dao/video/video.dao.ts +339 -313
- package/src/entities/BaseEntity.ts +1 -1
- package/src/index.ts +24 -26
- package/src/interfaces/auth/auth.interfaces.ts +10 -10
- package/src/interfaces/batch/batch.interfaces.ts +1 -1
- package/src/interfaces/camera/camera.interfaces.ts +9 -7
- package/src/interfaces/chat/chat.interfaces.ts +4 -4
- package/src/interfaces/folder/folder.interfaces.ts +2 -2
- package/src/interfaces/location/location.interfaces.ts +9 -0
- package/src/interfaces/message/message.interfaces.ts +3 -3
- package/src/interfaces/report-configuration/report-configuration.interfaces.ts +16 -16
- package/src/interfaces/study/study.interfaces.ts +7 -6
- package/src/interfaces/user/user.interfaces.ts +9 -9
- package/src/interfaces/user-push-notification-token/user-push-notification-token.interfaces.ts +9 -9
- package/src/interfaces/video/video.interfaces.ts +34 -33
- package/plan.md +0 -684
package/plan.md
DELETED
|
@@ -1,684 +0,0 @@
|
|
|
1
|
-
# Plan: Add Study-Level Minute Result Aggregation
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
|
|
5
|
-
Add `getGroupedMinuteResultsByStudyUuid()` method to `VideoMinuteResultDAO` to aggregate minute results across all COMPLETED videos in a study, with time normalization using `recordingStartedAt`.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Schema Analysis (Existing)
|
|
10
|
-
|
|
11
|
-
### Table Relationships
|
|
12
|
-
|
|
13
|
-
```
|
|
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)
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
### Key Fields
|
|
24
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
### Existing Indexes
|
|
31
|
-
|
|
32
|
-
- `idx_videos_folder_recording` on (folderId, recordingStartedAt) WHERE recordingStartedAt IS NOT NULL
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## Interface Definitions
|
|
37
|
-
|
|
38
|
-
### New Response Interface
|
|
39
|
-
|
|
40
|
-
**File**: `knex-rel/src/dao/VideoMinuteResultDAO.ts` (add at top with existing interfaces)
|
|
41
|
-
|
|
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
|
-
}
|
|
52
|
-
|
|
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
|
-
```
|
|
70
|
-
|
|
71
|
-
---
|
|
72
|
-
|
|
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
|
-
```
|
|
99
|
-
|
|
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;
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
**Parameters**:
|
|
131
|
-
|
|
132
|
-
- `?` (first): groupingMinutes (e.g., 15)
|
|
133
|
-
- `?` (second): Array of video IDs from Phase 1
|
|
134
|
-
|
|
135
|
-
**Output**: Rows with absolute timestamps and time bucket assignments
|
|
136
|
-
|
|
137
|
-
### Phase 3: Aggregate by Time Bucket (TypeScript)
|
|
138
|
-
|
|
139
|
-
**Reason for TypeScript aggregation**:
|
|
140
|
-
|
|
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)
|
|
144
|
-
|
|
145
|
-
**Pseudo-SQL for Grouping** (illustrative, actual grouping in TS):
|
|
146
|
-
|
|
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;
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## TypeScript Implementation Logic
|
|
164
|
-
|
|
165
|
-
### Method Signature
|
|
166
|
-
|
|
167
|
-
```typescript
|
|
168
|
-
async getGroupedMinuteResultsByStudyUuid(
|
|
169
|
-
studyUuid: string,
|
|
170
|
-
groupingMinutes: number = 15
|
|
171
|
-
): Promise<IGroupedStudyResponse>
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
### Step-by-Step Logic
|
|
175
|
-
|
|
176
|
-
#### Step 1: Fetch Study and Videos
|
|
177
|
-
|
|
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`);
|
|
183
|
-
}
|
|
184
|
-
|
|
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
|
-
};
|
|
218
|
-
}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
#### Step 2: Fetch and Transform Minute Results
|
|
222
|
-
|
|
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
|
-
```
|
|
253
|
-
|
|
254
|
-
#### Step 3: Group by Time Buckets
|
|
255
|
-
|
|
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
|
-
}
|
|
279
|
-
|
|
280
|
-
const bucketData = bucketMap.get(bucket)!;
|
|
281
|
-
bucketData.results.push(row.results);
|
|
282
|
-
bucketData.videoUuids.add(row.video_uuid);
|
|
283
|
-
bucketData.minuteCount++;
|
|
284
|
-
}
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
#### Step 4: Aggregate Each Bucket
|
|
288
|
-
|
|
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
|
-
}
|
|
304
|
-
|
|
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
|
-
};
|
|
320
|
-
},
|
|
321
|
-
);
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
#### Step 5: Calculate Date Range
|
|
325
|
-
|
|
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
|
-
};
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
---
|
|
357
|
-
|
|
358
|
-
## Code Reuse Strategy
|
|
359
|
-
|
|
360
|
-
### Existing Methods to Reuse
|
|
361
|
-
|
|
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
|
|
366
|
-
|
|
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
|
|
371
|
-
|
|
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
|
|
375
|
-
|
|
376
|
-
### New Helper Method Required
|
|
377
|
-
|
|
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)}`;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// If different days, show full timestamps
|
|
405
|
-
return `${formatDate(startTime)} ${formatTime(startTime)} - ${formatDate(endTime)} ${formatTime(endTime)}`;
|
|
406
|
-
}
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
---
|
|
410
|
-
|
|
411
|
-
## Error Handling Strategy
|
|
412
|
-
|
|
413
|
-
### Input Validation
|
|
414
|
-
|
|
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
|
-
);
|
|
422
|
-
}
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
### Edge Cases
|
|
426
|
-
|
|
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 |
|
|
437
|
-
|
|
438
|
-
### Logging Strategy
|
|
439
|
-
|
|
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
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// After aggregation
|
|
463
|
-
console.log(
|
|
464
|
-
`[VideoMinuteResultDAO] Generated ${aggregatedGroups.length} time buckets`,
|
|
465
|
-
);
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
---
|
|
469
|
-
|
|
470
|
-
## Performance Considerations
|
|
471
|
-
|
|
472
|
-
### Query Optimization
|
|
473
|
-
|
|
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
|
|
478
|
-
|
|
479
|
-
### Memory Management
|
|
480
|
-
|
|
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
|
|
486
|
-
|
|
487
|
-
### Database Load
|
|
488
|
-
|
|
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)
|
|
493
|
-
|
|
494
|
-
---
|
|
495
|
-
|
|
496
|
-
## Testing Strategy (Recommendations Only - Not Implemented)
|
|
497
|
-
|
|
498
|
-
### Unit Test Cases
|
|
499
|
-
|
|
500
|
-
1. **Basic Functionality**
|
|
501
|
-
- Single video, 1-minute grouping
|
|
502
|
-
- Multiple videos, 15-minute grouping
|
|
503
|
-
- TMC vs ATR aggregation
|
|
504
|
-
|
|
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
|
|
509
|
-
|
|
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)
|
|
515
|
-
|
|
516
|
-
4. **Aggregation Accuracy**
|
|
517
|
-
- Verify TMC turning movements sum correctly
|
|
518
|
-
- Verify ATR lane counts sum correctly
|
|
519
|
-
- Check contributingVideos array accuracy
|
|
520
|
-
|
|
521
|
-
### Integration Test
|
|
522
|
-
|
|
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
|
-
```
|
|
534
|
-
|
|
535
|
-
---
|
|
536
|
-
|
|
537
|
-
## Migration Requirements
|
|
538
|
-
|
|
539
|
-
**No database migrations needed** - all existing schema supports this feature:
|
|
540
|
-
|
|
541
|
-
- `video.recordingStartedAt` exists (migration 20251020225758)
|
|
542
|
-
- `video.status` exists
|
|
543
|
-
- Indexes on folder/recording relationships exist
|
|
544
|
-
- Study → Folder → Video relationships exist
|
|
545
|
-
|
|
546
|
-
---
|
|
547
|
-
|
|
548
|
-
## Implementation Order
|
|
549
|
-
|
|
550
|
-
1. **Add Interfaces** (lines 10-69 in VideoMinuteResultDAO.ts)
|
|
551
|
-
- `IStudyTimeGroupResult`
|
|
552
|
-
- `IGroupedStudyResponse`
|
|
553
|
-
|
|
554
|
-
2. **Add Helper Method** (before existing private methods)
|
|
555
|
-
- `formatAbsoluteTimeLabel(startTime: Date, endTime: Date): string`
|
|
556
|
-
|
|
557
|
-
3. **Add Main Method** (after `getGroupedMinuteResultsByVideoUuid`, around line 479)
|
|
558
|
-
- `getGroupedMinuteResultsByStudyUuid(studyUuid, groupingMinutes)`
|
|
559
|
-
- Follow 5-step logic outlined above
|
|
560
|
-
|
|
561
|
-
4. **Export Interfaces** (at end of file)
|
|
562
|
-
- Export new interfaces for use in API layer
|
|
563
|
-
|
|
564
|
-
5. **Update Package**
|
|
565
|
-
- `npm run build` to verify TypeScript compilation
|
|
566
|
-
- No DAO export changes needed (class already exported)
|
|
567
|
-
|
|
568
|
-
---
|
|
569
|
-
|
|
570
|
-
## API Integration Notes (For Future Work)
|
|
571
|
-
|
|
572
|
-
### Suggested API Endpoint
|
|
573
|
-
|
|
574
|
-
```
|
|
575
|
-
GET /api/studies/:studyUuid/minute-results?grouping=15
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
### Response Format
|
|
579
|
-
|
|
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"]
|
|
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
|
-
}
|
|
608
|
-
}
|
|
609
|
-
```
|
|
610
|
-
|
|
611
|
-
---
|
|
612
|
-
|
|
613
|
-
## Risks & Mitigation
|
|
614
|
-
|
|
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 |
|
|
623
|
-
|
|
624
|
-
---
|
|
625
|
-
|
|
626
|
-
## Pattern Compliance Checklist
|
|
627
|
-
|
|
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
|
|
636
|
-
|
|
637
|
-
---
|
|
638
|
-
|
|
639
|
-
## Files Modified
|
|
640
|
-
|
|
641
|
-
### knex-rel/src/dao/VideoMinuteResultDAO.ts
|
|
642
|
-
|
|
643
|
-
**Changes**:
|
|
644
|
-
|
|
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
|
|
650
|
-
|
|
651
|
-
**Lines Added**: ~200 lines
|
|
652
|
-
**Pattern Compliance**: 100%
|
|
653
|
-
|
|
654
|
-
---
|
|
655
|
-
|
|
656
|
-
## Validation Checklist
|
|
657
|
-
|
|
658
|
-
Before implementation:
|
|
659
|
-
|
|
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
|
|
664
|
-
|
|
665
|
-
After implementation:
|
|
666
|
-
|
|
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
|
|
675
|
-
|
|
676
|
-
---
|
|
677
|
-
|
|
678
|
-
## Summary
|
|
679
|
-
|
|
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.
|
|
681
|
-
|
|
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)
|