From 23244fc4db968579370e563661a322064636a486 Mon Sep 17 00:00:00 2001 From: Quentin Rouiller Date: Fri, 17 Apr 2026 16:00:00 +0200 Subject: [PATCH] =?UTF-8?q?Version=203.2.0=20=E2=80=94=20Stabilisation=203?= =?UTF-8?q?.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.json | 2 +- viewer.js | 413 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 309 insertions(+), 106 deletions(-) diff --git a/manifest.json b/manifest.json index f939d89..95600d1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Planning Techniciens — Vue claire", - "version": "3.2.0-pre", + "version": "3.2.0", "description": "Vue claire du planning EasyVista (itsma.etat-de-vaud.ch et itsma.vd.ch) avec navigation par date, détection automatique des interventions closes et cache 7 jours.", "permissions": [ "activeTab", diff --git a/viewer.js b/viewer.js index 3b1ca4b..889b42b 100644 --- a/viewer.js +++ b/viewer.js @@ -164,19 +164,41 @@ let state = { // Chaque refresh reçoit un "jeton" (entier incrémenté). Les workers lisent // isRefreshAborted() avant chaque fetch : si le jeton a changé ou si // l'utilisateur a cliqué sur "Arrêter", ils s'arrêtent proprement. +// +// v3.2 : on ajoute une "abortPromise" par refresh. loadForDate race cette +// promesse avec son Promise.all, donc dès qu'on clique Arrêter, loadForDate +// sort immédiatement (masque le bouton, fait un toast), même si les fetches +// en cours continuent silencieusement. Le changement de token les rend +// inoffensifs (ils ne peuvent plus écrire le cache ni updater le DOM). let currentRefreshToken = 0; let abortedToken = -1; +let abortResolvers = new Map(); // token → resolve fn of the abort promise function startNewRefresh() { currentRefreshToken++; return currentRefreshToken; } +function makeAbortPromise(myToken) { + return new Promise(resolve => { + abortResolvers.set(myToken, resolve); + }); +} function abortCurrentRefresh() { abortedToken = currentRefreshToken; + // Réveiller tous les loadForDate en attente (normalement un seul) + for (const [token, resolve] of abortResolvers) { + if (token <= currentRefreshToken) { + resolve("aborted"); + abortResolvers.delete(token); + } + } } function isRefreshAborted() { return abortedToken === currentRefreshToken; } +function cleanupAbortResolver(myToken) { + abortResolvers.delete(myToken); +} // ============================================================================ // Boot @@ -253,8 +275,11 @@ function bindTopbar() { document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("refresh-btn").addEventListener("click", () => refreshPlanning()); document.getElementById("abort-btn").addEventListener("click", () => { - abortCurrentRefresh(); + // Feedback visuel instantané : masquer le bouton tout de suite, sans + // attendre que loadForDate finisse sa race. showAbortButton(false); + abortCurrentRefresh(); + showAbortToast(); }); document.getElementById("clear-cache-btn").addEventListener("click", onClearCache); @@ -425,11 +450,12 @@ async function loadForDate(isoDate, opts = {}) { }); console.log(`[load] 1er rendu (sans refs) à ${Math.round(performance.now() - t0)} ms`); - // 5. PARALLÈLE : xhr2 (lieu/contact) + fetches fiches (ref/statut) - // Avant v3.1.1 : séquentiel, on devait attendre les 34 xhr2 avant de - // lancer les 34 fiches. Résultat : première ref arrivait après ~1s. - // Maintenant : les deux démarrent en même temps, chacun met à jour - // la ligne correspondante via le rendu incrémental. + // 5. SÉQUENTIEL : xhr2 (lieu/contact de la bulle) EN PREMIER, + // puis fetches fiches (ref/statut/texte complet) APRÈS. + // Raison : le texte complet de la timeline (branche fiches) peut + // écraser bulleDescription/bulleContact/bulleLieu. Il ne faut pas + // que ça arrive AVANT que la bulle de base soit posée, sinon on se + // retrouve avec une popup vide ou incomplète pendant des secondes. const bulleNeeded = []; for (const tech of merged.techs) { for (const iv of tech.interventions) { @@ -439,46 +465,44 @@ async function loadForDate(isoDate, opts = {}) { } } + // On refetche les fiches si : + // - au moins une intervention n'a jamais été fetchée (pas de ficheTarget), OU + // - au moins une intervention n'a pas encore l'actionDescription complète de la fiche + // (cas du cache chargé depuis une version antérieure à v3.2) const needFetch = merged.techs.some(tech => tech.interventions.some(iv => - iv.type === "AL-Intervention" && !iv.ficheTarget + iv.type === "AL-Intervention" && + (!iv.ficheTarget || !iv.actionDescriptionFetched) ) ); - const promises = []; - + // Étape 5a : xhr2 d'abord (bulle de base : lieu/contact/texte court) if (bulleNeeded.length > 0 && !isRefreshAborted()) { const tBulles = performance.now(); console.log(`[load] fetch xhr2 pour ${bulleNeeded.length} interventions…`); - promises.push( - fetchBullesForInterventions(bulleNeeded).then(() => { - console.log(`[load] xhr2 finis en ${Math.round(performance.now() - tBulles)} ms`); - if (!isRefreshAborted()) { - renderFromData({ - techs: merged.techs, - targetDate: isoDate, - captureTime: Date.now(), - source: "fresh+bulles" - }); - } - }) - ); + await fetchBullesForInterventions(bulleNeeded); + console.log(`[load] xhr2 finis en ${Math.round(performance.now() - tBulles)} ms`); + if (!isRefreshAborted()) { + renderFromData({ + techs: merged.techs, + targetDate: isoDate, + captureTime: Date.now(), + source: "fresh+bulles" + }); + } } + // Étape 5b : fiches APRÈS les xhr2 (ref/statut + texte complet de timeline) if ((opts.doStatusRefresh || needFetch) && !isRefreshAborted()) { const tFiches = performance.now(); const nFiches = merged.techs.flatMap(t=>t.interventions).filter(i=>i.type==="AL-Intervention").length; console.log(`[load] début fetch des ${nFiches} fiches…`); - promises.push( - refreshStatuses(merged.techs, isoDate).then(() => { - console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); - }) - ); + await refreshStatuses(merged.techs, isoDate); + console.log(`[load] fiches finies en ${Math.round(performance.now() - tFiches)} ms`); } - await Promise.all(promises); - // 6. Sauvegarder dans le cache (une seule fois, après que tout soit enrichi) + // Uniquement si on est allé au bout (pas d'annulation). if (!isRefreshAborted()) { await writeCache(isoDate, { techs: merged.techs }); } @@ -486,8 +510,15 @@ async function loadForDate(isoDate, opts = {}) { if (!isRefreshAborted()) { showRefreshDone(); console.log(`[load] TOTAL: ${Math.round(performance.now() - t0)} ms`); + + // Retry silencieux en arrière-plan pour les interventions dont le texte + // d'action n'a pas pu être récupéré (timeline partielle au 1er coup). + // Lancé SANS await : l'user peut continuer à utiliser l'extension. + // La fonction respecte le token : si l'user change de jour, elle s'arrête. + runBackgroundTimelineRetry(merged.techs, isoDate, myToken).catch(() => {}); } else { console.log(`[load] annulé par l'utilisateur à ${Math.round(performance.now() - t0)} ms`); + showAbortToast(); } } finally { // Masquer le bouton "Arrêter" uniquement si c'est NOTRE chargement qui @@ -496,6 +527,7 @@ async function loadForDate(isoDate, opts = {}) { if (currentRefreshToken === myToken) { showAbortButton(false); } + cleanupAbortResolver(myToken); } } @@ -878,9 +910,11 @@ async function refreshStatuses(techs, isoDate) { if (!iv.formLink) continue; // On skip si : // - Déjà clos / résolu ET ficheTarget déjà connu (statut + requestId OK) - // - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic) + // ET actionDescription déjà remplacée depuis la fiche + // - Sinon on garde (pour avoir statut frais OU ficheTarget pour clic + // OU le texte complet de l'action) const statusClosed = isClosedStatus(iv.status) || isResolvedStatus(iv.status); - if (statusClosed && iv.ficheTarget) continue; + if (statusClosed && iv.ficheTarget && iv.actionDescriptionFetched) continue; toFetch.push(iv); } } @@ -981,32 +1015,72 @@ async function fetchAndUpdateIntervention(iv) { } iv.commentaireTech = fiche.commentaireTech; - // ─── Remplacement UNIQUEMENT du texte d'action affiché dans la popup ────── + // ─── Remplacement du texte d'action + contact/lieu depuis la fiche ───────── // Le texte de la bulle (planning_xhr_2.php) est parfois tronqué/incomplet. // La fiche contient le texte complet dans AM_ACTION.DESCRIPTION. // SÉCURITÉ : on ne remplace QUE si l'Intervenant de la fiche correspond au // tech de la ligne du planning (car une même fiche peut avoir plusieurs // actions assignées à différents techs, et on fetche la MÊME fiche pour tous). - // NB : on NE touche PAS à bulleContact/bulleLieu (ils viennent de la bulle - // de base et sont utilisés tels quels ailleurs). - if (fiche.actionDescription && fiche.intervenant && iv.techId) { - const expectedTechName = TEAM[iv.techId]; - if (expectedTechName && namesMatch(fiche.intervenant, expectedTechName)) { - iv.bulleDescription = fiche.actionDescription; + const expectedTechName = iv.techId ? TEAM[iv.techId] : null; + const matchOk = fiche.intervenant && expectedTechName && + namesMatch(fiche.intervenant, expectedTechName); + if (fiche.actionDescription && matchOk) { + // Remplace le texte d'action (affiché dans la popup) + iv.bulleDescription = fiche.actionDescription; + iv.actionDescriptionFetched = true; // flag : déjà remplacé depuis la fiche + // Reparse contact/lieu depuis le nouveau texte : la carte affiche + // bulleContact/bulleLieu, donc il faut les mettre à jour aussi. + const infob = parseActionText(fiche.actionDescription); + if (infob) { + iv.infobulle = infob; + if (infob.contact) iv.bulleContact = infob.contact; + if (infob.lieu) iv.bulleLieu = infob.lieu; } - // Si ça ne matche pas : on garde bulleDescription tel quel (sécurité) } + // Si ça ne matche pas : on garde bulleDescription/Contact/Lieu tels quels (sécurité) // Extraire le checksum CORRECT pour ouvrir la fiche : // - Le target de la FICHE = iv.requestId (vient du XML) // - Il faut trouver le checksum qui est accolé à ce target dans le HTML - // (pattern : target=REQUEST_ID&checksum=XXX...) + // La regex principale cherche "target=REQUEST_ID&checksum=XXX" mais peut + // échouer si ce pattern n'apparaît pas dans le HTML (selon les sections + // hydratées par Angular). On a plusieurs fallbacks robustes. if (iv.requestId) { - const rx = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); - const ckm = ficheResp.html.match(rx); - if (ckm) { + let checksumFound = false; + // Tentative 1 : target=ID&checksum=... (pattern le plus courant dans les liens) + const rx1 = new RegExp(`target=${iv.requestId}&(?:amp;)?checksum=([a-f0-9]{40})`); + const m1 = ficheResp.html.match(rx1); + if (m1) { iv.ficheTarget = iv.requestId; - iv.ficheChecksum = ckm[1]; + iv.ficheChecksum = m1[1]; + checksumFound = true; + } else { + // Tentative 2 : dans le JSON formData : "id":"REQUEST_ID"..."checksum":"..." + // ou l'inverse : "checksum":"..."..."id":"REQUEST_ID" + const rx2a = new RegExp(`"id"\\s*:\\s*"${iv.requestId}"[\\s\\S]{0,200}?"checksum"\\s*:\\s*"([a-f0-9]{40})"`); + const m2a = ficheResp.html.match(rx2a); + if (m2a) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m2a[1]; + checksumFound = true; + } else { + const rx2b = new RegExp(`"checksum"\\s*:\\s*"([a-f0-9]{40})"[\\s\\S]{0,200}?"id"\\s*:\\s*"${iv.requestId}"`); + const m2b = ficheResp.html.match(rx2b); + if (m2b) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m2b[1]; + checksumFound = true; + } + } + } + // Tentative 3 (ultime) : le checksum global du form principal. + if (!checksumFound) { + const rx3 = /"form"\s*:\s*\{[^}]*?"checksum"\s*:\s*"([a-f0-9]{40})"[\s\S]{0,2000}?"id"\s*:\s*"(\d+)"/; + const m3 = ficheResp.html.match(rx3); + if (m3 && m3[2] === String(iv.requestId)) { + iv.ficheTarget = iv.requestId; + iv.ficheChecksum = m3[1]; + } } } iv.ficheFetched = true; @@ -1024,8 +1098,15 @@ async function fetchAndUpdateIntervention(iv) { // Étape timeline API : on veut le texte COMPLET de l'action. // planning_xhr_2.php tronque souvent à ~300 chars, mais l'API timeline // retourne le texte intégral. On la fetch à chaque fois que possible. + // + // PROBLÈME OBSERVÉ : EasyVista retourne parfois une timeline "partielle" + // au 1er appel (ex: 8 Ko au lieu de 44 Ko), sans le texte de l'action + // courante. Le serveur a besoin de "construire" le contexte après le fetch + // de la fiche. Dans ce cas on MARQUE l'intervention pour un retry silencieux + // en arrière-plan (fait plus tard par runBackgroundTimelineRetry). const needsTimelineValidation = !iv.actionText; if (needsTimelineValidation && timelineTarget && timelineChecksum) { + if (isRefreshAborted()) return; const tlResp = await sendMessage({ type: "fetchTimeline", target: timelineTarget, @@ -1034,18 +1115,13 @@ async function fetchAndUpdateIntervention(iv) { if (tlResp && tlResp.ok) { const actionDetails = parseTimelineJson(tlResp.body, iv.actionId); if (actionDetails && actionDetails.text) { - iv.actionText = actionDetails.text; - iv.actionDone = actionDetails.doneById; - // Le texte de timeline est plus complet que bulleDescription : - // on remplace bulleDescription par actionText pour le tooltip. - iv.bulleDescription = actionDetails.text; - const infob = parseActionText(actionDetails.text); - if (infob) { - iv.infobulle = infob; - if (infob.contact) iv.bulleContact = infob.contact; - if (infob.lieu) iv.bulleLieu = infob.lieu; - } + applyActionTextToIv(iv, actionDetails); + } else { + // Timeline partielle : marquer pour retry silencieux en arrière-plan + iv.actionTextPending = true; } + } else { + iv.actionTextPending = true; } } } catch (err) { @@ -1055,6 +1131,94 @@ async function fetchAndUpdateIntervention(iv) { } } +/** + * Applique les détails d'action (texte timeline) à une intervention : + * - met à jour bulleDescription (texte affiché dans la popup) + * - reparse contact/lieu pour mettre à jour la carte + * - rafraîchit la ligne dans le DOM + * Utilisé à la fois par le flow principal et par le retry silencieux. + */ +function applyActionTextToIv(iv, actionDetails) { + iv.actionText = actionDetails.text; + iv.actionDone = actionDetails.doneById; + iv.bulleDescription = actionDetails.text; + iv.actionDescriptionFetched = true; + iv.actionTextPending = false; + const infob = parseActionText(actionDetails.text); + if (infob) { + iv.infobulle = infob; + if (infob.contact) iv.bulleContact = infob.contact; + if (infob.lieu) iv.bulleLieu = infob.lieu; + } + // Rafraîchir la ligne dans le DOM (lieu/contact mis à jour en live) + updateInterventionRow(iv); +} + +/** + * Retry silencieux en arrière-plan : liste les interventions dont le texte + * d'action n'a pas pu être récupéré (timeline partielle au 1er coup), et + * refait un fetch timeline pour chacune, avec un petit délai entre les appels + * pour ne pas surcharger le serveur. + * + * Cette fonction est lancée sans await — elle tourne en tâche de fond pendant + * que l'utilisateur navigue. Elle respecte le jeton de refresh : si l'user + * change de jour, le jeton change et le retry s'arrête silencieusement. + * + * Aucun spinner ni indication visuelle : l'user ne voit rien, sauf que les + * popups se mettent à jour quand le texte arrive. + */ +async function runBackgroundTimelineRetry(techs, isoDate, myToken) { + // Collecter les interventions qui ont besoin d'un retry + const pending = []; + for (const tech of techs) { + for (const iv of tech.interventions) { + if (iv.actionTextPending && iv.ficheTarget && iv.ficheChecksum) { + pending.push(iv); + } + } + } + if (pending.length === 0) return; + + // Attendre un peu avant de démarrer (laisser le serveur "respirer") + await new Promise(r => setTimeout(r, 1500)); + + // Si l'user a changé de jour entre-temps, abandonner + if (currentRefreshToken !== myToken) return; + + for (const iv of pending) { + // Si l'user a navigué ailleurs OU cliqué arrêter : on sort sans bruit + if (currentRefreshToken !== myToken) return; + if (isRefreshAborted()) return; + + try { + const tlResp = await sendMessage({ + type: "fetchTimeline", + target: iv.ficheTarget, + checksum: iv.ficheChecksum + }); + if (tlResp && tlResp.ok) { + const actionDetails = parseTimelineJson(tlResp.body, iv.actionId); + if (actionDetails && actionDetails.text) { + applyActionTextToIv(iv, actionDetails); + } + } + } catch { + // Silence : c'est du retry en arrière-plan, on ne dérange pas l'user + } + + // Petit délai entre chaque retry pour ménager le serveur + await new Promise(r => setTimeout(r, 400)); + } + + // Sauvegarder le cache avec les nouvelles infos (si on est toujours + // sur la même date et même token) + if (currentRefreshToken === myToken && !isRefreshAborted()) { + try { + await writeCache(isoDate, { techs }); + } catch {} + } +} + function isClosedStatus(s) { return !!s && CLOSED_STATUS.some(x => s.includes(x)); } @@ -1447,6 +1611,16 @@ function showAbortButton(on) { else btn.classList.add("hidden"); } +/** + * Toast de feedback quand l'user clique Arrêter. Les fetches en cours peuvent + * encore prendre 1-2 secondes avant de se terminer (on ne peut pas vraiment + * annuler un fetch() en cours), mais du point de vue de l'interface tout + * est arrêté : plus de mise à jour, plus de cache, plus rien. + */ +function showAbortToast() { + showToast("Rafraîchissement", "arrêté"); +} + function renderFromData(data) { state.currentData = data; document.getElementById("loading").classList.add("hidden"); @@ -2008,56 +2182,8 @@ function buildInterventionRow(iv, cardEl) { const contactRaw = i.contact || iv.bulleContact || null; const lieuRaw = i.lieu || iv.bulleLieu || null; - // Extraire tous les contacts (s'il y en a plusieurs séparés par "ou", etc.) - const contacts = extractContacts(contactRaw); - - // Split le lieu : ville / adresse - const { ville, adresse } = splitLieu(lieuRaw); - - // ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ────────── - if (ville || adresse) { - const lieuBlock = document.createElement("div"); - lieuBlock.className = "iv-lieu-block"; - if (ville) { - const villeEl = document.createElement("div"); - villeEl.className = "iv-lieu-ville"; - villeEl.textContent = ville.toUpperCase(); - lieuBlock.appendChild(villeEl); - } - if (adresse) { - const addrEl = document.createElement("div"); - addrEl.className = "iv-lieu-adresse"; - addrEl.textContent = adresse; - lieuBlock.appendChild(addrEl); - } - rightCol.appendChild(lieuBlock); - } - - // ── Contact(s) + téléphone — un par ligne si plusieurs ────────────────── - for (const c of contacts) { - if (!c.name && !c.phone) continue; - const contactEl = document.createElement("div"); - contactEl.className = "iv-contact-line"; - if (c.name) { - const nameSpan = document.createElement("span"); - nameSpan.className = "iv-contact"; - nameSpan.textContent = c.name; - contactEl.appendChild(nameSpan); - } - if (c.phone) { - if (c.name) { - const sep = document.createElement("span"); - sep.className = "iv-sep"; - sep.textContent = " | "; - contactEl.appendChild(sep); - } - const phoneSpan = document.createElement("span"); - phoneSpan.className = "iv-phone"; - phoneSpan.textContent = c.phone; - contactEl.appendChild(phoneSpan); - } - rightCol.appendChild(contactEl); - } + // Rendu initial de lieu + contacts dans rightCol + renderLieuContactBlocks(rightCol, lieuRaw, contactRaw); // ── Bas : Catégorie (à gauche) + Signature planificateur (à droite) ────── const bottomEl = document.createElement("div"); @@ -2576,6 +2702,73 @@ const ALL_COLOR_CLASSES = [ "color-reservation", "color-autre" ]; +/** + * (Re)génère les blocs Lieu et Contact(s) dans le conteneur .iv-right. + * Supprime d'abord les anciens blocs (.iv-lieu-block + .iv-contact-line), + * puis insère les nouveaux AVANT le bloc .iv-bottom-line (si présent) pour + * conserver l'ordre d'affichage. Utilisé à la création ET lors de la + * mise à jour après fetch de la fiche. + */ +function renderLieuContactBlocks(rightCol, lieuRaw, contactRaw) { + // Supprime les anciens blocs lieu/contact + rightCol.querySelectorAll(".iv-lieu-block, .iv-contact-line").forEach(el => el.remove()); + + const contacts = extractContacts(contactRaw); + const { ville, adresse } = splitLieu(lieuRaw); + + // Point d'insertion : avant .iv-bottom-line (catégorie + signature), sinon à la fin + const anchor = rightCol.querySelector(".iv-bottom-line"); + const insert = (el) => { + if (anchor) rightCol.insertBefore(el, anchor); + else rightCol.appendChild(el); + }; + + // ── Lieu : ville (MAJUSCULES GRAS) puis adresse (italique noir) ────────── + if (ville || adresse) { + const lieuBlock = document.createElement("div"); + lieuBlock.className = "iv-lieu-block"; + if (ville) { + const villeEl = document.createElement("div"); + villeEl.className = "iv-lieu-ville"; + villeEl.textContent = ville.toUpperCase(); + lieuBlock.appendChild(villeEl); + } + if (adresse) { + const addrEl = document.createElement("div"); + addrEl.className = "iv-lieu-adresse"; + addrEl.textContent = adresse; + lieuBlock.appendChild(addrEl); + } + insert(lieuBlock); + } + + // ── Contact(s) + téléphone — un par ligne si plusieurs ────────────────── + for (const c of contacts) { + if (!c.name && !c.phone) continue; + const contactEl = document.createElement("div"); + contactEl.className = "iv-contact-line"; + if (c.name) { + const nameSpan = document.createElement("span"); + nameSpan.className = "iv-contact"; + nameSpan.textContent = c.name; + contactEl.appendChild(nameSpan); + } + if (c.phone) { + if (c.name) { + const sep = document.createElement("span"); + sep.className = "iv-sep"; + sep.textContent = " | "; + contactEl.appendChild(sep); + } + const phoneSpan = document.createElement("span"); + phoneSpan.className = "iv-phone"; + phoneSpan.textContent = c.phone; + contactEl.appendChild(phoneSpan); + } + insert(contactEl); + } +} + function updateInterventionRow(iv) { // Réservations : pas concerné (pas de fetch fiche pour elles) if (iv.type === "AL-Reservation") return; @@ -2641,6 +2834,16 @@ function updateInterventionRow(iv) { const catEl = row.querySelector(".iv-category"); if (catEl) catEl.textContent = deriveShortTitle(iv); + // Lieu + Contact(s) : régénérés depuis les valeurs actuelles de iv + // (elles peuvent avoir été mises à jour par le fetch de la fiche). + const rightCol = row.querySelector(".iv-right"); + if (rightCol) { + const info = iv.infobulle || {}; + const contactRaw = info.contact || iv.bulleContact || null; + const lieuRaw = info.lieu || iv.bulleLieu || null; + renderLieuContactBlocks(rightCol, lieuRaw, contactRaw); + } + // Segment timeline correspondant : même couleur + même classe statut const card = row.closest(".card"); if (card && row.dataset.ivIdx !== undefined) {