Expliquer les conflits et mémoriser les choix

JavaScriptBeginner
Pratiquer maintenant

Introduction

Un planning de festival n'est utile que si le visiteur peut réellement assister aux séances choisies. Daylight Festival propose des séances adjacentes compatibles et des séances qui se chevauchent sur différentes scènes. Vous ferez expliquer le véritable conflit lors d'un ajout, puis mémoriserez uniquement les ID pour qu'une actualisation normale restaure les enregistrements du programme actuel.

Vous avez besoin des prérequis React et JavaScript du premier laboratoire et de son modèle de sélection fondé sur les enregistrements actuels et un résumé dérivé. Cette unité démarre indépendamment avec ce comportement préparé : aucune VM, aucun fichier et aucun choix de navigateur antérieur ne sont nécessaires. Vous apprendrez les intervalles semi-ouverts, la vérification de toute la sélection, la récupération JSON, la portée de localStorage et le cycle minimal useEffect/initialisation différée qui évite d'écraser les choix enregistrés. Les 60 minutes sont une estimation de l'auteur incluant lecture et erreurs courantes. Il n'y a ni backend, ni réservation, ni persistance entre appareils.

Utilisez Workspace à /home/labex/project/festival-schedule/work et Festival pour les vrais liens de pages autonomes. Modifiez uniquement src/App.jsx, src/conflicts.js et src/storage.js ; src/selection.js est l'auxiliaire complet préparé du premier laboratoire. Conservez paramètres/catalogue, moteur de rendu/styles, point d'entrée, Study, dépendances et configuration du service. Study possède une autre clé de stockage et ne compte jamais comme votre travail. Les libellés du site restent en anglais.

Refuser les chevauchements réels et autoriser les séances adjacentes

Dans cette étape, chaque ajout prendra en compte tous les choix existants et identifiera une séance réellement incompatible.

Dans Festival, ouvrez Open Study schedule in a new page. Commencez par l'exemple normal : ajoutez Garden First Light, 10:00–10:30 et Studio Paper Signals, 10:30–11:15. Choisissez View my schedule et lisez les deux cartes et les 75 minutes. Essayez ensuite Studio Colour Routes, 10:50–11:10. Un message près de cette séance candidate identifie Paper Signals ; les deux enregistrements sélectionnés et les 75 minutes restent inchangés. Le moteur de rendu préparé garde le message visible, y compris sur écran étroit. Il s'agit de l'exemple fourni, pas de votre implémentation.

Fermez Study et revenez à l'entrée Festival du produit. Ouvrez Open Work schedule in a new page et laissez la page ouverte. Les auxiliaires de sélection sont préparés, mais la fonction Add initiale ne refuse pas les chevauchements et les choix restent en mémoire. Passez à Workspace pour modifier les fichiers. Le dossier initial est /home/labex/project/festival-schedule/work ; utilisez les commandes cd, pwd et cat du premier laboratoire si vous devez confirmer un chemin. Ne réinstallez pas les paquets à cause d'une faute de nom.

Représentez une séance par un intervalle semi-ouvert [start, end). Son début est inclus et sa fin est libre pour la séance suivante. Tous les horaires sont des minutes entières sur une même date locale explicitée. Par exemple, 10:30 vaut 630. Garden First Light est [600, 630) et Paper Signals est [630, 675) : l'égalité en 630 est autorisée. Colour Routes [650, 670) chevauche Paper Signals, mais pas la première séance choisie. Ne vérifier que le premier choix serait incorrect.

La condition de chevauchement est candidate.start < session.end && session.start < candidate.end. Les deux comparaisons strictes doivent être vraies ; remplacer < par <= refuserait à tort l'adjacence. find parcourt les enregistrements sélectionnés jusqu'à trouver un conflit et renvoie cet objet réel, ou undefined s'il n'y en a pas. En présence de plusieurs conflits, il peut renvoyer n'importe lequel des conflits réels ; le message doit l'identifier honnêtement.

Enregistrez l'auxiliaire de conflit avec le here-document entre guillemets du premier laboratoire. Le JS final est seul sur sa ligne et > remplace le fichier indiqué :

cat > src/conflicts.js <<'JS'
export function findConflict(candidate, selected) {
  return selected.find(session =>
    candidate.start < session.end && session.start < candidate.end
  );
}
JS

Reliez-le maintenant avant la mise à jour de l'état des ID. Le composant ci-dessous importe l'auxiliaire que vous venez d'écrire. Il retrouve les enregistrements à partir de toute la sélection actuelle, pas du programme filtré par scène. Un refus définit le message de la carte cliquée puis retourne sans changer les ID ni les totaux. Le message utilise titre, scène et horaire pour distinguer les deux First Light. Une formulation différente mais claire est valable ; la relation importante associe la séance candidate à un véritable conflit sélectionné. Les autres états et auxiliaires dérivés suivent le laboratoire précédent.

cat > src/App.jsx <<'JS'
import React, {useState} from 'react';
import View, {clock} from '../../shared/View.jsx';
import {findConflict} from './conflicts.js';
import {catalog, settings} from './settings.js';
import {currentRecords, totalMinutes, visibleRecords, missingNotice} from './selection.js';

const describe = session => session.title + ' (' + session.stage + ', ' + clock(session.start) + '-' + clock(session.end) + ')';

export default function App() {
  const [selectedIDs, setSelectedIDs] = useState(settings.initialIDs);
  const [stage, setStage] = useState('all');
  const [feedback, setFeedback] = useState({sessionID: null, text: ''});
  const selected = currentRecords(selectedIDs, catalog);
  const visible = visibleRecords(catalog, stage);
  const minutes = totalMinutes(selected);

  function add(id) {
    const session = catalog.find(item => item.id === id);
    if (!session || selectedIDs.includes(id)) return;
    const conflict = findConflict(session, selected);
    if (conflict) {
      setFeedback({sessionID: id, text: 'Cannot add ' + describe(session) + '. It overlaps ' + describe(conflict) + '.'});
      return;
    }
    setSelectedIDs(ids => [...ids, id]);
    setFeedback({sessionID: id, text: 'Added ' + describe(session) + '.'});
  }

  function remove(id) {
    setSelectedIDs(ids => ids.filter(existing => existing !== id));
    setFeedback({sessionID: null, text: ''});
  }

  function changeStage(value) {
    setStage(value);
    setFeedback({sessionID: null, text: ''});
  }

  return <View settings={settings} visible={visible} selected={selected}
    count={selected.length} minutes={minutes} selectedIDs={selectedIDs}
    stage={stage} onStage={changeStage} onAdd={add} onRemove={remove}
    feedback={feedback} missingNotice={missingNotice(selectedIDs, catalog)} />;
}
JS

Revenez à Work et actualisez normalement. Le initialIDs indépendant de cette unité est vide. Ajoutez Garden First Light et Paper Signals pour obtenir 75 minutes. Essayez Colour Routes : Paper Signals est le deuxième objet choisi et le refus doit conserver les 75 minutes. Choisissez Stage: Loft, puis essayez First Light, 10:15–10:45. La véritable séance incompatible Garden First Light est masquée du programme par le filtre, mais reste dans My schedule. Le nouveau refus doit identifier cette séance Garden et conserver les deux mêmes choix. Un filtre ne doit pas affaiblir la règle de conflit.

Remettez Stage sur All stages. Ajoutez Garden Small Stages, 11:15–11:45 : cette séance est adjacente à Paper Signals et porte le total à 105 minutes. Depuis View my schedule, retirez Paper Signals. Garden First Light et Small Stages totalisent 60 minutes. Ajoutez de nouveau Colour Routes, 10:50–11:10 : elle convient maintenant, soit 80 minutes dans l'ordre First Light, Colour Routes, Small Stages. La Loft First Light de 10:15 est toujours incompatible avec Garden First Light ; ne l'utilisez pas comme ajout devenu valide après le retrait du conflit.

Sur écran étroit, la navigation de la journée donne accès à My schedule sans parcourir tout le programme, et le refus apparaît près de la carte cliquée. Utilisez réellement Tab/Enter sur une séance candidate et vérifiez que son message est lisible sans déplacer le focus vers une région de statut. En cas de faute de code, inspectez src/conflicts.js et src/App.jsx, rétablissez le bloc complet concerné et actualisez. Cette étape garde encore les choix en mémoire ; une actualisation peut les réinitialiser avant l'étape 2. Lancez la vérification des conflits.

Restaurer les ID avant de synchroniser le stockage

Dans cette étape, restaurez une liste d'ID enregistrée avant de l'écrire, puis récupérez les données sauvegardées inutilisables ou périmées.

Commencez dans Workspace. localStorage est le stockage du navigateur pour une origine : protocole, hôte et port. Les chemins de même origine partagent cet espace ; utilisez donc une clé propre au cours et à l'unité, settings.storageKey, plutôt qu'une clé générique data. Study possède une autre clé. N'appelez pas clear() et ne supprimez pas d'autres clés. Une URL de nouvelle VM, un contexte de navigateur vierge, un autre navigateur ou appareil ne possèdent pas nécessairement ces choix. La politique du navigateur peut refuser le stockage. Il s'agit d'une commodité pour cette page et ce navigateur, pas d'une réservation, d'une sauvegarde ni d'une promesse de conservation permanente.

Les valeurs stockées sont des chaînes. JSON.stringify(ids) transforme le tableau d'ID en chaîne JSON ; JSON.parse(raw) la relit. Enregistrez uniquement des ID comme ["D11","D12"], pas des copies de titres et horaires susceptibles de devenir obsolètes. Retrouvez les ID restaurés dans le catalogue actuel, puis calculez les mêmes cartes, nombre et minutes qu'avant. L'ordre du tableau enregistré n'impose pas l'ordre d'affichage : My schedule utilise le début actuel et l'ID.

Les règles publiques de récupération sont les suivantes :

  • Une clé absente utilise le settings.initialIDs préparé de cette unité. Ici, ce tableau est vide. Le défi indépendant possède ses propres ID initiaux explicites.
  • Un JSON mal formé, une valeur qui n'est pas un tableau ou un tableau contenant un élément non textuel donne un planning vide utilisable et un avis lisible. Un tableau vide est valide.
  • Les ID textuels répétés ne dupliquent pas les cartes. Les ID absents du catalogue sont omis avec un avis ; les choix actuels valides et compatibles restent avec leurs détails à jour.
  • Si la liste enregistrée contient elle-même des conflits, traitez-la dans son ordre d'entrée enregistré. Gardez les premiers choix compatibles selon cet ordre et expliquez que des choix qui se chevauchent ont été ignorés. C'est la même règle d'admission, pas une optimisation du planning.
  • Si la lecture est refusée, permettez une visite en mémoire avec un avis. Si l'écriture échoue, gardez les choix actuels et indiquez qu'ils n'ont pas pu être enregistrés. Dans cette situation, la restauration après actualisation n'est pas promise.

L'auxiliaire suivant introduit quelques outils JavaScript. try exécute du code susceptible de lever une erreur ; catch propose une récupération lisible au lieu de laisser planter la page. raw === null identifie une clé absente. Array.isArray et typeof valident la forme publique de la liste d'ID ; throw new Error(...) dirige une forme invalide vers la même récupération. for (const id of saved) visite les ID dans l'ordre, continue en ignore un et push ajoute à de nouveaux tableaux locaux. Ces tableaux temporaires ne sont pas de l'état React. join(' ') rassemble les phrases d'avis applicables. Lire cet auxiliaire n'écrit ni ne supprime de stockage.

cat > src/storage.js <<'JS'
import {catalog, settings} from './settings.js';
import {findConflict} from './conflicts.js';

export function readSaved() {
  let raw;
  try {
    raw = localStorage.getItem(settings.storageKey);
  } catch {
    return {ids: [], notice: 'Saved choices are unavailable. You can plan in memory for this visit.'};
  }
  try {
    const saved = raw === null ? settings.initialIDs : JSON.parse(raw);
    if (!Array.isArray(saved) || saved.some(id => typeof id !== 'string')) {
      throw new Error('Expected a list of session IDs');
    }
    const ids = [];
    const records = [];
    let missing = false;
    let conflicting = false;
    for (const id of saved) {
      if (ids.includes(id)) continue;
      const session = catalog.find(item => item.id === id);
      if (!session) {missing = true; continue;}
      if (findConflict(session, records)) {conflicting = true; continue;}
      ids.push(id);
      records.push(session);
    }
    const notices = [];
    if (missing) notices.push('Some saved sessions are no longer listed. Your available choices are kept.');
    if (conflicting) notices.push('Some saved choices overlapped. The earlier compatible choices are kept.');
    return {ids, notice: notices.join(' ')};
  } catch {
    return {ids: [], notice: 'Your saved list could not be read. Start a new schedule here.'};
  }
}

export function writeSaved(ids) {
  try {
    localStorage.setItem(settings.storageKey, JSON.stringify(ids));
    return true;
  } catch {
    return false;
  }
}
JS

Restaurez maintenant l'état initial en sécurité. useState(() => readSaved()) passe une fonction comme initialiseur différé : React l'appelle à l'initialisation du composant. Elle renvoie {ids, notice} sans écrire de stockage, changer une autre clé ni charger des données distantes. Gardez cet instantané dans initial, initialisez les ID sélectionnés avec initial.ids et conservez son avis comme message. StrictMode en développement peut appeler plusieurs fois un initialiseur pour détecter les problèmes ; un initialiseur en lecture seule peut être répété sans danger. Une autre implémentation avec une barrière explicite de restauration est aussi valide, si elle n'écrit pas une liste vide avant la restauration.

useEffect est importé de React et synchronise le stockage externe après l'application d'un rendu. La liste de dépendances [selectedIDs] demande de recommencer cette synchronisation quand le tableau change. StrictMode en développement peut exécuter un cycle d'effet supplémentaire ; écrire la même liste d'ID est idempotent : le répéter laisse le même résultat enregistré sans créer un choix en double. Sélection, tri, totaux et décisions de conflit restent du travail normal de rendu ou de gestionnaire, pas des effets. Partir d'une liste vide, l'écrire immédiatement puis charger les ID effacerait un véritable planning enregistré.

Remplacez le composant. readSaved et writeSaved viennent de l'auxiliaire que vous venez d'enregistrer ; les autres imports ont été utilisés à l'étape 1. remembering indique à la vue préparée que cette version tente une persistance dans le navigateur, tandis qu'une écriture échouée affiche toujours le message explicite de visite en mémoire.

cat > src/App.jsx <<'JS'
import React, {useState, useEffect} from 'react';
import View, {clock} from '../../shared/View.jsx';
import {readSaved, writeSaved} from './storage.js';
import {findConflict} from './conflicts.js';
import {catalog, settings} from './settings.js';
import {currentRecords, totalMinutes, visibleRecords, missingNotice} from './selection.js';

const describe = session => session.title + ' (' + session.stage + ', ' + clock(session.start) + '-' + clock(session.end) + ')';

export default function App() {
  const [initial] = useState(() => readSaved());
  const [selectedIDs, setSelectedIDs] = useState(initial.ids);
  const [stage, setStage] = useState('all');
  const [feedback, setFeedback] = useState({sessionID: null, text: initial.notice});
  const selected = currentRecords(selectedIDs, catalog);
  const visible = visibleRecords(catalog, stage);
  const minutes = totalMinutes(selected);

  useEffect(() => {
    if (!writeSaved(selectedIDs)) {
      setFeedback({sessionID: null, text: 'These choices work for this visit, but your browser could not save them.'});
    }
  }, [selectedIDs]);

  function add(id) {
    const session = catalog.find(item => item.id === id);
    if (!session || selectedIDs.includes(id)) return;
    const conflict = findConflict(session, selected);
    if (conflict) {
      setFeedback({sessionID: id, text: 'Cannot add ' + describe(session) + '. It overlaps ' + describe(conflict) + '.'});
      return;
    }
    setSelectedIDs(ids => [...ids, id]);
    setFeedback({sessionID: id, text: 'Added ' + describe(session) + '.'});
  }

  function remove(id) {
    setSelectedIDs(ids => ids.filter(existing => existing !== id));
    setFeedback({sessionID: null, text: ''});
  }

  function changeStage(value) {
    setStage(value);
    setFeedback({sessionID: null, text: ''});
  }

  return <View settings={settings} visible={visible} selected={selected}
    count={selected.length} minutes={minutes} selectedIDs={selectedIDs}
    stage={stage} onStage={changeStage} onAdd={add} onRemove={remove}
    feedback={feedback} missingNotice={missingNotice(selectedIDs, catalog)} remembering={true} />;
}
JS

Revenez à Work et actualisez normalement. La première actualisation après ce changement peut donner un planning vide : l'étape 1 n'a jamais enregistré ses choix en mémoire. Ajoutez de nouveau Garden First Light et Paper Signals, puis actualisez exactement la même URL dans la même page du navigateur. Les deux enregistrements actuels et les 75 minutes doivent revenir. Un contexte indépendant vierge doit repartir de la sélection initiale de cette unité ; n'affirmez pas que cette sélection privée est partagée par une URL.

Utilisez ensuite la page d'entraînement contrôlé distincte. Choisissez Practice recovery dans le pied de page ; elle remplace Work dans le même onglet. Lisez son avertissement : chaque exercice remplace uniquement la clé de choix de cette unité, et remplacera donc les choix de test actuels. Choisissez Load unreadable JSON, puis Return to Work. Le composant normal doit afficher un planning vide utilisable et un avis lisible de récupération. Ajoutez la Loft First Light de 12:00–12:30 et actualisez normalement : sa carte actuelle et ses 30 minutes reviennent. Cela prouve qu'une récupération peut être suivie d'un usage normal, pas seulement d'un message d'erreur.

Revenez à Practice recovery, choisissez Load retired IDs, puis Return to Work. Le préréglage visible contient D11, un ID retiré et D12. Les deux enregistrements disponibles restent, totalisant 75 minutes, avec une explication du choix ancien omis. Une fois qu'une liste propre remplace cette entrée ancienne, une actualisation ultérieure n'a pas à répéter le même avis. Utilisez enfin Load conflicting IDs : son préréglage est D11, D13, D12. D13 chevauche D11, qui le précède dans l'entrée enregistrée ; la restauration garde donc D11 et D12 avec un avis, et non les trois séances ou un planning vide. La page Practice est explicitement un entraînement, pas une remise à zéro invisible dans le parcours du visiteur.

Revenez à Work et confirmez qu'Add, le filtrage et Remove agissent toujours sur les choix actuels récupérés. Testez l'adjacence valide de Small Stages et le refus de Colour Routes comme à l'étape 1. Si une vraie politique du navigateur bloque le stockage, gardez le message explicite de visite en mémoire, utilisez la page pour cette visite et ouvrez un contexte autorisé si vous avez besoin de persistance après actualisation. N'effacez pas les données du navigateur sans rapport pour forcer la réussite. Lancez la vérification finale quand ces parcours normaux et de récupération fonctionnent.

Après la séquence de retrait puis d'ajout, les cartes restaurées First Light, Colour Routes et Small Stages totalisent 80 minutes. Comparez leurs détails actuels et les états d'Add :

Choix actuels restaurés et résumé dérivé

Sur écran étroit, le refus de la séance candidate précoce de Loft identifie la séance Garden déjà sélectionnée près du bouton ; la navigation du planning reste accessible au-dessus.

Refus lisible près de la séance candidate sur écran étroit

Résumé

Vous avez construit une règle de participation fidèle : chaque enregistrement actuel sélectionné participe aux vérifications de chevauchement semi-ouvert, l'adjacence est autorisée et un retrait peut rendre une séance candidate compatible. Vous avez ensuite restauré des données du navigateur limitées à une clé et composées uniquement d'ID avant de synchroniser les écritures, tout en conservant les relations actuelles et en récupérant les choix enregistrés illisibles, absents ou incompatibles.

La restauration est limitée à la même origine du navigateur et à un environnement de cours existant autorisant le stockage. Le défi indépendant change les données et relations du programme avec ces mêmes mécanismes.