feat(measure): timer cicalino con countdown e buzzer dopo fine ciclo misura

- Blueprint: passa measurement_interval_minutes dalla ricetta al template task_execute
- Dopo "Fine ciclo misura": countdown MM:SS con banner amber animato sopra footer
- Allo scadere: 3 beep sonori (Web Audio API 880Hz) + reset misurazioni per nuovo ciclo
- Contatore cicli visibile nel banner timer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-05-24 07:22:10 +00:00
parent 6bfe0a98e8
commit e05eb66a1c
4 changed files with 108 additions and 0 deletions
@@ -135,11 +135,15 @@ def task_execute(task_id: int):
# Load all task IDs for this recipe (ordered) for auto-advance # Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id") recipe_id = task_resp.get("recipe_id")
all_task_ids = [] all_task_ids = []
measurement_interval_minutes = None
if recipe_id: if recipe_id:
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list): if isinstance(tasks_resp, list):
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0)) sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
all_task_ids = [t["id"] for t in sorted_tasks] all_task_ids = [t["id"] for t in sorted_tasks]
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if not recipe_resp.get("error"):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
return render_template( return render_template(
"measure/task_execute.html", "measure/task_execute.html",
@@ -147,6 +151,7 @@ def task_execute(task_id: int):
lot_number=lot_number, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
all_task_ids=all_task_ids, all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes,
) )
@@ -406,6 +406,28 @@
</div> </div>
{# ================================================================
MEASUREMENT TIMER BANNER
================================================================ #}
<div x-show="timerActive"
x-transition
x-cloak
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
<div class="flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Prossima misurazione tra') }}
</span>
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
x-text="timerDisplay"></span>
<span class="text-xs text-amber-600 dark:text-amber-400">
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
</span>
</div>
</div>
{# ================================================================ {# ================================================================
FOOTER — Progress bar + navigation FOOTER — Progress bar + navigation
================================================================ #} ================================================================ #}
@@ -629,6 +651,13 @@ function taskExecute() {
errorMessage: '', errorMessage: '',
showCompletionOverlay: false, showCompletionOverlay: false,
// ---- Measurement timer ----
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
timerActive: false,
timerRemaining: 0,
_timerInterval: null,
cycleCount: 0,
// ---- Cycle & workflow state ---- // ---- Cycle & workflow state ----
cycleConfirmed: false, cycleConfirmed: false,
showSupervisorModal: false, showSupervisorModal: false,
@@ -852,6 +881,68 @@ function taskExecute() {
confirmCycle() { confirmCycle() {
this.cycleConfirmed = true; this.cycleConfirmed = true;
this.showCompletionOverlay = false; this.showCompletionOverlay = false;
this.cycleCount++;
// Start measurement timer if recipe has an interval
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startMeasurementTimer();
}
},
// ---- Measurement timer ----
startMeasurementTimer() {
this.stopMeasurementTimer();
this.timerRemaining = this.measurementIntervalMinutes * 60;
this.timerActive = true;
var self = this;
this._timerInterval = setInterval(function () {
self.timerRemaining--;
if (self.timerRemaining <= 0) {
self.onTimerExpired();
}
}, 1000);
},
stopMeasurementTimer() {
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this.timerActive = false;
},
onTimerExpired() {
this.stopMeasurementTimer();
this.playBuzzer();
// Reset for new measurement cycle
this.cycleConfirmed = false;
this.measurements = [];
this.currentIndex = 0;
this.currentValue = null;
},
playBuzzer() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
// 3 short beeps
[0, 0.25, 0.5].forEach(function (delay) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'square';
gain.gain.value = 0.3;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.15);
});
} catch (_) {}
},
get timerDisplay() {
var m = Math.floor(this.timerRemaining / 60);
var s = this.timerRemaining % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}, },
// ---- Navigate to next task (Completato) ---- // ---- Navigate to next task (Completato) ----
@@ -759,6 +759,12 @@ msgstr "seconds due to inactivity."
msgid "Continua a lavorare" msgid "Continua a lavorare"
msgstr "Continue working" msgstr "Continue working"
msgid "Prossima misurazione tra"
msgstr "Next measurement in"
msgid "Ciclo"
msgstr "Cycle"
msgid "Intervallo misura (minuti)" msgid "Intervallo misura (minuti)"
msgstr "Measurement interval (minutes)" msgstr "Measurement interval (minutes)"
@@ -789,6 +789,12 @@ msgstr "secondi per inattività."
msgid "Continua a lavorare" msgid "Continua a lavorare"
msgstr "Continua a lavorare" msgstr "Continua a lavorare"
msgid "Prossima misurazione tra"
msgstr "Prossima misurazione tra"
msgid "Ciclo"
msgstr "Ciclo"
msgid "Intervallo misura (minuti)" msgid "Intervallo misura (minuti)"
msgstr "Intervallo misura (minuti)" msgstr "Intervallo misura (minuti)"