Объяснять конфликты и запоминать выбор

JavaScriptBeginner
Практиковаться сейчас

Введение

Расписание фестиваля полезно, только если посетитель действительно может попасть на выбранные сессии. В Daylight Festival есть совместимые соседние сессии и пересекающиеся по времени события на разных сценах. Вы научите добавление объяснять реальный конфликт, а затем сохранять только ID, чтобы обычное обновление восстанавливало записи текущей программы.

Нужны знания React и JavaScript из первой работы и её подход к выбору с текущими записями и производной сводкой. Эта часть запускается независимо с подготовленной логикой выбора: предыдущие VM, файлы и браузерные выборы не нужны. Вы изучите полуоткрытые интервалы, проверку всех выбранных записей, восстановление JSON, область localStorage и минимальный жизненный цикл useEffect и ленивой инициализации, предотвращающий перезапись сохранённого выбора. 60 минут — оценка автора с учётом чтения и обычных ошибок. Backend, бронирование и хранение между устройствами не используются.

Используйте Workspace в /home/labex/project/festival-schedule/work и Festival для настоящих ссылок на отдельные страницы. Редактируйте только src/App.jsx, src/conflicts.js и src/storage.js; src/selection.js — подготовленная завершённая функция выбора из первой работы. Сохраните настройки/каталог, рендерер/стили, точку входа, Study, зависимости и конфигурацию сервиса. Study использует другой ключ хранилища; пример никогда не считается вашей работой. Подписи сайта остаются английскими.

Отклонять реальные пересечения и разрешать соседние сессии

В этом шаге каждое добавление будет учитывать все существующие выборы и указывать реально конфликтующую сессию.

В Festival откройте Open Study schedule in a new page. Начните с нормального примера: добавьте Garden First Light, 10:00–10:30 и Studio Paper Signals, 10:30–11:15. Выберите View my schedule и прочитайте две карточки и 75 минут. Затем попробуйте Studio Colour Routes, 10:50–11:10. Сообщение рядом с кандидатом укажет Paper Signals; две выбранные записи и 75 минут останутся прежними. Подготовленный рендерер держит сообщение видимым, в том числе на узком экране. Это предоставленный пример, не ваша реализация.

Закройте Study и вернитесь ко входу Festival продукта. Откройте Open Work schedule in a new page и оставьте страницу открытой. Выбор уже подготовлен, но начальная функция Add не отклоняет пересечения и хранит выбор только в памяти. Переключитесь в Workspace для редактирования. Начальный каталог — /home/labex/project/festival-schedule/work; при необходимости проверьте путь изученными cd, pwd и cat. Не переустанавливайте пакеты из-за ошибки имени файла.

Представьте сессию полуоткрытым интервалом [start, end): начало включено, конец свободен для следующей сессии. Все времена — целые минуты одной указанной местной даты. Например, 10:30 — это 630. Garden First Light занимает [600, 630), Paper Signals — [630, 675): равенство на 630 допустимо. Colour Routes [650, 670) пересекается с Paper Signals, но не с первым выбором. Проверять только первый элемент неверно.

Условие пересечения — candidate.start < session.end && session.start < candidate.end. Оба строгих сравнения должны выполняться; замена < на <= ошибочно запретит соседние сессии. find проверяет выбранные записи, пока не найдёт конфликт, и возвращает реальный объект либо undefined, если конфликта нет. При нескольких конфликтах допустимо вернуть любой настоящий; сообщение должно честно его назвать.

Сохраните функцию конфликтов через here-document с кавычками из первой работы. Конечный JS стоит отдельно, а > заменяет указанный файл:

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

Подключите проверку до изменения состояния ID. Компонент ниже импортирует только что созданную функцию. Он получает выбранные записи из всей текущей выборки, не из программы, отфильтрованной по сцене. Отклонение задаёт сообщение нажатому кандидату и возвращает управление без изменения ID и итогов. Название, сцена и время позволяют не путать две First Light. Другая понятная формулировка допустима; важна связь кандидата с реальным выбранным конфликтом. Остальные состояния и производные функции следуют предыдущей работе.

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

Вернитесь в Work и обычно обновите страницу. Независимый initialIDs этой части пуст. Добавьте Garden First Light и Paper Signals, получив 75 минут. Попробуйте Colour Routes: Paper Signals — второй выбранный объект, после отклонения должны остаться 75 минут. Выберите Stage: Loft, затем First Light, 10:15–10:45. Реальный конфликт Garden First Light скрыт фильтром из программы, но остаётся в My schedule. Новый отказ должен указать именно эту Garden-сессию и сохранить те же два выбора. Фильтр не должен ослаблять правило конфликтов.

Верните Stage в All stages. Добавьте Garden Small Stages, 11:15–11:45: она смежна с Paper Signals, поэтому сумма станет 105 минут. Через View my schedule удалите Paper Signals. Garden First Light и Small Stages дают 60. Снова добавьте Colour Routes, 10:50–11:10: теперь она подходит, давая 80 минут в порядке First Light, Colour Routes, Small Stages. Одноимённая Loft First Light в 10:15 всё ещё пересекается с Garden First Light; не используйте её как успешное добавление после удаления конфликта.

На узком экране навигация дня открывает My schedule без долгого поиска по программе, а отказ виден рядом с нажатой карточкой. Реально используйте Tab/Enter для кандидата и убедитесь, что сообщение читаемо без перевода фокуса в область статуса. При ошибке кода проверьте сохранённые src/conflicts.js и src/App.jsx, восстановите соответствующий полный блок и обновите. Пока выбор только в памяти; до шага 2 обновление может его сбросить. Запустите проверку конфликтов.

Восстановить ID до синхронизации хранилища

В этом шаге восстановите сохранённый список ID перед записью и обработайте непригодные или устаревшие данные.

Начните в Workspace. localStorage — хранилище браузера для одного источника: схемы, хоста и порта. Пути одного источника используют общую область, поэтому выбирайте ключ курса и части settings.storageKey, а не общий data. Study имеет отдельный ключ. Не вызывайте clear() и не удаляйте другие ключи. URL новой VM, чистый браузерный контекст, другой браузер или устройство не обязаны содержать эти выборы. Политика браузера может отказать в доступе. Это удобство для той же страницы и браузера, а не бронь, резервная копия или обещание вечного хранения.

Значения хранилища — строки. JSON.stringify(ids) превращает массив ID в строку JSON; JSON.parse(raw) читает её обратно. Сохраняйте лишь ID вроде ["D11","D12"], не копии названий и времени, которые могут устареть. Сопоставляйте восстановленные ID с текущим каталогом и получайте те же карточки, количество и минуты. Порядок сохранённого массива не задаёт порядок отображения: My schedule использует текущее начало и ID.

Публичные правила восстановления:

  • Отсутствующий ключ использует подготовленный settings.initialIDs этой части. Здесь массив пуст. У отдельного испытания есть собственные явно заданные начальные ID.
  • Повреждённый JSON, значение не в форме массива или массив с нестроковым элементом дают пустое пригодное расписание с понятным сообщением. Пустой массив допустим.
  • Повторяющиеся строковые ID не создают дубликаты. Отсутствующие в каталоге ID пропускаются с сообщением; текущие допустимые совместимые выборы сохраняют актуальные сведения.
  • Если сам сохранённый список конфликтует, обрабатывайте его в исходном сохранённом порядке. Оставляйте первые совместимые выборы в этом порядке и объясняйте пропуск пересечений. Это прежнее правило допуска, не оптимизация расписания.
  • При отказе чтения разрешите использование в памяти с уведомлением. При ошибке записи сохраните текущий выбор и сообщите, что записать его не удалось. В этом случае восстановление после обновления не обещается.

Следующий код вводит несколько средств JavaScript. try выполняет код, который может выбросить исключение; catch даёт понятное восстановление вместо сбоя страницы. raw === null обозначает отсутствующий ключ. Array.isArray и typeof проверяют публичную форму списка ID; throw new Error(...) отправляет неверную форму в тот же путь восстановления. for (const id of saved) посещает ID по порядку, continue пропускает один, а push добавляет в новые локальные массивы. Эти временные массивы не являются состоянием React. join(' ') соединяет подходящие предложения уведомления. Функция чтения ничего не записывает и не удаляет в хранилище.

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

Теперь безопасно восстановите начальное состояние. useState(() => readSaved()) передаёт функцию как ленивый инициализатор: React вызывает её при инициализации компонента. Она возвращает {ids, notice}, не записывая данные, не меняя другой ключ и не загружая внешние данные. Сохраните снимок в initial, задайте выбранные ID из initial.ids и оставьте уведомление в сообщениях. StrictMode при разработке может вызвать инициализатор несколько раз для выявления проблем; функцию только для чтения безопасно повторять. Другая реализация с явным разрешением записи после восстановления также допустима, если не записывает пустой список до чтения.

useEffect импортируется из React и синхронизирует внешнее хранилище после применения рендера. Зависимость [selectedIDs] просит повторять синхронизацию при изменении массива. StrictMode при разработке может выполнить дополнительный цикл эффекта; запись того же списка ID идемпотентна: повтор оставляет тот же сохранённый результат без дубликатов. Выбор, сортировка, количество и конфликты остаются обычной работой рендера и обработчиков, не эффектов. Начать с пустого состояния, немедленно записать пустоту и лишь затем прочитать ID — значит стереть реальное сохранённое расписание.

Замените компонент. readSaved и writeSaved берутся из только что сохранённой функции; остальные импорты использовались в шаге 1. remembering сообщает подготовленному представлению, что версия пытается сохранять в браузере; при ошибке записи всё равно появляется явное сообщение о работе только в памяти.

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

Вернитесь в Work и обновите обычно. Первое обновление после смены кода может показать пустой выбор: шаг 1 не сохранял его из памяти. Снова добавьте Garden First Light и Paper Signals и обновите точно тот же URL на той же странице браузера. Должны вернуться обе текущие записи и 75 минут. Новый независимый чистый контекст должен вместо этого начать с начального выбора этой части; не заявляйте, что URL делится этим личным расписанием.

Затем используйте отдельную контролируемую учебную страницу. Выберите Practice recovery внизу; она заменяет Work в той же вкладке. Прочитайте предупреждение: каждое упражнение заменяет только ключ выбора этой части, поэтому перезапишет текущие тестовые выборы. Выберите Load unreadable JSON, затем Return to Work. Обычный компонент должен показать пригодное пустое расписание и понятное сообщение о восстановлении. Добавьте Loft First Light на 12:00–12:30 и обновите нормально: вернутся её текущая карточка и 30 минут. Это доказывает возможность обычной работы после восстановления, а не лишь показ ошибки.

Вернитесь в Practice recovery, выберите Load retired IDs, затем Return to Work. Видимая заготовка содержит D11, удалённый ID и D12. Две доступные записи остаются с суммой 75 минут и объяснением пропущенного старого выбора. После замены старого ввода очищенной записью следующее обновление не обязано повторять уведомление. Наконец используйте Load conflicting IDs: список — D11, D13, D12. D13 пересекается с D11, который стоит раньше в сохранённом вводе, поэтому восстановление оставляет D11 и D12 с уведомлением, не все три и не пустое расписание. Страница Practice явно учебная, не скрытый сброс в обычном пользовательском потоке.

Вернитесь в Work и убедитесь, что Add, фильтр и Remove продолжают работать с восстановленными текущими выборами. Как в шаге 1, проверьте допустимую смежность Small Stages и отказ Colour Routes. Если настоящая политика браузера блокирует хранение, оставьте явное уведомление о памяти и используйте страницу в этом посещении; для восстановления после обновления откройте разрешённый контекст. Не стирайте посторонние браузерные данные ради успеха. Запустите итоговую проверку после нормальных действий и восстановления.

После удаления и повторного добавления восстановленные карточки First Light, Colour Routes и Small Stages суммируются до 80 минут. Сравните их текущие сведения и состояния Add:

Восстановленные текущие выборы и производная сводка

На узком экране отказ раннего кандидата Loft указывает выбранную Garden-сессию рядом с кнопкой; навигация расписания остаётся доступной выше.

Читаемый отказ рядом с кандидатом на узком экране

Резюме

Вы построили достоверное правило участия: каждая текущая выбранная запись участвует в проверке полуоткрытых интервалов, смежные сессии разрешены, а удаление может сделать кандидата совместимым. Затем вы восстановили ограниченные одним ключом браузерные данные только из ID до синхронизации записи, сохранив связи с текущими записями и обработав нечитаемые, отсутствующие или конфликтующие сохранённые выборы.

Восстановление ограничено тем же источником браузера и существующей средой курса с разрешённым хранением. Независимое испытание изменяет данные и связи программы, используя те же механизмы.