소개
축제 일정은 방문자가 고른 세션에 실제로 참여할 수 있어야 유용합니다. 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 상태를 업데이트하기 전에 이 함수를 연결합니다. 아래 컴포넌트는 방금 작성한 함수를 import합니다. 무대로 필터링한 프로그램이 아니라 전체 현재 선택에서 레코드를 구합니다. 거부는 클릭된 후보의 피드백을 설정하고 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를 다시 추가하면 이제 가능하여 First Light, Colour Routes, Small Stages 순서로 80분이 됩니다. 10:15의 동명 Loft First Light는 아직 Garden First Light와 겹칩니다. 충돌 제거 후 성공적으로 추가하는 사례로 사용하지 마세요.
좁은 화면에서는 날짜 탐색의 My schedule로 이동할 수 있어 프로그램을 오래 뒤질 필요가 없으며 거부는 클릭한 카드 옆에 나타납니다. 실제 Tab/Enter로 후보를 조작하고 상태 영역으로 포커스를 옮기지 않아도 피드백이 읽히는지 확인합니다. 코드 오타가 있으면 저장한 src/conflicts.js와 src/App.jsx를 검사하고 해당 전체 블록을 복구한 후 새로 고침합니다. 이 단계는 아직 메모리에만 선택을 보관하므로 2단계 전에는 새로 고침으로 초기화될 수 있습니다. 충돌 검사를 실행합니다.
저장 동기화 전에 ID 복원하기
이 단계에서는 저장된 ID 목록을 쓰기 전에 복원한 다음 사용할 수 없거나 오래된 저장 데이터를 복구합니다.
Workspace에서 시작합니다. localStorage는 스킴, 호스트, 포트로 이루어진 한 오리진의 브라우저 저장소입니다. 같은 오리진의 경로는 저장 영역을 공유하므로 일반적인 data 키가 아닌 코스·단원별 settings.storageKey를 사용합니다. 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는 방금 저장한 보조 파일에서 가져옵니다. 다른 import는 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를 남깁니다. 셋 모두나 빈 일정이 아닙니다. Practice 페이지는 명시적인 훈련이지 고객 흐름의 숨은 초기화가 아닙니다.
Work로 돌아가 Add, 필터, Remove가 복구된 현재 선택에 계속 작용하는지 확인합니다. 1단계처럼 유효한 인접 Small Stages와 Colour Routes 거부를 시험합니다. 실제 브라우저 정책이 저장을 막으면 명시적인 메모리 전용 메시지를 유지하고 이번 방문에서 페이지를 사용하세요. 새로 고침 후 보존이 필요하면 허용된 브라우저 컨텍스트에서 다시 엽니다. 성공을 강제하려고 관계없는 브라우저 데이터를 지우지 마세요. 정상 경로와 복구 경로가 동작하면 마지막 검사를 실행합니다.
제거 후 다시 추가한 순서가 끝나면 복원된 First Light, Colour Routes, Small Stages 카드가 합계 80분입니다. 현재 상세 정보와 Add 상태를 비교하세요.

좁은 화면에서는 이른 Loft 후보의 거부가 버튼 옆에서 이미 선택한 Garden 세션을 밝히며 위쪽 일정 탐색도 사용할 수 있습니다.

요약
실제 참여 규칙을 만들었습니다. 현재 선택된 모든 레코드를 반열린 구간 검사에 포함하고, 인접을 허용하며, 제거 후 후보를 추가할 수 있게 했습니다. 이후 특정 키의 ID 전용 브라우저 데이터를 쓰기 동기화 전에 복원하여 현재 레코드 관계를 유지하고 읽을 수 없거나 없어진 선택, 충돌하는 저장 선택을 복구했습니다.
복원은 같은 브라우저 오리진, 존재하는 코스 환경, 저장이 허용된 경우로 제한됩니다. 독립 챌린지는 같은 메커니즘으로 프로그램 데이터와 관계를 바꿉니다.
