@trafficgroup/knex-rel 0.1.8 → 0.1.9

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 (43) hide show
  1. package/dist/dao/VideoMinuteResultDAO.d.ts +3 -0
  2. package/dist/dao/VideoMinuteResultDAO.js +5 -2
  3. package/dist/dao/VideoMinuteResultDAO.js.map +1 -1
  4. package/dist/dao/camera/camera.dao.d.ts +17 -7
  5. package/dist/dao/camera/camera.dao.js +33 -48
  6. package/dist/dao/camera/camera.dao.js.map +1 -1
  7. package/dist/dao/folder/folder.dao.js +2 -1
  8. package/dist/dao/folder/folder.dao.js.map +1 -1
  9. package/dist/dao/location/location.dao.d.ts +17 -0
  10. package/dist/dao/location/location.dao.js +123 -0
  11. package/dist/dao/location/location.dao.js.map +1 -0
  12. package/dist/dao/study/study.dao.d.ts +1 -1
  13. package/dist/dao/study/study.dao.js +10 -10
  14. package/dist/dao/study/study.dao.js.map +1 -1
  15. package/dist/dao/video/video.dao.d.ts +7 -0
  16. package/dist/dao/video/video.dao.js +33 -1
  17. package/dist/dao/video/video.dao.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +3 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/interfaces/camera/camera.interfaces.d.ts +4 -2
  22. package/dist/interfaces/location/location.interfaces.d.ts +9 -0
  23. package/dist/interfaces/location/location.interfaces.js +3 -0
  24. package/dist/interfaces/location/location.interfaces.js.map +1 -0
  25. package/dist/interfaces/study/study.interfaces.d.ts +4 -3
  26. package/dist/interfaces/video/video.interfaces.d.ts +1 -0
  27. package/migrations/20251112120000_migration.ts +89 -0
  28. package/migrations/20251112120100_migration.ts +21 -0
  29. package/migrations/20251112120200_migration.ts +50 -0
  30. package/migrations/20251112120300_migration.ts +27 -0
  31. package/package.json +1 -1
  32. package/src/dao/VideoMinuteResultDAO.ts +7 -2
  33. package/src/dao/camera/camera.dao.ts +44 -61
  34. package/src/dao/folder/folder.dao.ts +7 -1
  35. package/src/dao/location/location.dao.ts +123 -0
  36. package/src/dao/study/study.dao.ts +10 -10
  37. package/src/dao/video/video.dao.ts +45 -1
  38. package/src/index.ts +2 -0
  39. package/src/interfaces/camera/camera.interfaces.ts +4 -2
  40. package/src/interfaces/location/location.interfaces.ts +9 -0
  41. package/src/interfaces/study/study.interfaces.ts +4 -3
  42. package/src/interfaces/video/video.interfaces.ts +1 -0
  43. 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)