はじめに
フェスティバルの予定表は、選んだセッションに実際に参加できてこそ役立ちます。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 を選び、2 枚のカードと 75 分を確認します。次に Studio Colour Routes, 10:50–11:10 を試します。候補の隣のメッセージが Paper Signals を示し、選択された 2 レコードと 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 は 2 番目の選択なので、拒否後も 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 から import した 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 を再読み込みします。現在の 2 レコードと 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 が含まれます。利用できる 2 レコードは合計 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 のみのブラウザデータを、同期書き込みより先に復元しました。現在レコードとの関係を保持し、読めない、見つからない、または競合する保存済み選択から復旧できるようにしました。
復元は同じブラウザオリジン、既存のコース環境、ストレージが許可されている場合に限られます。独立チャレンジでは、同じ仕組みでプログラムのデータと関係を変更します。
