Add smart alert profiles and recording timeline (Tasks 4-5)

Task 4 — Alert Profiles: presence-aware + time-of-day alert routing.
Profiles match household state (EMPTY/KIDS_HOME/ADULTS_HOME/ALL_HOME)
and time windows (sleep hours). Per-detection-type rules control
push/record/quiet behavior with role-based recipients (all vs adults).

Task 5 — Recording Timeline: canvas-based 24h timeline per camera
with color-coded segments (person=red, vehicle=blue, motion=gray).
Click-to-play, date picker, detection type filters, hour markers.
Timeline API endpoint returns segments for a camera+date.

All 5 daily-use feature tasks complete. 140 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-04-03 00:09:08 -04:00
parent 8314a61815
commit 11d776faa6
8 changed files with 464 additions and 5 deletions

View File

@@ -4,11 +4,52 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-camera-video me-2"></i>Recordings</h5>
<select class="form-select form-select-sm" id="filter-camera" style="width: auto;">
<option value="">All Cameras</option>
</select>
<div class="d-flex gap-2 align-items-center">
<input type="date" class="form-control form-control-sm bg-dark text-light border-secondary"
id="timeline-date" style="width: auto;">
<select class="form-select form-select-sm bg-dark text-light border-secondary"
id="filter-camera" style="width: auto;">
<option value="">All Cameras</option>
</select>
<div class="btn-group btn-group-sm" id="filter-type">
<button class="btn btn-outline-light active" data-type="all">All</button>
<button class="btn btn-outline-danger" data-type="person">Person</button>
<button class="btn btn-outline-primary" data-type="vehicle">Vehicle</button>
<button class="btn btn-outline-secondary" data-type="motion">Motion</button>
</div>
</div>
</div>
<!-- Timeline canvases (one per camera) -->
<div id="timeline-container">
<div class="card bg-dark border-secondary mb-3" id="timeline-placeholder">
<div class="card-body text-center text-muted py-3">
Select a camera to view the recording timeline
</div>
</div>
</div>
<!-- Video player area -->
<div class="card bg-dark border-secondary mb-3 d-none" id="player-card">
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
<span id="player-title">Recording</span>
<button class="btn btn-sm btn-outline-light" id="player-close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="card-body p-0">
<video id="player-video" class="w-100" controls></video>
</div>
</div>
<!-- Legend -->
<div class="d-flex gap-3 mb-3 small text-muted">
<span><span class="badge" style="background:#dc3545">&nbsp;</span> Person</span>
<span><span class="badge" style="background:#0d6efd">&nbsp;</span> Vehicle</span>
<span><span class="badge" style="background:#6c757d">&nbsp;</span> Motion</span>
</div>
<!-- Fallback table for recordings list -->
<div class="card bg-dark border-secondary">
<div class="card-body p-0">
<div class="table-responsive">
@@ -47,4 +88,71 @@
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/timeline.js') }}"></script>
<script>
(function() {
'use strict';
const dateInput = document.getElementById('timeline-date');
const cameraSelect = document.getElementById('filter-camera');
const container = document.getElementById('timeline-container');
const placeholder = document.getElementById('timeline-placeholder');
const playerCard = document.getElementById('player-card');
const playerVideo = document.getElementById('player-video');
const playerTitle = document.getElementById('player-title');
const playerClose = document.getElementById('player-close');
// Default to today
dateInput.value = new Date().toISOString().split('T')[0];
playerClose.addEventListener('click', () => {
playerCard.classList.add('d-none');
playerVideo.pause();
playerVideo.src = '';
});
function handleSegmentClick(seg) {
playerCard.classList.remove('d-none');
playerTitle.textContent = `Recording #${seg.id} (${seg.type || 'motion'})`;
playerVideo.src = `/recordings/${seg.id}/download`;
playerVideo.play().catch(() => {});
}
function loadTimeline() {
const cameraId = cameraSelect.value;
const date = dateInput.value;
if (!cameraId) {
placeholder.classList.remove('d-none');
// Remove any existing timeline canvases
container.querySelectorAll('.timeline-row').forEach(el => el.remove());
return;
}
placeholder.classList.add('d-none');
container.querySelectorAll('.timeline-row').forEach(el => el.remove());
const row = document.createElement('div');
row.className = 'card bg-dark border-secondary mb-2 timeline-row';
row.innerHTML = `
<div class="card-header border-secondary py-1 small">${cameraId}</div>
<div class="card-body p-2">
<canvas data-height="48" style="width:100%;cursor:pointer"></canvas>
</div>`;
container.appendChild(row);
const canvas = row.querySelector('canvas');
const tl = new VigilarTimeline(canvas, {
cameraId: cameraId,
date: date,
onClick: handleSegmentClick,
});
tl.load();
}
cameraSelect.addEventListener('change', loadTimeline);
dateInput.addEventListener('change', loadTimeline);
})();
</script>
{% endblock %}