Explicar los conflictos y recordar las elecciones

JavaScriptBeginner
Practicar Ahora

Introducción

Una agenda de festival solo es útil si el visitante puede asistir realmente a las sesiones elegidas. Daylight Festival tiene sesiones adyacentes compatibles y sesiones solapadas en escenarios distintos que no lo son. Harás que las adiciones expliquen el conflicto real y después recordarás únicamente ID de sesión para que una actualización normal restaure registros del programa actual.

Necesitas los conocimientos previos de React y JavaScript del primer laboratorio y su patrón de selección con registros actuales y resumen derivado. Esta unidad empieza de forma independiente con ese comportamiento preparado, sin necesitar una VM, archivos ni elecciones del navegador anteriores. Aprenderás intervalos semiabiertos, comprobación de toda la selección, recuperación de JSON, alcance de localStorage y el ciclo mínimo de useEffect e inicialización diferida necesario para no sobrescribir elecciones guardadas. Sus 60 minutos son una estimación del autor que incluye lectura y errores normales. No intervienen un backend, reservas ni persistencia entre dispositivos.

Usa Workspace en /home/labex/project/festival-schedule/work y Festival para los enlaces a páginas independientes reales. Edita solo src/App.jsx, src/conflicts.js y src/storage.js; src/selection.js es el auxiliar completo preparado a partir del primer laboratorio. Conserva configuración y catálogo, renderizador y estilos, punto de entrada, Study, dependencias y configuración del servicio. Study utiliza una clave de almacenamiento distinta y nunca cuenta como tu trabajo. Las etiquetas del sitio permanecen en inglés.

Rechazar solapamientos reales y permitir sesiones adyacentes

En este paso harás que cada adición considere todas las elecciones existentes e identifique una sesión realmente incompatible.

En Festival, abre Open Study schedule in a new page. Usa primero el ejemplo normal: añade Garden First Light, 10:00–10:30 y Studio Paper Signals, 10:30–11:15. Elige View my schedule y lee las dos tarjetas y los 75 minutos. Después prueba Studio Colour Routes, 10:50–11:10. Un mensaje junto a la candidata identifica Paper Signals; los dos registros seleccionados y los 75 minutos no cambian. El renderizador preparado mantiene visible el mensaje de la tarjeta, incluso en pantallas estrechas. Es el ejemplo proporcionado, no tu implementación.

Cierra Study y vuelve a la entrada Festival del producto. Abre Open Work schedule in a new page y déjala abierta. Las funciones de selección ya están preparadas, pero la función Add inicial no rechaza solapamientos y las elecciones solo están en memoria. Cambia a Workspace para editar. Su directorio inicial es /home/labex/project/festival-schedule/work; usa los comandos cd, pwd y cat del primer laboratorio si necesitas confirmar una ruta. No reinstales paquetes por un nombre de archivo mal escrito.

Representa una sesión como intervalo semiabierto [start, end). Incluye su inicio y deja libre el final para la siguiente sesión. Todos los horarios son minutos enteros de una misma fecha local declarada. Por ejemplo, 10:30 equivale a 630. Garden First Light es [600, 630) y Paper Signals es [630, 675): la igualdad en 630 es válida. Colour Routes [650, 670) solapa Paper Signals, pero no la primera sesión elegida. Comprobar solo la primera elección sería incorrecto.

La condición de solapamiento es candidate.start < session.end && session.start < candidate.end. Ambas comparaciones estrictas deben cumplirse; sustituir < por <= rechazaría incorrectamente la adyacencia. find recorre los registros seleccionados hasta encontrar un conflicto y devuelve ese objeto real, o undefined si no existe. Puede devolver cualquier conflicto real cuando hay varios; el mensaje debe identificarlo de forma veraz.

Guarda la función de conflictos con el método de here-document entre comillas del primer laboratorio. El JS final va solo en su línea y > reemplaza el archivo indicado:

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

Conéctala ahora antes de actualizar el estado de ID. El componente siguiente importa la función que acabas de escribir. Resuelve los registros elegidos desde toda la selección actual, no desde el programa filtrado por escenario. Un rechazo establece el mensaje de la candidata pulsada y retorna sin cambiar ID ni totales. El mensaje usa título, escenario y horario para no confundir las dos First Light. Otra redacción clara es válida; lo importante es la relación entre la candidata y un conflicto seleccionado real. El resto del estado y los auxiliares derivados siguen el laboratorio anterior.

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

Vuelve a Work y actualiza normalmente. El initialIDs independiente de esta unidad está vacío. Añade Garden First Light y Paper Signals para obtener 75 minutos. Prueba Colour Routes: Paper Signals es el segundo objeto seleccionado y el rechazo debe mantener los 75 minutos. Elige Stage: Loft y prueba First Light, 10:15–10:45. Garden First Light, el conflicto real, está oculta del programa por el filtro, pero sigue en My schedule. El nuevo rechazo debe identificar esa sesión de Garden y conservar las mismas dos elecciones. Un filtro no debe debilitar la regla de conflicto.

Devuelve Stage a All stages. Añade Garden Small Stages, 11:15–11:45: es adyacente a Paper Signals y el total pasa a 105 minutos. Desde View my schedule, elimina Paper Signals. Garden First Light y Small Stages suman 60. Añade de nuevo Colour Routes, 10:50–11:10: ahora encaja, dando 80 minutos en el orden First Light, Colour Routes, Small Stages. La Loft First Light de las 10:15 sigue solapando Garden First Light; no la uses como la adición que ya resulta válida tras eliminar un conflicto.

En una pantalla estrecha, la navegación del día ofrece My schedule sin tener que recorrer todo el programa y el rechazo aparece junto a la tarjeta pulsada. Usa realmente Tab/Enter sobre una candidata y comprueba que el mensaje sea legible sin mover el foco a una región de estado. Si has cometido un error de código, inspecciona src/conflicts.js y src/App.jsx, restaura el bloque completo correspondiente y actualiza. Este paso sigue manteniendo elecciones en memoria; una actualización puede restablecerlas hasta el paso 2. Ejecuta la comprobación de conflictos.

Restaurar los ID antes de sincronizar el almacenamiento

En este paso restaurarás una lista de ID guardada antes de escribir y te recuperarás de datos guardados inutilizables u obsoletos.

Empieza en Workspace. localStorage es almacenamiento del navegador para un origen: esquema, host y puerto. Las rutas del mismo origen comparten su área, por lo que debes usar una clave específica del curso y la unidad, settings.storageKey, en lugar de una clave genérica data. Study tiene una clave diferente. No llames a clear() ni elimines otras claves. Una URL de una VM nueva, un contexto de navegador limpio, otro navegador u otro dispositivo no tienen por qué contener estas elecciones. La política del navegador puede denegar el almacenamiento. Es una comodidad para la misma página y navegador, no una reserva, copia de seguridad ni promesa de conservación permanente.

Los valores almacenados son cadenas. JSON.stringify(ids) convierte el array de ID en una cadena JSON; JSON.parse(raw) la lee. Guarda únicamente ID como ["D11","D12"], no copias de títulos y horarios que pueden quedar obsoletas. Resuelve los ID restaurados contra el catálogo actual y deriva las mismas tarjetas, cantidad y minutos que antes. El orden del array guardado no es un requisito de orden visual: My schedule usa el inicio actual y el ID.

Las reglas públicas de recuperación son:

  • Si falta la clave, usa el settings.initialIDs preparado de esta unidad. Aquí está vacío. El desafío independiente tiene sus propios ID iniciales explícitos.
  • Un JSON mal formado, un valor que no sea un array o un array con algún elemento no textual producen una agenda vacía utilizable y un aviso legible. Un array vacío es válido.
  • Los ID de cadena repetidos no duplican tarjetas. Los ID ausentes del catálogo se omiten con un aviso; permanecen las elecciones actuales válidas y compatibles, con sus detalles actuales.
  • Si la propia lista guardada contiene conflictos, procésala en su orden de entrada guardado. Conserva las primeras elecciones compatibles según ese orden y explica que se omitieron elecciones solapadas. Es la misma regla de admisión, no una optimización de horarios.
  • Si se deniega la lectura, permite una visita en memoria con un aviso. Si falla la escritura, conserva las elecciones actuales e indica que no se pudieron guardar. En esa situación no se promete restauración al actualizar.

El auxiliar siguiente introduce algunas herramientas JavaScript. try ejecuta código que puede lanzar un error; catch proporciona una recuperación legible en lugar de dejar que la página falle. raw === null identifica una clave ausente. Array.isArray y typeof validan la forma pública de la lista de ID; throw new Error(...) envía una forma inválida a la misma recuperación. for (const id of saved) visita los ID en orden, continue omite uno y push añade elementos a arrays locales nuevos. Estos arrays temporales no son estado React. join(' ') combina las frases de aviso aplicables. Leer este auxiliar no escribe ni elimina almacenamiento.

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

Restaura ahora el estado inicial con seguridad. useState(() => readSaved()) pasa una función como inicializador diferido: React la llama al inicializar este componente. Devuelve {ids, notice} sin escribir almacenamiento, cambiar otra clave ni obtener datos remotos. Conserva esa instantánea en initial, inicializa los ID elegidos desde initial.ids y conserva su aviso como mensaje. StrictMode de desarrollo puede llamar más de una vez al inicializador para detectar problemas; uno de solo lectura se puede repetir con seguridad. También es válida otra implementación con una barrera explícita de restauración, siempre que no escriba una lista vacía antes de restaurar.

useEffect se importa de React y sincroniza el almacenamiento externo después de que se confirme un renderizado. La lista de dependencias [selectedIDs] pide repetir la sincronización cuando cambie ese array. StrictMode de desarrollo puede ejecutar un ciclo de efecto adicional; escribir la misma lista de ID es idempotente: repetirlo deja el mismo resultado guardado y no crea otra elección. La selección, ordenación, cantidades y decisiones de conflicto siguen siendo trabajo normal de renderizado o de los manejadores, no efectos. Empezar vacío, escribir vacío inmediatamente y cargar los ID después borraría una agenda real guardada.

Reemplaza el componente. readSaved y writeSaved proceden del auxiliar que acabas de guardar; las demás importaciones se usaron en el paso 1. remembering indica a la vista preparada que esta versión intenta persistencia en el navegador, mientras que una escritura fallida sigue mostrando un aviso explícito de uso solo en memoria.

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

Vuelve a Work y actualiza normalmente. La primera actualización tras este cambio puede dejar la agenda vacía: el paso 1 nunca guardó sus elecciones en memoria. Añade otra vez Garden First Light y Paper Signals y actualiza exactamente la misma URL en la misma página del navegador. Deben volver ambos registros actuales y los 75 minutos. Un contexto independiente limpio debe empezar con la selección inicial de esta unidad; no afirmes que esta agenda privada se comparte mediante una URL.

Usa después la página de entrenamiento controlado independiente. Elige Practice recovery en el pie; sustituye esta página Work en la misma pestaña. Lee la advertencia: cada ejercicio reemplaza solo la clave de elecciones de esta unidad, así que sustituirá las elecciones de prueba actuales. Elige Load unreadable JSON y después Return to Work. El componente normal debe mostrar una agenda vacía utilizable y un aviso de recuperación legible. Añade la Loft First Light de 12:00–12:30 y actualiza normalmente: vuelven su tarjeta actual y 30 minutos. Esto demuestra que después de recuperarse se puede usar normalmente, no solo mostrar un error.

Vuelve a Practice recovery, elige Load retired IDs y después Return to Work. Su preajuste visible contiene D11, un ID retirado y D12. Permanecen los dos registros disponibles, sumando 75 minutos, con una explicación de la elección antigua omitida. Una vez que una lista limpia sustituya esa entrada antigua, otra actualización no tiene que repetir el aviso. Usa finalmente Load conflicting IDs: su preajuste es D11, D13, D12. D13 solapa D11, que aparece antes en la entrada guardada, por lo que el resultado conserva D11 y D12 con un aviso, no las tres sesiones ni una agenda vacía. La página Practice es entrenamiento explícito, no un reinicio invisible del flujo del visitante.

Vuelve a Work y confirma que Add, el filtro y Remove siguen actuando sobre las elecciones actuales recuperadas. Prueba la adyacencia válida de Small Stages y el rechazo de Colour Routes como en el paso 1. Si una política real del navegador bloquea el almacenamiento, conserva el mensaje explícito de uso solo en memoria, utiliza la página durante esta visita y abre un contexto permitido si necesitas persistencia tras actualizar. No borres datos del navegador ajenos para forzar el éxito. Ejecuta la comprobación final cuando funcionen estos recorridos normales y de recuperación.

Tras la secuencia de eliminación y nueva adición, las tarjetas restauradas First Light, Colour Routes y Small Stages suman 80 minutos. Compara sus detalles actuales y los estados de Add:

Elecciones actuales restauradas y su resumen derivado

En una pantalla estrecha, el rechazo de la candidata temprana de Loft identifica la sesión de Garden ya elegida junto a su botón; la navegación de la agenda sigue accesible encima.

Rechazo legible junto a la candidata en pantalla estrecha

Resumen

Has construido una regla de asistencia veraz: cada registro actual seleccionado participa en las comprobaciones de solapamiento semiabierto, la adyacencia es válida y eliminar una elección puede hacer compatible una candidata. Después has restaurado datos del navegador limitados a una clave y compuestos solo por ID antes de sincronizar las escrituras, conservando las relaciones con registros actuales y recuperándote de elecciones guardadas ilegibles, ausentes o incompatibles.

La restauración se limita al mismo origen del navegador y a un entorno del curso que siga existiendo y permita almacenamiento. El desafío independiente cambia los datos y relaciones del programa mediante estos mismos mecanismos.