说明时间冲突并记住选择

JavaScriptBeginner
立即练习

介绍

音乐节日程只有在访客能实际参加所选场次时才有用。Daylight Festival 既有可以衔接的相邻场次,也有不同舞台上不能同时参加的重叠场次。你将让添加操作说明实际冲突,再只记住场次 ID,使普通刷新能够恢复当前目录中的记录。

你需要第一实验所列的 React 和 JavaScript 前置,以及按当前记录查找并推导摘要的选择模式。本单元独立启动,已经准备好这些选择行为,不需要之前的 VM、文件或浏览器选择。你将学习半开区间、检查所有选择、JSON 恢复、localStorage 作用域,以及避免覆盖已保存选择所需的最基本 useEffect 与惰性初始化生命周期。60 分钟是包含阅读和普通错误的作者估计。不涉及后端、预订或跨设备持久化。

使用 /home/labex/project/festival-schedule/work 中的 Workspace,通过 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。10:15 的同名 Loft First Light 仍与 Garden First Light 冲突;不能把它当作移除冲突后成功添加的场次。

窄屏的日期导航提供 My schedule,无须在节目中长距离寻找;拒绝消息出现在被点击卡片旁。实际用 Tab/Enter 操作候选,检查反馈是否可读,同时不把焦点移进状态区域。若代码拼写有误,检查保存的 src/conflicts.js 和 src/App.jsx,恢复相关完整代码块后刷新。本步骤仍只把选择保存在内存,刷新可能重置它们,直到第 2 步完成。运行冲突检查。

先恢复 ID,再同步存储

本步骤先恢复已保存的 ID 列表,再写入,并从不可用或过时的保存数据中恢复。

从 Workspace 开始。localStorage 是按来源划分的浏览器存储,来源由协议、主机和端口组成。同源路径共享存储区域,因此使用课程与单元专属的键 settings.storageKey,而不是通用的 data 键。Study 有不同的键。不要调用 clear() 或移除其他键。新 VM 的 URL、干净浏览器上下文、另一浏览器或设备不一定有这些选择。浏览器策略也可能拒绝存储。这只方便同一浏览器页面使用,不是预订、备份或永久保留承诺。

存储值是字符串。JSON.stringify(ids) 把 ID 数组转换为 JSON 字符串;JSON.parse(raw) 将其读回。只存储 ["D11","D12"] 这样的 ID,不保存可能过时的标题和时间副本。根据当前目录解析恢复的 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 中,用 initial.ids 初始化选择 ID,并将提示保留为反馈。开发 StrictMode 为检测问题,可能多次调用初始化器;只读初始化器可以安全重复。使用明确恢复门控的另一实现同样有效,但不能在恢复前先写空列表。

从 React 导入的 useEffect 在渲染提交后同步外部存储。依赖列表 [selectedIDs] 要求该数组变化时重新同步。开发 StrictMode 可能多执行一轮 effect;写入相同 ID 列表是幂等的:重复后保存结果相同,不会增加重复选择。选择、排序、数量和冲突判断仍属于普通渲染或处理函数工作,不应放到 effect 中。先以空列表启动、立即写空,再加载 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;它会在同一 Tab 中替换 Work 页面。阅读警告:每个练习只替换本单元的已保存选择键,因此会替换当前测试选择。选择 Load unreadable JSON,再选择 Return to Work。正常组件应显示可用的空日程和可读恢复提示。添加 12:00–12:30 的 Loft First Light 并普通刷新:其当前卡片和 30 分钟应恢复。这证明恢复之后还能正常使用,而不只是显示错误标签。

返回 Practice recovery,选择 Load retired IDs,再选择 Return to Work。可见预设包含 D11、一个已退出目录的 ID 和 D12。两个可用记录保留,合计 75 分钟,并说明略去的旧选择。一旦干净列表替换了旧输入,后续刷新不必再次显示相同提示。最后使用 Load conflicting IDs:预设为 D11、D13、D12。D13 与原保存输入中先出现的 D11 重叠,因此恢复结果保留 D11 和 D12 并提示,而不是保留三者或清空日程。练习页明确用于训练,不是顾客流程中的隐形重置。

返回 Work,确认 Add、筛选和 Remove 仍作用于恢复后的当前选择。像第 1 步一样,测试合法相邻的 Small Stages 和 Colour Routes 拒绝。若真实浏览器策略阻止存储,保留明确的仅内存消息,并继续本次访问;需要刷新持久化时,在允许存储的浏览器上下文重新打开。不要为了成功清除无关浏览器数据。正常流程和恢复路径正确后,运行最后检查。

完成移除后再添加的序列后,恢复的 First Light、Colour Routes 和 Small Stages 卡片合计 80 分钟。对照其当前详情和 Add 状态:

恢复后的当前选择与派生摘要

在窄屏中,较早的 Loft 候选旁会指出已选 Garden 场次,日程导航仍在上方可用。

窄屏上可读的候选旁拒绝提示

总结

你构建了真实的参加规则:每个已选当前记录都参与半开区间重叠检查,相邻场次合法,移除场次可以让候选变得兼容。随后,你先恢复指定作用域内仅含 ID 的浏览器数据,再同步写入,保持当前记录关系,并从不可读、缺失或存在冲突的已保存选择中恢复。

恢复只适用于同一浏览器来源、仍存在的课程环境以及允许存储的情况。独立挑战将使用相同机制,改变节目数据和对象关系。