feat: redesign task_execute with 3-column layout and subtask images

Refactor measurement execution page from 2-column to 3-column layout:
- Left: vertical marker sidebar with status indicators
- Center: image area with subtask image switching (per-subtask detail
  images override task annotation viewer) + optional recipe image strip
- Right: compact info panel with tolerances, feedback, and numpad

Also: compact header bar, use window.__data pattern for tojson escaping,
fetch recipe image_path in measure blueprint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-20 11:58:55 +01:00
parent 13986f05d7
commit 6bd6e229e5
2 changed files with 354 additions and 412 deletions
+6
View File
@@ -124,15 +124,21 @@ 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 = []
recipe_resp = {}
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]
# Fetch recipe for image_path
resp = api_client.get(f"/api/recipes/{recipe_id}")
if not (isinstance(resp, dict) and resp.get("error")):
recipe_resp = resp
return render_template( return render_template(
"measure/task_execute.html", "measure/task_execute.html",
task=task_resp, task=task_resp,
recipe=recipe_resp,
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,
+194 -258
View File
@@ -3,13 +3,12 @@
{% block extra_head %} {% block extra_head %}
<style> <style>
/* Annotation viewer container aspect ratio */ /* Annotation viewer container */
.annotation-container { .annotation-container {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 300px; height: 100%;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 0.75rem;
overflow: hidden; overflow: hidden;
} }
.annotation-container canvas { .annotation-container canvas {
@@ -31,96 +30,83 @@
); );
} }
/* Pulse animation for active marker indicator */ /* Pulse animation for active marker */
@keyframes markerPulse { @keyframes markerPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); } 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(37, 99, 235, 0); } 50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
} }
.marker-active-pulse { .marker-active-pulse {
animation: markerPulse 2s ease-in-out infinite; animation: markerPulse 2s ease-in-out infinite;
} }
/* Smooth value transition */ /* Left sidebar scrollbar */
.value-enter { .sidebar-markers::-webkit-scrollbar { width: 3px; }
animation: valueSlideIn 0.3s ease-out; .sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
}
@keyframes valueSlideIn { /* Right panel scrollbar */
from { opacity: 0; transform: translateY(-8px); } .right-panel::-webkit-scrollbar { width: 4px; }
to { opacity: 1; transform: translateY(0); } .right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<script> <script>
window.__taskData = {{ task|tojson }};
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }}; window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
window.__allTaskIds = {{ all_task_ids|tojson }}; window.__allTaskIds = {{ all_task_ids|tojson }};
window.__recipeImagePath = {{ (recipe.image_path or '')|tojson }};
</script> </script>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
<div class="h-screen flex flex-col overflow-hidden"
x-data="taskExecute()" x-data="taskExecute()"
x-init="init()" x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)" @numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"> @marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
{# ================================================================ {# ================================================================
HEADER — Task info + traceability HEADER — Compact task info bar
================================================================ #} ================================================================ #}
<div class="bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm"> <div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div class="px-3 py-2 flex items-center gap-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
{# Left: Task title and directive #} {# Back button #}
<div class="flex-1 min-w-0">
{# Breadcrumb line #}
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}" <a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="text-xs text-[var(--text-muted)] hover:text-primary transition-colors inline-flex items-center gap-1"> class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/> <path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg> </svg>
{{ _('Task') }}
</a> </a>
<svg class="w-3 h-3 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/> {# Task badge + title #}
</svg> <div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold <span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800"> border border-primary-200 dark:border-primary-800">
Task {{ (task.order_index or 0) + 1 }} Task {{ (task.order_index or 0) + 1 }}
</span> </span>
</div> <h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
<h1 class="text-xl font-bold text-[var(--text-primary)] truncate">
{{ task.title or _('Task di misurazione') }} {{ task.title or _('Task di misurazione') }}
</h1> </h1>
{% if task.directive or task.description %}
<p class="text-sm text-[var(--text-secondary)] mt-0.5 line-clamp-2">
{{ task.directive or task.description }}
</p>
{% endif %}
</div> </div>
{# Right: Traceability + Caliper #} {# Lot + Serial badges #}
<div class="flex items-center gap-3 flex-wrap shrink-0"> <div class="shrink-0 flex items-center gap-2">
{# Lot badge #}
{% if lot_number %} {% if lot_number %}
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs <div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
text-amber-800 dark:text-amber-200"> text-amber-800 dark:text-amber-200">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"> <svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/> <path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg> </svg>
<span class="font-mono font-semibold">{{ lot_number }}</span> <span class="font-mono font-semibold">{{ lot_number }}</span>
</div> </div>
{% endif %} {% endif %}
{# Serial badge #}
{% if serial_number %} {% if serial_number %}
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs <div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800 bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
text-indigo-800 dark:text-indigo-200"> text-indigo-800 dark:text-indigo-200">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24"> <svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/> <path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg> </svg>
<span class="font-mono font-semibold">{{ serial_number }}</span> <span class="font-mono font-semibold">{{ serial_number }}</span>
@@ -132,224 +118,173 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{# ================================================================ {# ================================================================
MAIN CONTENT — Split layout MAIN — 3-column layout
================================================================ #} ================================================================ #}
<div class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-5"> <div class="flex-1 flex overflow-hidden">
<div class="flex flex-col lg:flex-row gap-5 h-full">
{# ────────────────────────────────────────────── {# ──────────────────────────────────────────────
LEFT COLUMN — Annotation Viewer (60%) LEFT SIDEBAR — Marker list (vertical)
────────────────────────────────────────────── #} ────────────────────────────────────────────── #}
<div class="lg:w-[60%] flex flex-col gap-4"> <div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
{# Image viewer card #}
<div class="tmf-card flex-1 flex flex-col overflow-hidden">
<div class="tmf-card-header flex items-center justify-between">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ _('Disegno Tecnico') }}
</span>
<span class="text-xs text-[var(--text-muted)]"
x-text="'Marker #' + (currentSubtask?.marker_number || '-') + ' {{ _('attivo') }}'"></span>
</div>
<div class="tmf-card-body flex-1 p-0">
{% if task.file_path and task.file_type == 'image' %}
{# Image with annotation overlay #}
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/measure/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations;
$nextTick(() => init());
"
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
<canvas x-ref="annotationCanvas"
@click="handleClick($event)"
class="cursor-pointer"></canvas>
</div>
{% elif task.file_path and task.file_type == 'pdf' %}
{# PDF inline viewer using PDF.js via annotationViewer #}
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/measure/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations;
$nextTick(() => init());
"
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
<canvas x-ref="annotationCanvas"
@click="handleClick($event)"
class="cursor-pointer"></canvas>
</div>
{% else %}
{# No file placeholder #}
<div class="annotation-container flex items-center justify-center">
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Nessuna immagine allegata') }}</p>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Segui le istruzioni nella direttiva') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
{# Marker quick-nav strip (horizontal scrollable) #}
<div class="flex gap-2 overflow-x-auto pb-1 -mb-1 scrollbar-thin" x-show="subtasks.length > 1">
<template x-for="(st, idx) in subtasks" :key="st.id"> <template x-for="(st, idx) in subtasks" :key="st.id">
<button @click="goToSubtask(idx)" <button @click="goToSubtask(idx)"
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all duration-200" class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
:class="idx === currentIndex :class="idx === currentIndex
? 'bg-primary text-white border-primary shadow-md marker-active-pulse' ? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
: getMeasurementStatus(st.id) === 'pass' : 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
? 'bg-measure-pass/10 text-measure-pass border-measure-pass/30 hover:bg-measure-pass/20'
: getMeasurementStatus(st.id) === 'fail' {# Marker circle #}
? 'bg-red-50 dark:bg-red-900/20 text-measure-fail border-measure-fail/30 hover:bg-red-100 dark:hover:bg-red-900/30' <span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
: getMeasurementStatus(st.id) === 'warning'
? 'bg-amber-50 dark:bg-amber-900/20 text-measure-warning border-measure-warning/30 hover:bg-amber-100 dark:hover:bg-amber-900/30'
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
">
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold"
:class="idx === currentIndex :class="idx === currentIndex
? 'bg-white/20' ? 'bg-primary text-white shadow-md marker-active-pulse'
: getMeasurementStatus(st.id) === 'pass' : getMeasurementStatus(st.id) === 'pass'
? 'bg-measure-pass/20' ? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
: getMeasurementStatus(st.id) === 'fail' : getMeasurementStatus(st.id) === 'fail'
? 'bg-measure-fail/20' ? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
: getMeasurementStatus(st.id) === 'warning' : getMeasurementStatus(st.id) === 'warning'
? 'bg-measure-warning/20' ? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
: 'bg-[var(--bg-secondary)]' : 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
"
x-text="st.marker_number"></span> x-text="st.marker_number"></span>
<span x-text="st.description" class="truncate max-w-[100px]"></span>
{# Status icon: pass=check, fail=X, warning=! #} {# Status icon below circle #}
<template x-if="getMeasurementStatus(st.id) === 'pass'"> <template x-if="getMeasurementStatus(st.id) === 'pass'">
<svg class="w-3.5 h-3.5 text-measure-pass shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg> </svg>
</template> </template>
<template x-if="getMeasurementStatus(st.id) === 'fail'"> <template x-if="getMeasurementStatus(st.id) === 'fail'">
<svg class="w-3.5 h-3.5 text-measure-fail shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg> </svg>
</template> </template>
<template x-if="getMeasurementStatus(st.id) === 'warning'"> <template x-if="getMeasurementStatus(st.id) === 'warning'">
<svg class="w-3.5 h-3.5 text-measure-warning shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/> <path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg> </svg>
</template> </template>
</button> </button>
</template> </template>
</div> </div>
{# ──────────────────────────────────────────────
CENTER — Image area
────────────────────────────────────────────── #}
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
{# Recipe image strip (small, top, optional) #}
<div x-show="recipeImagePath" class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] px-2 py-1 flex items-center justify-center">
<img :src="'/measure/api/files/' + recipeImagePath"
alt="{{ _('Immagine ricetta') }}"
class="max-h-20 object-contain rounded">
</div>
{# Main image area #}
<div class="flex-1 overflow-hidden relative">
{# Option A: Subtask has its own image → plain <img> #}
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
<img :src="'/measure/api/files/' + currentSubtaskImage"
alt="{{ _('Immagine dettaglio misura') }}"
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
</div>
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
<div x-show="showTaskImage" class="absolute inset-0">
{% if task.file_path %}
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/measure/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations;
$nextTick(() => init());
"
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
<canvas x-ref="annotationCanvas"
@click="handleClick($event)"
class="cursor-pointer"></canvas>
</div>
{% endif %}
</div>
{# Option C: No image → placeholder #}
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
</div>
</div>
</div>
</div> </div>
{# ────────────────────────────────────────────── {# ──────────────────────────────────────────────
RIGHT COLUMN — Measurement Panel (40%) RIGHT PANEL — Info + tolerances + numpad
────────────────────────────────────────────── #} ────────────────────────────────────────────── #}
<div class="lg:w-[40%] flex flex-col gap-4"> <div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
{# ---- Subtask Info Card ---- #} {# ---- Subtask header ---- #}
<div class="tmf-card" x-show="currentSubtask"> <div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="p-4 sm:p-5"> <div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
{# Marker header #} bg-primary text-white font-bold text-sm shadow marker-active-pulse"
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2.5">
<span class="inline-flex items-center justify-center w-9 h-9 rounded-full
bg-primary text-white font-bold text-sm shadow-md marker-active-pulse"
x-text="currentSubtask?.marker_number"></span> x-text="currentSubtask?.marker_number"></span>
<div> <div class="min-w-0 flex-1">
<h2 class="font-semibold text-[var(--text-primary)] text-base leading-tight" <h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2> x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
<span class="text-xs text-[var(--text-muted)]" <span class="text-[11px] text-[var(--text-muted)]"
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span> x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
</div> </div>
</div> <span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
{# Counter badge #}
<span class="badge badge-neutral font-mono text-xs">
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span> <span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
</span> </span>
</div> </div>
</div>
{# Tolerance parameters table #} {# ---- Tolerance parameters (compact grid) ---- #}
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm mb-1"> <div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
{# Nominal #} {# Nominal row #}
<div class="flex items-center justify-between col-span-2 py-1.5 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800"> <div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
<span class="text-xs font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span> <span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
<span class="font-mono font-bold text-primary text-base" <span class="font-mono font-bold text-primary text-sm"
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span> x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
</div> </div>
{# UTL #} {# 2x2 tolerance grid #}
<div class="flex items-center justify-between py-1 px-3 rounded bg-red-50/50 dark:bg-red-900/10"> <div class="grid grid-cols-2 gap-1 text-[11px]">
<span class="text-xs text-measure-fail font-medium">UTL</span> <div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="font-mono text-xs font-semibold text-measure-fail" <span class="text-measure-fail font-medium">UTL</span>
x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span> <span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
</div> </div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
{# LTL #} <span class="text-measure-fail font-medium">LTL</span>
<div class="flex items-center justify-between py-1 px-3 rounded bg-red-50/50 dark:bg-red-900/10"> <span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
<span class="text-xs text-measure-fail font-medium">LTL</span>
<span class="font-mono text-xs font-semibold text-measure-fail"
x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
</div> </div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
{# UWL #} <span class="text-measure-warning font-medium">UWL</span>
<div class="flex items-center justify-between py-1 px-3 rounded bg-amber-50/50 dark:bg-amber-900/10"> <span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
<span class="text-xs text-measure-warning font-medium">UWL</span>
<span class="font-mono text-xs font-semibold text-measure-warning"
x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
</div>
{# LWL #}
<div class="flex items-center justify-between py-1 px-3 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-xs text-measure-warning font-medium">LWL</span>
<span class="font-mono text-xs font-semibold text-measure-warning"
x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
</div>
</div>
{# Already measured indicator #}
<div x-show="isMeasured(currentSubtask?.id)"
class="mt-3 p-2.5 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
<div class="flex items-center gap-2 text-sm text-measure-pass font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span>{{ _('Misurazione registrata') }}:</span>
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
<span class="text-xs" x-text="currentSubtask?.unit || 'mm'"></span>
</div> </div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">LWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
</div> </div>
</div> </div>
</div> </div>
{# ---- Tolerance Visualization Bar ---- #} {# ---- Tolerance bar (compact) ---- #}
<div class="tmf-card" x-show="currentSubtask"> <div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="p-4"> <div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
<div class="tolerance-bar-bg h-8 rounded-full relative overflow-hidden border border-[var(--border-color)]">
{# Zone labels #} {# Zone labels #}
<div class="absolute inset-0 flex items-center justify-between px-2 text-[9px] font-mono text-[var(--text-muted)]"> <div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span> <span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span> <span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
<span x-text="currentSubtask?.utl?.toFixed(2)"></span> <span x-text="currentSubtask?.utl?.toFixed(2)"></span>
</div> </div>
{# Center line #}
{# Nominal center line #}
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div> <div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
{# Value needle #}
{# Value indicator (needle) #}
<div x-show="currentValue !== null" <div x-show="currentValue !== null"
x-transition x-transition
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300" class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
@@ -359,8 +294,7 @@
'bg-measure-fail': passFailStatus === 'fail' 'bg-measure-fail': passFailStatus === 'fail'
}" }"
:style="'left: calc(' + progressWidth + '% - 2px)'"> :style="'left: calc(' + progressWidth + '% - 2px)'">
{# Pip on top #} <div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white dark:border-slate-800 shadow-md"
:class="{ :class="{
'bg-measure-pass': passFailStatus === 'pass', 'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning', 'bg-measure-warning': passFailStatus === 'warning',
@@ -369,10 +303,22 @@
</div> </div>
</div> </div>
</div> </div>
{# ---- Already measured indicator ---- #}
<div x-show="isMeasured(currentSubtask?.id)"
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span>{{ _('Registrata') }}:</span>
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
<span x-text="currentSubtask?.unit || 'mm'"></span>
</div>
</div> </div>
{# ---- Measurement Feedback ---- #} {# ---- Measurement feedback ---- #}
<div class="px-1" x-data="{ get nominal() { return currentSubtask?.nominal || 0; }, <div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
get utl() { return currentSubtask?.utl || 0; }, get utl() { return currentSubtask?.utl || 0; },
get uwl() { return currentSubtask?.uwl || 0; }, get uwl() { return currentSubtask?.uwl || 0; },
get lwl() { return currentSubtask?.lwl || 0; }, get lwl() { return currentSubtask?.lwl || 0; },
@@ -382,14 +328,13 @@
</div> </div>
{# ---- Numpad ---- #} {# ---- Numpad ---- #}
<div class="tmf-card"> <div class="px-3 py-2 relative flex-1">
<div class="p-4 relative">
{# Saving overlay #} {# Saving overlay #}
<div x-show="saving" <div x-show="saving"
x-transition x-transition
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 rounded-xl flex items-center justify-center backdrop-blur-sm"> class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
<div class="flex items-center gap-3 text-primary font-medium"> <div class="flex items-center gap-2 text-primary font-medium text-sm">
<svg class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
@@ -399,10 +344,9 @@
{% include "components/numpad.html" %} {% include "components/numpad.html" %}
</div> </div>
</div>
{# ---- Next Measurement Indicator ---- #} {# ---- Next measurement indicator ---- #}
<div class="px-1"> <div class="px-3 pb-2">
{% include "components/next_measurement.html" %} {% include "components/next_measurement.html" %}
</div> </div>
@@ -410,9 +354,9 @@
<div x-show="errorMessage" <div x-show="errorMessage"
x-transition x-transition
x-cloak x-cloak
class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail"> class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
<span x-text="errorMessage"></span> <span x-text="errorMessage"></span>
@@ -421,51 +365,42 @@
</div> </div>
</div> </div>
</div>
{# ================================================================ {# ================================================================
FOOTER — Progress bar + navigation FOOTER — Progress bar + navigation
================================================================ #} ================================================================ #}
<div class="sticky bottom-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-30"> <div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3"> <div class="px-3 py-2 flex items-center gap-3">
<div class="flex items-center gap-4">
{# Left: Back button #} {# Left: Back #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}" <a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="btn btn-secondary text-xs shrink-0 gap-1.5"> class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/> <path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg> </svg>
<span class="hidden sm:inline">{{ _('Task') }}</span> <span class="hidden sm:inline">{{ _('Task') }}</span>
</a> </a>
{# Center: Progress #} {# Center: Progress bar #}
<div class="flex-1"> <div class="flex-1 flex items-center gap-2">
<div class="flex items-center gap-3">
{# Text counter #}
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono"> <span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span> <span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
</span> </span>
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
{# Progress bar #}
<div class="flex-1 h-2.5 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ease-out" <div class="h-full rounded-full transition-all duration-500 ease-out"
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'" :class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
:style="'width: ' + progressPercent + '%'"></div> :style="'width: ' + progressPercent + '%'"></div>
</div> </div>
{# Percentage #}
<span class="text-xs font-bold shrink-0" <span class="text-xs font-bold shrink-0"
:class="isComplete ? 'text-measure-pass' : 'text-primary'" :class="isComplete ? 'text-measure-pass' : 'text-primary'"
x-text="Math.round(progressPercent) + '%'"></span> x-text="Math.round(progressPercent) + '%'"></span>
</div> </div>
</div>
{# Right: Complete / next task button #} {# Right: Summary button #}
<button x-show="isComplete" <button x-show="isComplete"
x-transition x-transition
@click="goToSummary()" @click="goToSummary()"
class="btn btn-primary text-xs shrink-0 gap-1.5 shadow-md"> class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
@@ -476,7 +411,6 @@
</button> </button>
</div> </div>
</div> </div>
</div>
{# ================================================================ {# ================================================================
COMPLETION OVERLAY COMPLETION OVERLAY
@@ -495,7 +429,6 @@
x-transition:enter-start="opacity-0 scale-90" x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100"> x-transition:enter-end="opacity-100 scale-100">
{# Success icon #}
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4"> <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
@@ -507,7 +440,6 @@
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }} {{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
</p> </p>
{# Summary counts #}
<div class="flex justify-center gap-4 mb-5"> <div class="flex justify-center gap-4 mb-5">
<div class="text-center"> <div class="text-center">
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div> <div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
@@ -549,14 +481,16 @@
/** /**
* Task Execute - Main Alpine.js component * Task Execute - Main Alpine.js component
* Manages measurement workflow state, save logic, and navigation. * Manages measurement workflow state, save logic, and navigation.
* Layout: 3-column (marker sidebar | image center | info+numpad right)
*/ */
function taskExecute() { function taskExecute() {
return { return {
// ---- Data from server ---- // ---- Data from server ----
task: {{ task|tojson }}, task: window.__taskData,
subtasks: {{ task.subtasks|tojson if task.subtasks else '[]' }}, subtasks: window.__taskSubtasks,
lotNumber: '{{ lot_number or '' }}', lotNumber: '{{ lot_number or '' }}',
serialNumber: '{{ serial_number or '' }}', serialNumber: '{{ serial_number or '' }}',
recipeImagePath: window.__recipeImagePath || '',
// ---- Measurement state ---- // ---- Measurement state ----
currentIndex: 0, currentIndex: 0,
@@ -568,6 +502,17 @@ function taskExecute() {
// ---- Value from numpad / caliper ---- // ---- Value from numpad / caliper ----
currentValue: null, currentValue: null,
// ---- Image switching logic ----
get currentSubtaskImage() {
return this.currentSubtask?.image_path || null;
},
get showSubtaskImage() {
return !!this.currentSubtaskImage;
},
get showTaskImage() {
return !this.currentSubtaskImage && !!this.task.file_path;
},
// ---- Computed properties ---- // ---- Computed properties ----
get currentSubtask() { get currentSubtask() {
return this.subtasks[this.currentIndex] || null; return this.subtasks[this.currentIndex] || null;
@@ -635,7 +580,6 @@ function taskExecute() {
// ---- Init ---- // ---- Init ----
init() { init() {
// Sort subtasks by order_index to ensure correct order
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0)); this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
}, },
@@ -661,14 +605,13 @@ function taskExecute() {
this.currentValue = value; this.currentValue = value;
this.errorMessage = ''; this.errorMessage = '';
// Compute pass/fail before saving
const pf = this.passFailStatus; const pf = this.passFailStatus;
const dev = this.deviation; const dev = this.deviation;
this.saving = true; this.saving = true;
try { try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const response = await fetch('{{ url_for("measure.save_measurement") }}', { const response = await fetch('{{ url_for("measure.save_measurement") }}', {
method: 'POST', method: 'POST',
@@ -694,7 +637,7 @@ function taskExecute() {
return; return;
} }
// Record measurement locally (replace if already measured) // Record measurement locally
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id); const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev }; const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
if (existingIdx !== -1) { if (existingIdx !== -1) {
@@ -705,25 +648,21 @@ function taskExecute() {
this.saving = false; this.saving = false;
// Pause 1s to let user see the result (pass/fail/warning) // Pause to show result feedback
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
// Check if all done // Check if all done
if (this.completedCount >= this.totalSubtasks) { if (this.completedCount >= this.totalSubtasks) {
// Auto-advance to next task, or show overlay on last task
const taskIds = window.__allTaskIds || []; const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id); const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) { if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
// Navigate to next task
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]); window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else { } else {
// Last task (or unknown): show completion overlay
this.showCompletionOverlay = true; this.showCompletionOverlay = true;
} }
return; return;
} }
// Advance to next unmeasured subtask
this.advanceToNext(); this.advanceToNext();
} catch (err) { } catch (err) {
@@ -737,15 +676,12 @@ function taskExecute() {
advanceToNext() { advanceToNext() {
this.currentValue = null; this.currentValue = null;
// First try the next sequential subtask
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) { for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
if (!this.isMeasured(this.subtasks[i].id)) { if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i; this.currentIndex = i;
return; return;
} }
} }
// Wrap around from beginning
for (let i = 0; i < this.currentIndex; i++) { for (let i = 0; i < this.currentIndex; i++) {
if (!this.isMeasured(this.subtasks[i].id)) { if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i; this.currentIndex = i;