feat: add support for one-off recording schedules with start and end datetime

This commit is contained in:
serversdwn
2026-02-10 07:08:03 +00:00
parent 3b29c4d645
commit 842e9d6f61
9 changed files with 508 additions and 9 deletions

View File

@@ -18,6 +18,10 @@
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Weekly
</span>
{% elif item.schedule.schedule_type == 'one_off' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
One-Off
</span>
{% else %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
24/7 Cycle
@@ -69,6 +73,20 @@
(with download)
{% endif %}
</div>
{% elif item.schedule.schedule_type == 'one_off' %}
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
{% if item.schedule.start_datetime %}
{{ item.schedule.start_datetime|local_datetime }} {{ timezone_abbr() }}
&rarr;
{{ item.schedule.end_datetime|local_datetime }} {{ timezone_abbr() }}
{% endif %}
{% if item.schedule.include_download %}
(with download)
{% endif %}
</div>
{% endif %}
{% if item.schedule.next_occurrence %}

View File

@@ -0,0 +1,206 @@
<!-- One-Off Recording Schedule Editor -->
<!-- Used for single start/stop recordings with a specific date+time range -->
<div id="schedule-oneoff-editor" class="space-y-4">
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
Schedule a single recording session with a specific start and end time.
Duration can be between 15 minutes and 24 hours.
</p>
</div>
<!-- Info box -->
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex gap-3">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-amber-700 dark:text-amber-300">
<p class="font-medium mb-1">How it works:</p>
<ol class="list-decimal list-inside space-y-1 text-xs">
<li>At the start time, the measurement will <strong>start</strong></li>
<li>At the end time, the measurement will <strong>stop</strong></li>
<li>If enabled, data will be <strong>downloaded</strong> via FTP after stop</li>
<li>The schedule will be <strong>auto-disabled</strong> after completion</li>
</ol>
</div>
</div>
</div>
<!-- Start date/time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Start Date & Time
</label>
<input type="datetime-local"
name="start_datetime"
id="oneoff_start_datetime"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange"
required>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Must be in the future
</p>
</div>
<!-- End date/time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date & Time
</label>
<input type="datetime-local"
name="end_datetime"
id="oneoff_end_datetime"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange"
required>
</div>
<!-- Duration preview -->
<div id="oneoff-duration-preview" class="hidden bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Duration: </span>
<span id="oneoff-duration-text" class="text-sm text-gray-600 dark:text-gray-400"></span>
</div>
</div>
<p id="oneoff-duration-error" class="hidden text-xs text-red-500 mt-2"></p>
</div>
<!-- Download option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="include_download"
id="include_download_oneoff"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
checked>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Download data after recording ends
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, measurement data will be downloaded via FTP after the recording stops.
</p>
</div>
</label>
</div>
<!-- Auto-increment index option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="auto_increment_index"
id="auto_increment_index_oneoff"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
checked>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-increment store index before start
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, the store/index number is incremented before starting.
This prevents "overwrite existing data?" prompts on the device.
</p>
</div>
</label>
</div>
</div>
<script>
// Set min datetime to now (prevent past selections)
function setMinDatetime() {
const now = new Date();
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15);
now.setSeconds(0);
now.setMilliseconds(0);
const minStr = now.toISOString().slice(0, 16);
document.getElementById('oneoff_start_datetime').min = minStr;
document.getElementById('oneoff_end_datetime').min = minStr;
}
setMinDatetime();
// Update duration preview when dates change
function updateDurationPreview() {
const startInput = document.getElementById('oneoff_start_datetime');
const endInput = document.getElementById('oneoff_end_datetime');
const preview = document.getElementById('oneoff-duration-preview');
const durationText = document.getElementById('oneoff-duration-text');
const errorText = document.getElementById('oneoff-duration-error');
if (!startInput.value || !endInput.value) {
preview.classList.add('hidden');
return;
}
const start = new Date(startInput.value);
const end = new Date(endInput.value);
const diffMs = end - start;
const diffMinutes = diffMs / (1000 * 60);
preview.classList.remove('hidden');
errorText.classList.add('hidden');
if (diffMinutes <= 0) {
durationText.textContent = 'Invalid';
errorText.textContent = 'End time must be after start time.';
errorText.classList.remove('hidden');
return;
}
if (diffMinutes < 15) {
const mins = Math.round(diffMinutes);
durationText.textContent = `${mins} minute${mins !== 1 ? 's' : ''}`;
errorText.textContent = 'Minimum duration is 15 minutes.';
errorText.classList.remove('hidden');
return;
}
if (diffMinutes > 1440) {
const hours = Math.round(diffMinutes / 60 * 10) / 10;
durationText.textContent = `${hours} hours`;
errorText.textContent = 'Maximum duration is 24 hours.';
errorText.classList.remove('hidden');
return;
}
// Valid duration
if (diffMinutes < 60) {
durationText.textContent = `${Math.round(diffMinutes)} minutes`;
} else {
const hours = Math.floor(diffMinutes / 60);
const mins = Math.round(diffMinutes % 60);
durationText.textContent = mins > 0
? `${hours} hour${hours !== 1 ? 's' : ''} ${mins} min`
: `${hours} hour${hours !== 1 ? 's' : ''}`;
}
}
document.getElementById('oneoff_start_datetime').addEventListener('change', function() {
// Auto-set end to start + 1 hour if end is empty
const endInput = document.getElementById('oneoff_end_datetime');
if (!endInput.value) {
const start = new Date(this.value);
start.setHours(start.getHours() + 1);
endInput.value = start.toISOString().slice(0, 16);
}
// Update min of end input
endInput.min = this.value;
updateDurationPreview();
});
document.getElementById('oneoff_end_datetime').addEventListener('change', updateDurationPreview);
// Function to get one-off data as object (called by parent form)
function getOneOffData() {
return {
start_datetime: document.getElementById('oneoff_start_datetime').value,
end_datetime: document.getElementById('oneoff_end_datetime').value,
include_download: document.getElementById('include_download_oneoff').checked,
auto_increment_index: document.getElementById('auto_increment_index_oneoff').checked,
};
}
</script>

View File

@@ -456,7 +456,7 @@
<!-- Schedule Type Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
@@ -485,6 +485,20 @@
</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="one_off" class="peer sr-only" onchange="toggleScheduleType('one_off')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Single recording session with a specific start and end date/time (15 min - 24 hrs).
</p>
</div>
</label>
</div>
</div>
@@ -498,6 +512,11 @@
{% include "partials/projects/schedule_interval.html" %}
</div>
<!-- One-Off Editor -->
<div id="schedule-oneoff-wrapper" class="hidden">
{% include "partials/projects/schedule_oneoff.html" %}
</div>
<!-- Timezone -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
@@ -1108,13 +1127,18 @@ function getSelectedLocationIds() {
function toggleScheduleType(type) {
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
const intervalEditor = document.getElementById('schedule-interval-wrapper');
const oneoffEditor = document.getElementById('schedule-oneoff-wrapper');
weeklyEditor.classList.add('hidden');
intervalEditor.classList.add('hidden');
oneoffEditor.classList.add('hidden');
if (type === 'weekly_calendar') {
weeklyEditor.classList.remove('hidden');
intervalEditor.classList.add('hidden');
} else {
weeklyEditor.classList.add('hidden');
} else if (type === 'simple_interval') {
intervalEditor.classList.remove('hidden');
} else if (type === 'one_off') {
oneoffEditor.classList.remove('hidden');
}
}
@@ -1178,7 +1202,7 @@ document.getElementById('schedule-form').addEventListener('submit', async functi
} else {
payload.include_download = true;
}
} else {
} else if (scheduleType === 'simple_interval') {
// Get interval data
if (typeof getIntervalData === 'function') {
const intervalData = getIntervalData();
@@ -1190,6 +1214,45 @@ document.getElementById('schedule-form').addEventListener('submit', async functi
showScheduleError('Interval editor not loaded properly.');
return;
}
} else if (scheduleType === 'one_off') {
// Get one-off data
if (typeof getOneOffData === 'function') {
const oneOffData = getOneOffData();
if (!oneOffData.start_datetime || !oneOffData.end_datetime) {
showScheduleError('Please select both start and end date/time.');
return;
}
const start = new Date(oneOffData.start_datetime);
const end = new Date(oneOffData.end_datetime);
const diffMinutes = (end - start) / (1000 * 60);
if (diffMinutes <= 0) {
showScheduleError('End time must be after start time.');
return;
}
if (diffMinutes < 15) {
showScheduleError('Duration must be at least 15 minutes.');
return;
}
if (diffMinutes > 1440) {
showScheduleError('Duration cannot exceed 24 hours.');
return;
}
if (start <= new Date()) {
showScheduleError('Start time must be in the future.');
return;
}
payload.start_datetime = oneOffData.start_datetime;
payload.end_datetime = oneOffData.end_datetime;
payload.include_download = oneOffData.include_download;
payload.auto_increment_index = oneOffData.auto_increment_index;
} else {
showScheduleError('One-off editor not loaded properly.');
return;
}
}
try {

View File

@@ -772,6 +772,8 @@
// Handle Add Unit form submission
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
// Only handle the form's own POST request, not child HTMX requests (e.g. project picker search)
if (event.detail.elt !== this) return;
if (event.detail.successful) {
closeAddUnitModal();
refreshDeviceList();