/*__IIFE_WRAPPED__*/
;(function(){
// screens-event.jsx — Secret-Input(本音) / Plan-Pending / Plan-Reveal / Surprise-Hint
const { useState: useS, useEffect: useE, useRef: useR } = React;
const {
useApp, Icon, Btn, Chip, Card, Avatar, AiAvatar, ImgSlot, Eyebrow, LottieBox,
TopBar, Body, Field, Segmented, PrivacyBadge, SAFE_TOP, Amulet,
Weather, WeatherBadge,
MonthGrid, TimeBand, parseISODate, parseTimeRange, hhmm, WD,
} = window;
const PARTNER_FALLBACK = 'パートナー';
/* ── 音声入力ボタン(Web Speech API)──
タップして話すと、確定した認識結果を onText(text) で親の入力欄へ渡す。
ブラウザ/端末が未対応なら何も描画しない(従来どおりキーボード入力)。 */
function MicButton({ onText, lang = 'ja-JP' }) {
const [supported] = useS(() => !!(window.SpeechRecognition || window.webkitSpeechRecognition));
const [listening, setListening] = useS(false);
const recRef = useR(null);
const onTextRef = useR(onText);
onTextRef.current = onText;
useE(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return;
const rec = new SR();
rec.lang = lang;
rec.interimResults = false;
rec.continuous = false;
rec.onresult = (e) => {
let fin = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) fin += e.results[i][0].transcript;
}
fin = fin.trim();
if (fin && onTextRef.current) onTextRef.current(fin);
};
rec.onend = () => setListening(false);
rec.onerror = () => setListening(false);
recRef.current = rec;
return () => { try { rec.abort(); } catch (_) {} };
}, []);
if (!supported) return null;
const toggle = () => {
const rec = recRef.current;
if (!rec) return;
if (listening) { try { rec.stop(); } catch (_) {} setListening(false); return; }
try { rec.start(); setListening(true); } catch (_) { /* 二重start は無視 */ }
};
return (
);
}
/* 音声認識テキストを既存入力へ追記する(末尾に半角スペースを挟む) */
const appendVoice = (setText) => (t) => setText(prev => (prev && prev.trim() ? prev.replace(/\s+$/, '') + ' ' : '') + t);
const FIRST_PROMPT = { who: 'ai', text: 'どんな気分ですか? 行きたい場所、いまの正直な体力、なんでも聞かせてください。ここはあなただけのひみつの場所です。' };
const delay = ms => new Promise(r => setTimeout(r, ms));
const spotToProposal = s => ({
name: s.name, kind: s.category || 'スポット', blurb: s.note || '',
img: (s.category || 'SPOT').slice(0, 6), lat: s.lat, lng: s.lng, category: s.category,
photo: s.photoUrl, placeId: s.placeId, address: s.address, rating: s.rating, mapsUri: s.mapsUri,
});
/* ─────────── 体力バッテリー(スワイプで充電) ─────────── */
function batteryLabel(v) { return v < 25 ? 'クタクタ' : v < 55 ? 'ほどほど' : v < 80 ? '元気' : '元気満タン'; }
function batteryColor(v) { return v < 25 ? 'var(--me)' : v < 55 ? 'var(--gold)' : 'var(--partner)'; }
function BatterySwipe({ value, onChange }) {
const ref = useR(null);
const drag = useR(false);
useE(() => {
const setFrom = cx => {
const r = ref.current.getBoundingClientRect();
const p = Math.round(((cx - r.left) / r.width) * 100);
onChange(Math.max(0, Math.min(100, p)));
};
const move = e => { if (drag.current) { e.preventDefault(); setFrom(e.clientX); } };
const up = () => { drag.current = false; };
window.addEventListener('pointermove', move, { passive: false });
window.addEventListener('pointerup', up);
ref.current.__setFrom = setFrom;
return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
}, [onChange]);
const col = batteryColor(value);
return (
今日の体力
{batteryLabel(value)}
{ drag.current = true; ref.current.__setFrom(e.clientX); }}
style={{
position: 'relative', flex: 1, height: 56, borderRadius: 14, cursor: 'grab', touchAction: 'none',
background: 'var(--surface-2)', border: '3px solid var(--ink)', padding: 4, overflow: 'hidden',
}}>
{[0, 1, 2, 3, 4].map(i =>
)}
48 ? '#fff' : 'var(--ink)', textShadow: value > 48 ? '0 1px 3px rgba(0,0,0,.25)' : 'none' }}>{value}%
クタクタスワイプして充電 →元気満タン
);
}
/* ─────────── チャット共通: 吹き出し・タイピング・提案 ─────────── */
function Bubble({ m }) {
const me = m.who === 'me';
return (
);
}
/* 配列をシャッフルした新しい配列を返す(毎回おなじ順で出ると「作り物」に見えるため) */
const shuffled = (arr) => {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
};
/* 応答待ちの“間”を埋める、意味をなさないひとりごと。
ドットだけだと止まって見えるので、言葉が動き続けることで「生きている」ことを伝える。 */
const WHIMSY_LINES = [
'こねこね中', 'ひらめき待ち', 'ふむふむ', 'ことばを探し中', 'ぐるぐる思案中',
'そわそわ', 'まぜまぜ', 'ひとりごと中', 'メモ取り中', 'たくらみ中',
'うんうん唸り中', 'にやにや中', 'そっと考え中', 'ぽかぽか思考中', 'とんとん拍子中',
'えんやこら', 'ひらり思いつき中', 'もぐもぐ咀嚼中', 'ぱたぱた探し中', 'しんみり中',
];
function TypingBubble({ lines = WHIMSY_LINES }) {
const seq = useR(null);
if (!seq.current) seq.current = shuffled(lines);
const [i, setI] = useS(0);
const [sec, setSec] = useS(0);
useE(() => {
const t = setInterval(() => setI(v => (v + 1) % seq.current.length), 1900);
const s = setInterval(() => setSec(v => v + 1), 1000);
return () => { clearInterval(t); clearInterval(s); };
}, []);
return (
{[0, 1, 2].map(d => (
))}
{seq.current[i]}…
{sec >= 5 && (
{sec}s
)}
);
}
function ProposalBlock({ spots, onPick }) {
const [picked, setPicked] = useS(null);
const list = (spots && spots.length ? spots : []);
return (
このあたり、どこが気になる?
気になる場所を選ぶと、Amulet Map に追加されます。
{list.map(p => {
const on = picked === p.name;
return (
);
})}
);
}
/* ─────────── プラン作り直しの承認カード(③ プラン相談) ───────────
会話が溜まってAIが作り直し案を用意したら出る。押して初めてプランが作り直される
(勝手な再生成を避けるための承認ゲート)。 */
function PlanProposalCard({ onApply }) {
const { toast } = useApp();
const [busy, setBusy] = useS(false);
const [done, setDone] = useS(false);
const apply = async () => {
if (busy || done) return;
setBusy(true);
try {
await onApply();
setDone(true);
} catch (e) {
console.error(e);
toast(Amulet.friendlyError(e));
} finally { setBusy(false); }
};
return (
{done ? <>プランを作り直しました。
最新のプランに差し替わっています。>
: <>ここまでの相談で、作り直しの案がまとまったよ。
反映してよければ、下のボタンで作り直せます。>}
{!done && (
)}
);
}
/* ─────────── デート作成サジェスト(秘密インプット専用の唯一のサジェスト) ───────────
AIが「そろそろデートにする?」と判断したら出すカード。タップすると
デート作成画面が、AIの提案したタイトル・エリア・日付で“埋まった状態”で開く。 */
function EventSuggestCard({ data }) {
const { go } = useApp();
const d = data || {};
return (
気持ちが見えてきたよ。そろそろデートにする?
この内容で作成画面をひらけます。あとから直せます。
);
}
/* ─────────── チャットの共通ロジック(実データ前提・実API) ─────────── */
function useAmuletChat(ev, live, toast, initialMsgs, mode = 'counsel') {
const isPlan = mode === 'plan';
const isLive = !!(ev && ev.live && live.coupleId);
// 実データ前提。サインイン前(≒PC自動ログイン完了前)は最初の問いかけだけ出す。
const [msgs, setMsgs] = useS(isLive ? [] : [FIRST_PROMPT]);
const [loading, setLoading] = useS(isLive);
const [typing, setTyping] = useS(false);
useE(() => {
if (!isLive) return;
let dead = false;
// ① counsel は inputs スレッド、③ plan は planChat スレッドから履歴を読む
const loader = isPlan
? Amulet.getMyPlanChat(live.coupleId, ev.id)
: Amulet.getMyInput(live.coupleId, ev.id);
loader.then(doc => {
if (dead) return;
const hist = (doc?.messages || []).map(m => ({ who: m.role === 'user' ? 'me' : 'ai', text: m.text }));
// live で履歴が空なら initialMsgs(intro)→ なければ FIRST_PROMPT
const base = hist.length ? hist : (initialMsgs?.length ? initialMsgs : [FIRST_PROMPT]);
// 前回の相談で承認待ちの案が残っていれば、末尾に承認カードを復元する
if (isPlan && doc?.pendingPlan?.body) base.push({ kind: 'plan_proposal' });
setMsgs(base);
}).catch(e => {
console.error(e);
setMsgs([FIRST_PROMPT]);
}).finally(() => { if (!dead) setLoading(false); });
return () => { dead = true; };
}, []);
const send = async (text) => {
if (!text.trim() || typing) return;
setMsgs(m => [...m, { who: 'me', text }]);
if (!isLive) return; // 実APIが無い状態では送信しない(サインイン後に有効)
setTyping(true);
try {
if (isPlan) {
// ③ プラン相談: まずは会話だけ。案が溜まったら承認カードを出し、
// 本人が承認したときだけ applyPlanProposal でプランを作り直す(勝手に再生成しない)。
const r = await Amulet.planChat({ eventId: ev.id, text });
setMsgs(m => {
const next = [...m, { who: 'ai', text: r.reply }];
if (r.proposal) next.push({ kind: 'plan_proposal' });
return next;
});
} else {
// ① 本音・相談役
const r = await Amulet.sendChat({ eventId: ev.id, text });
setMsgs(m => {
const next = [...m, { who: 'ai', text: r.reply }];
const evSug = (r.actions || []).find(a => a.type === 'suggest_event');
if (evSug) next.push({ kind: 'event_suggest', data: evSug });
return next;
});
}
} catch (e) {
console.error(e);
toast(Amulet.friendlyError(e));
} finally { setTyping(false); }
};
// 承認カードの「このプランで作り直す」→ 溜まった案を実際に反映する
const apply = async () => {
if (!isLive) throw new Error('offline');
await Amulet.applyPlanProposal(ev.id);
// listenLatestPlan が新バージョンを拾って画面を差し替える
toast(プランを作り直しました);
};
const pick = async (p) => {
setMsgs(m => [...m, { who: 'me', text: `「${p.name}」が気になるな。` }]);
if (isLive && p.lat != null) {
Amulet.addPin(live.coupleId, {
name: p.name, category: p.category || p.kind || 'スポット',
lat: p.lat, lng: p.lng, note: p.blurb || '',
photoUrl: p.photo, placeId: p.placeId, address: p.address, rating: p.rating, mapsUri: p.mapsUri,
}).catch(e => console.error('addPin:', e));
}
setTimeout(() => setMsgs(m => [...m, { who: 'ai', text: `いいですね。「${p.name}」、Amulet Map に追加してプランにも織り込んでおきますね。` }]), 500);
};
return { isLive, msgs, loading, typing, send, pick, apply };
}
/* ════════════════ HOME CHAT (① EventCreatorAgent · eventId なし) ════════════════
ホームの「なんでも、ふたりのことを相談」から開く “なんでも屋” チャット。
イベント不要。デートの相談・アプリへの不満・ふたりの関係の悩み…なんでも話せる。
会話の中でデート作成の意図が出たら create_event(createDate) で「器」づくりに入り、
タイトル(大まかなテーマ)が分かったらデート作成画面(EventCreateScreen)へ引き継ぐ。
日付/時間帯/エリアの詳細確定はそちらの担当(ここでは作らない)。
※ 行き先・スポット等の詳細プランは「秘密インプット」以降の担当(ここでは作らない)。
履歴は永続化しないので、毎回フロントから history を渡してマルチターンを成立させる。 */
const HOME_FIRST_PROMPT = { who: 'ai', text: 'こんにちは。悩み、デートの相談…なんでも聞かせてください。ちょっとした不満でも大丈夫です。' };
const WD_HOME = ['日', '月', '火', '水', '木', '金', '土'];
const isoToday = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
function parseISOLocal(iso) { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso || ''); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : null; }
function dowFromISO(iso) { const d = parseISOLocal(iso); return d ? WD_HOME[d.getDay()] : ''; }
function labelFromISO(iso) { const d = parseISOLocal(iso); return d ? `${d.getMonth() + 1}月${d.getDate()}日` : ''; }
/* デート作成ドラフト: 会話で集まった「器」を表示。揃ったら(complete)作成ボタンを出す。
ここで作るのはデートの“器”だけ。行き先や時間割は次の「秘密インプット」で。 */
function EventDraftCard({ data }) {
const { go } = useApp();
const d = (data && data.draft) || {};
const dateLabel = d.dateLabel || labelFromISO(d.dateISO);
const dow = dowFromISO(d.dateISO);
const rows = [
['cal', '日付', d.dateISO ? `${dateLabel}${dow ? `(${dow})` : ''}` : '—'],
['clock', '時間帯', (d.timeRange && d.timeRange.length === 2) ? `${d.timeRange[0]}–${d.timeRange[1]}` : '—'],
['pin', 'エリア', d.area || '—'],
['edit', 'タイトル', d.title || '(なし)'],
];
// ここでは作成せず、デート作成画面(EventCreateScreen)へ事前入力で渡す。
// 日付・時間・エリアの詳細確定はそちらの担当。
const goCreate = () => {
go('event-create', {
initTitle: d.title, initArea: d.area, initISO: d.dateISO, initTimeRange: d.timeRange, initSummary: d.summary,
});
};
return (
この内容でデートをつくる?
次の画面で日付・時間・エリアを決められます。
{rows.map(([ic, k, v]) => (
{k}
{v}
))}
);
}
// これまでのターンで確定した「デートの器」に、今ターンの draft をマージする。
// 新しい非空フィールドが上書きし、空/未定義は既存を保持する(サーバ側 mergeDraft と同じ意味論)。
// これで、モデルが確定ターンで一部フィールドを出し忘れても器が消えず、確認カードが安定して出る。
function mergeHomeDraft(prev, next) {
const p = prev || {}, n = next || {};
const has = v => v !== undefined && v !== null
&& !(typeof v === 'string' && !v.trim())
&& !(Array.isArray(v) && v.length === 0);
const pick = k => (has(n[k]) ? n[k] : p[k]);
const merged = { title: pick('title'), dateISO: pick('dateISO'), dateLabel: pick('dateLabel'), area: pick('area'), timeRange: pick('timeRange'), summary: pick('summary') };
// 日付が今ターンで変わったら、古い dateLabel は引き継がない(表示ズレ防止。無ければ ISO から補完される)
if (has(n.dateISO) && n.dateISO !== p.dateISO) merged.dateLabel = has(n.dateLabel) ? n.dateLabel : undefined;
const out = {};
for (const k of Object.keys(merged)) if (merged[k] !== undefined) out[k] = merged[k];
return out;
}
// タイトル(大まかなテーマ)が分かれば器として確認カードを出す。日付/エリア/時間帯は「プラン入力画面」で決める。
const homeDraftComplete = d => !!(d && d.title);
function HomeChatScreen({ params }) {
const { go, back, live, toast } = useApp();
const signedIn = !!live.user; // ① は coupleId 不要・認証だけでよい
const [msgs, setMsgs] = useS([HOME_FIRST_PROMPT]);
const [typing, setTyping] = useS(false);
const [text, setText] = useS('');
const scroller = useR(null);
const seeded = useR(false);
const msgsRef = useR(msgs); // 履歴を渡すため最新 msgs を参照
const draftRef = useR({}); // これまで確定した「デートの器」を蓄積(サーバへ往復で渡す)
useE(() => { msgsRef.current = msgs; }, [msgs]);
useE(() => { const el = scroller.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs, typing]);
/* ── ホームからの shared-element 遷移 ──
ホームの「なんでも、相談」ピル(params.hero の矩形)が、そのまま画面下の入力バーへ
滑り降りるモーフ。キーボードの出現で入力バーの最終位置は毎フレーム動くため、
目標矩形はアニメーション中に測り直して追従する。 */
const entering = !!(params && params.hero);
const [morphing, setMorphing] = useS(entering);
const rootRef = useR(null);
const pillRef = useR(null); // 実物: 入力ピル(textarea を包む丸枠)
const sendBtnRef = useR(null); // 実物: 送信ボタン
const clonePillRef = useR(null); // モーフ用クローン
const cloneSendRef = useR(null);
const taRef = useR(null);
/* キーボードを開いたまま遷移する: ホーム側でジェスチャ内にゴースト input へフォーカス済み。
マウント直後(同一タスク内)に textarea へフォーカスを引き継ぐとキーボードは閉じない。 */
React.useLayoutEffect(() => {
if (params && params.focus && taRef.current) taRef.current.focus({ preventScroll: true });
}, []);
React.useLayoutEffect(() => {
if (!morphing) return;
const root = rootRef.current, pill = pillRef.current, send = sendBtnRef.current;
const cp = clonePillRef.current, cs = cloneSendRef.current;
if (!root || !pill || !send || !cp || !cs) { setMorphing(false); return; }
// viewport座標 → root ローカル座標(PCのデバイスフレームは transform:scale されているので割り戻す)
const loc = (r, rootRect, scale) => ({
x: (r.left - rootRect.left) / scale, y: (r.top - rootRect.top) / scale,
w: r.width / scale, h: r.height / scale,
});
const rr0 = root.getBoundingClientRect();
const sc0 = rr0.width / root.offsetWidth || 1;
const hr = params.hero;
const p0 = loc({ left: hr.left, top: hr.top, width: hr.width, height: hr.height }, rr0, sc0);
// 出発点の送信サークル(元ピルの右端内側にあるものを近似)
const cSize = Math.max(26, Math.min(44, p0.h - 26));
const s0 = { x: p0.x + p0.w - cSize - (p0.h - cSize) / 2, y: p0.y + (p0.h - cSize) / 2, w: cSize, h: cSize };
const DUR = 420;
const ease = t => 1 - Math.pow(1 - t, 3);
const lerp = (a, b, k) => a + (b - a) * k;
let raf, t0;
const step = (now) => {
if (t0 === undefined) t0 = now;
const k = ease(Math.min(1, (now - t0) / DUR));
const rr = root.getBoundingClientRect();
const sc = rr.width / root.offsetWidth || 1;
const p1 = loc(pill.getBoundingClientRect(), rr, sc);
const s1 = loc(send.getBoundingClientRect(), rr, sc);
const set = (el, a, b, radius) => {
el.style.left = lerp(a.x, b.x, k) + 'px';
el.style.top = lerp(a.y, b.y, k) + 'px';
el.style.width = lerp(a.w, b.w, k) + 'px';
el.style.height = lerp(a.h, b.h, k) + 'px';
el.style.borderRadius = radius;
el.style.opacity = 1;
};
set(cp, p0, p1, lerp(18, 22, k) + 'px');
set(cs, s0, s1, '50%');
if ((now - t0) < DUR) raf = requestAnimationFrame(step);
else setMorphing(false);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [morphing]);
/* キーボードの出入りで表示領域が変わったら、会話は最下部に貼りつけておく */
useE(() => {
const vv = window.visualViewport;
if (!vv) return;
const pin = () => { const el = scroller.current; if (el) el.scrollTop = el.scrollHeight; };
vv.addEventListener('resize', pin);
return () => vv.removeEventListener('resize', pin);
}, []);
const sendText = async (raw) => {
const v = (raw || '').trim();
if (!v || typing) return;
setMsgs(m => [...m, { who: 'me', text: v }]);
if (!signedIn) return; // サインイン前は送らない(最初の問いかけだけ)
setTyping(true);
try {
// これまでの会話を履歴として渡す(ホームチャットは永続化しないため)
const history = msgsRef.current
.filter(m => m.text && (m.who === 'me' || m.who === 'ai'))
.map(m => ({ role: m.who === 'me' ? 'user' : 'model', text: m.text }));
history.push({ role: 'user', text: v });
// eventId を渡さない → ① EventCreatorAgent(なんでも屋)
// これまで蓄積した器(draftRef)を同送し、サーバ側でもマージして complete を判定させる。
const r = await Amulet.sendChat({ text: v, history, today: isoToday(), draft: draftRef.current });
const acts = r.actions || [];
const ce = acts.find(a => a.type === 'create_event');
// 今ターンの draft を蓄積器へマージ。表示判定はマージ結果で行う(サーバの complete 単独に依存しない)。
let mergedDraft = null;
if (ce) { draftRef.current = mergeHomeDraft(draftRef.current, ce.draft || {}); mergedDraft = draftRef.current; }
setMsgs(m => {
const next = [...m, { who: 'ai', text: r.reply }];
// 収集中はカードを出さず会話だけ。日付・時間帯・エリアが揃った確定段階だけ確認カードを出す。
if (homeDraftComplete(mergedDraft)) {
const lastCard = [...m].reverse().find(x => x.kind === 'event_draft');
const dup = lastCard && JSON.stringify(lastCard.data?.draft) === JSON.stringify(mergedDraft);
if (!dup) next.push({ kind: 'event_draft', data: { draft: mergedDraft } });
} else if (!ce) {
const evSug = acts.find(a => a.type === 'suggest_event'); // 旧エージェント互換
if (evSug) next.push({ kind: 'event_suggest', data: evSug });
}
return next;
});
} catch (e) {
console.error(e);
toast(Amulet.friendlyError(e));
} finally { setTyping(false); }
};
const send = () => { const v = text; if (!v.trim()) return; setText(''); sendText(v); };
// ホームのサジェストチップから来たら、その文言で自動送信(1回だけ)
useE(() => {
if (seeded.current || !params?.seed) return;
seeded.current = true;
sendText(params.seed);
}, []);
return (
{/* header — hero遷移のときは上からふわっと降りてくる */}
{/* chat — hero遷移のときは下からふわっと */}
{msgs.map((m, i) =>
m.kind === 'event_draft' ?
: m.kind === 'event_suggest' ?
:
)}
{typing &&
}
{/* input — キーボードが出たら下パディングを詰める(--kbh は app.jsx が配布)。
モーフ中は実物を透明にしてクローンに演じさせる */}
{/* shared-element モーフ用クローン(ホームのピル → 入力バー)。初期 opacity 0 で
最初のフレームの位置決めまで見せない。座標は effect が毎フレーム流し込む */}
{morphing && (
)}
);
}
function Sheet({ children, title, onClose }) {
return (
e.stopPropagation()} style={{ width: '100%', background: 'var(--paper)', borderRadius: '26px 26px 0 0', padding: '12px 22px 40px', boxShadow: '0 -10px 40px rgba(0,0,0,.2)', animation: 'floatup .3s ease' }}>
{title &&
{title}
}
{children}
);
}
/* ════════════════ PLAN REVEAL (決定版プラン) ════════════════ */
function PlanRevealScreen({ params }) {
const { go, back, live, toast } = useApp();
const ev = params?.ev || {};
const isLive = !!(ev.live && live.coupleId);
const [planDoc, setPlanDoc] = useS(null);
const [evDoc, setEvDoc] = useS(null); // 基本情報(タイトル/日付)の編集を反映するため購読
const [weather, setWeather] = useS('sun');
const userSetWx = useR(false); // ユーザーが手動でタブを切り替えたか
const [chatOpen, setChatOpen] = useS(false); // プランを相談 → その場で作り変えるチャット
const [editOpen, setEditOpen] = useS(false); // 右上の編集(タイトル・日付のみ)
useE(() => {
if (!isLive) return;
// 新: バージョン管理プランの最新版を購読(planChat の作り変えで自動差し替え)
return Amulet.listenLatestPlan(live.coupleId, ev.id, setPlanDoc);
}, []);
useE(() => {
if (!isLive) return;
return Amulet.listenEvent(live.coupleId, ev.id, setEvDoc);
}, []);
/* ── 基本情報(タイトル・日付)。live購読(evDoc)を最優先、無ければ params にフォールバック ── */
const raw = evDoc || ev.raw || {};
const curTitle = raw.title || ev.title || 'ただの週末';
const curDateLabel = raw.dateLabel || ev.date || '';
const curDateISO = (raw.date && /^\d{4}-\d{1,2}-\d{1,2}/.test(raw.date)) ? raw.date : (ev.raw?.date || null);
const curDow = raw.dow || ev.dow || '土';
const curArea = raw.area || ev.area || '';
const saveBasics = async (patch) => {
if (!isLive) { toast('プレビューでは保存されません'); return; }
await Amulet.updateEvent(live.coupleId, ev.id, patch);
toast('変更を保存しました');
};
const deleteEvent = async () => {
if (!isLive) { toast('プレビューでは削除されません'); setEditOpen(false); return; }
await Amulet.deleteEvent(live.coupleId, ev.id);
toast('🗑 デートを削除しました');
go('home', {}, { replace: true });
};
/* 実際の天気を取得し、雨なら既定で「雨の日プラン」を表示(手動切替後は尊重)*/
useE(() => {
let dead = false;
Weather.get(ev.raw?.date || ev.date, ev.area).then(k => {
if (!dead && !userSetWx.current) setWeather(k);
});
return () => { dead = true; };
}, []);
const onWeather = (v) => { userSetWx.current = true; setWeather(v); };
const items = (weather === 'sun' ? planDoc?.sunny : planDoc?.rainy) || [];
const planTitle = planDoc?.title || '…';
const blurb = planDoc?.blurb || '';
const spots = items.map(it => ({ name: it.name, kind: it.kind, blurb: it.note, img: (it.tag || it.kind || 'SPOT').slice(0, 6), photo: it.photoUrl }));
const route = items.map(it => ({ type: 'stop', time: it.time, name: it.name, kind: it.kind, note: it.note }));
if (isLive && !planDoc) {
return (
);
}
return (
{isLive && (
)}
} />
{/* title — バッジ→タイトル→日付の順にふわっと */}
「{planTitle}」
{curDateLabel}({curDow}) · {curArea}
{/* ☀️/🌧 weather tabs (実プランのみ) */}
{isLive && (
晴れの日プラン },
{ value: 'rain', label: 雨の日プラン },
]}
value={weather} onChange={onWeather} />
)}
{/* ① main spots — 幅160で3枚目が右端から覗く=横スクロールできると分かる。
開いた瞬間に右から1枚ずつ滑り込む */}
主要スポット · {spots.length}
{spots.map((s, i) => (
{s.kind}
{s.name}
{s.blurb}
))}
{/* ② route — 見えた瞬間に一筆書きで描かれる。天気タブ切替でも描き直す */}
当日のルート案内
{/* ③ why this plan */}
どうしてこのプラン?
{blurb}
おたがいの本音の原文は、どちらにも表示されません。
{/* ④ surprise hint entry — 仕掛ける側だけのひみつ。
ふきだし(画像)をタップでこっそりメモへ。右端の犬はふきだしの話し手。 */}
{/* ⑤ プランを相談して、その場で作り変える案内(操作は右下のFABから) */}
気になるところ、ありますか?
右下の「相談」から話すと、このプランをその場で作り変えます。
{/* spacer so FAB never overlaps content */}
{/* FAB → プランを相談(その場で作り変える)。チャット中は隠す */}
{!chatOpen && (
)}
{chatOpen && (
setChatOpen(false)}
title="このプランを相談"
subtitle="話すと、その場で作り変えます"
intro={[
{ who: 'ai', text: 'このプラン、気になるところはありますか?「もう少しのんびりにして」「夜は別のお店がいい」のように言っていただければ、その場で作り変えます。' },
]} />
)}
{editOpen && (
setEditOpen(false)}
/>
)}
);
}
/* ルート案内のタイムライン。draw を渡すと、表示された瞬間に縦線が上から下へ
一筆書きのように伸びていき、停留点と内容が線の進行に合わせて順に現れる。 */
function RouteList({ route, draw }) {
const hostRef = useR(null);
const [started, setStarted] = useS(!draw); // draw なしなら最初から描画済み扱い
useE(() => {
if (!draw || started || !hostRef.current || !window.IntersectionObserver) { if (draw && !window.IntersectionObserver) setStarted(true); return; }
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setStarted(true); io.disconnect(); }
}, { threshold: 0 });
io.observe(hostRef.current);
return () => io.disconnect();
}, [started]);
const STEP = 0.18; // 1行ぶんの進行秒数
// アニメーション種別ごとのスタイル(draw かつ started のときだけ付与。開始前は透明で待機)
const anim = (name, delay, dur, extra) => {
if (!draw) return extra || {};
if (!started) return { ...(extra || {}), opacity: 0 };
return { ...(extra || {}), animation: `${name} ${dur}s ease both`, animationDelay: `${Math.max(0, delay)}s` };
};
return (
{route.map((it, i) => {
const stop = it.type === 'stop';
const isLast = i === route.length - 1;
const base = i * STEP;
return (
{/* time col */}
{stop && {it.time}}
{/* rail — 上の継ぎ足し→点→下の線 の順で伸ばすと一筆書きに見える */}
{!isLast &&
}
{i > 0 &&
}
{stop ? (
) : (
)}
{/* content */}
{stop ? (
{it.name}
{it.kind}
{it.note &&
{it.note}
}
) : (
{it.line && {it.line}}
{it.text}
{it.detail &&
{it.detail}
}
)}
);
})}
);
}
/* ════════════════ SURPRISE HINT ════════════════ */
function SurpriseHintScreen({ params }) {
const { go, back, live } = useApp();
const ev = params?.ev || {};
const isLive = !!(ev.live && live.coupleId);
const partnerName = live.partner?.name || PARTNER_FALLBACK;
const [items, setItems] = useS(undefined); // undefined=読込中 / []=ヒントなし
useE(() => {
if (!isLive) { setItems([]); return; } // 実データ前提。未サインインはヒントなし表示
let dead = false;
Amulet.getSurprise(live.coupleId, ev.id)
.then(d => { if (!dead) setItems(d?.items || []); })
.catch(() => { if (!dead) setItems([]); });
return () => { dead = true; };
}, []);
const liveHero = items && items.length ? items[0] : null;
const liveRest = items && items.length > 1 ? items.slice(1) : [];
return (
💡 こっそりメモ(あなただけに表示中 )
{partnerName}を、そっと喜ばせる
あなたの「小言キーパー」のメモから、今回のプランでできるサプライズを見つけました。相手には表示されません。
{items === undefined && (
ヒントを探しています…
)}
{items && items.length === 0 && (
🌱
まだヒントがありません
なんでも相談チャットで「相手が言っていたこと」をメモすると、プラン生成のときにサプライズ案がここに届きます。
)}
{liveHero && (
{liveHero.spot ? `経路の近く · ${liveHero.spot.name}` : 'サプライズ案'}
{liveHero.title || 'ちいさなサプライズ'}
{liveHero.text}
)}
{liveRest.length > 0 && (
他のヒント
{liveRest.map((it, i) => (
{it.spot?.photoUrl
?
:
}
{it.title || it.spot?.name || 'サプライズ案'}
{it.text}
))}
)}
プランに戻る
);
}
/* ════════════════ PLAN PENDING (考案前 + 相談チャット + 生成CTA) ════════════════ */
function FixedStop({ time, name, kind, first, last }) {
return (
);
}
function PendingStop() {
return (
);
}
/* ── 生成中のリビール演出(ローディングを兼ねる)──
実測で20〜25秒(planner 単体で ~19s)かかる。ただの待ちにすると「壊れたのでは」と思われるため、
犬が自分の思考を実況しながら進む。段は backend の実処理に対応させてある:
grounding = searchAreaSpots / thinking = planReply(ADK planner) / enrich = enrichPlanBody / saving = writePlanVersion
backend が events/{id}.planProgress.stage を書けばそれに追従し、無ければ経過時間から推定する。
進捗バーは 96% に漸近するだけで自力では到達しない = 完了を先に約束しない。 */
const PLAN_STAGES = [
{
key: 'grounding', label: 'まちを歩いています', secs: 3,
lines: [
'まちの匂いを、くんくん嗅いでいます',
'このへん、よさそうな気配がする…',
'角を曲がって、もう一本先まで見てくるね',
],
},
{
key: 'thinking', label: 'ふたりの本音を読んでいます', secs: 19,
lines: [
'ふたりの本音を、こっそり読んでいます',
'ここ、並びそうだなあ…と唸っています',
'歩く距離を、しっぽで測っています',
'おなかが空く時間から、逆算しています',
'雨が降った日の道も、掘っておきますね',
'サプライズを、どこに埋めようかな',
'順番を並べかえて、うーん、うーん',
'ふたりの「好き」が重なるところを探しています',
'休憩できるベンチも、覚えておこう',
'ちょっと欲張りすぎたかも…一つ減らします',
],
},
{
key: 'enrich', label: '写真と地図を集めています', secs: 4,
lines: [
'お店の写真を、くわえて持ってきました',
'地図にピンを、ぽす、ぽす',
'写真がボケてないか、鼻先で確認中',
],
},
{
key: 'saving', label: 'しおりに清書しています', secs: 2,
lines: [
'しおりに、清書しています',
'最後にリボンを、きゅっと結んで',
],
},
];
const PLAN_TOTAL_SECS = PLAN_STAGES.reduce((a, s) => a + s.secs, 0);
const LONG_WAIT_LINE = 'ちょっと長考しています…もう少しだけ待ってね';
const SORRY_LINE = 'ごめんなさい。うまくまとめきれませんでした…';
/* 経過秒から、いま何段目にいるかを推定する(backend の planProgress が無いときのフォールバック) */
const stageFromElapsed = (sec) => {
let acc = 0;
for (let i = 0; i < PLAN_STAGES.length; i++) {
acc += PLAN_STAGES[i].secs;
if (sec < acc) return i;
}
return PLAN_STAGES.length - 1;
};
function GeneratingOverlay({ progress, byPartner, error, onRetry, onClose }) {
const [sec, setSec] = useS(0);
const [tick, setTick] = useS(0); // セリフの送り
useE(() => {
if (error) return; // 失敗したら時計もセリフも止める
setSec(0); setTick(0); // 再試行では最初から数え直す
const a = setInterval(() => setSec(v => v + 1), 1000);
const b = setInterval(() => setTick(v => v + 1), 2400);
return () => { clearInterval(a); clearInterval(b); };
}, [error]);
// backend が段を教えてくれるならそれを信じる。無ければ経過時間から推定。
const realIdx = progress?.stage ? PLAN_STAGES.findIndex(s => s.key === progress.stage) : -1;
const idx = realIdx >= 0 ? realIdx : stageFromElapsed(sec);
const stage = PLAN_STAGES[idx];
// 予定より長引いたら、犬も正直に「長考中」と言う
const overtime = sec > PLAN_TOTAL_SECS + 6;
const line = error ? SORRY_LINE
: overtime && tick % 3 === 2 ? LONG_WAIT_LINE
: stage.lines[tick % stage.lines.length];
// 96% への漸近。完了は親のアンマウントでしか起きない。
const eased = 96 * (1 - Math.exp(-sec / (PLAN_TOTAL_SECS * 0.45)));
const floor = (PLAN_STAGES.slice(0, idx).reduce((a, s) => a + s.secs, 0) / PLAN_TOTAL_SECS) * 96;
const pct = Math.min(96, Math.max(eased, floor));
return (
{/* 犬のふきだし = 思考の実況。しっぽ(三角)で下の犬に繋ぐ */}
{line}
{/* 失敗したら犬も足を止める(動き続けると「まだやっている」ように見えるため) */}
{error ? 'プランを作れませんでした'
: byPartner ? 'パートナーがプランを生み出しています'
: 'プランを生み出しています'}
{error ? (
<>
{error}
もう一度ためす
とじる
>
) : (
<>
{/* 進捗バー */}
{stage.label}
{sec}s
{/* 段のインジケータ */}
{PLAN_STAGES.map((s, i) => (
))}
>
)}
);
}
/* draggable chat window — occupies the lower portion, resize by dragging the grabber */
function PlanChatWindow({ ev, onClose, title = 'プランを相談', subtitle = '気になること、なんでも相談', intro }) {
const { live, toast } = useApp();
const [frac, setFrac] = useS(0.56);
const rootRef = useR(null);
const rectRef = useR(null);
const drag = useR(false);
const chat = useAmuletChat(ev, live, toast, intro, 'plan');
const [text, setText] = useS('');
const scroller = useR(null);
useE(() => {
const move = e => {
if (!drag.current || !rectRef.current) return;
e.preventDefault();
const r = rectRef.current;
let f = (r.bottom - e.clientY) / r.height;
f = Math.max(0.34, Math.min(0.94, f));
setFrac(f);
};
const up = () => { drag.current = false; document.body.style.cursor = ''; };
window.addEventListener('pointermove', move, { passive: false });
window.addEventListener('pointerup', up);
return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
}, []);
useE(() => { const el = scroller.current; if (el) el.scrollTop = el.scrollHeight; }, [chat.msgs, chat.typing, frac]);
/* キーボードの出入りで表示領域が変わったら会話を最下部に貼りつける */
useE(() => {
const vv = window.visualViewport;
if (!vv) return;
const pin = () => { const el = scroller.current; if (el) el.scrollTop = el.scrollHeight; };
vv.addEventListener('resize', pin);
return () => vv.removeEventListener('resize', pin);
}, []);
const startDrag = e => {
drag.current = true;
rectRef.current = rootRef.current.parentElement.getBoundingClientRect();
document.body.style.cursor = 'ns-resize';
e.preventDefault();
};
const send = () => { const v = text; if (!v.trim()) return; setText(''); chat.send(v); };
return (
<>
{/* scrim */}
{/* window */}
{/* draggable header */}
{/* messages */}
{chat.loading && (
これまでの会話を読み込んでいます…
)}
{chat.msgs.map((m, i) =>
m.kind === 'proposal' ?
: m.kind === 'plan_proposal' ?
: m.kind === 'event_suggest' ?
:
)}
{chat.typing &&
}
{/* input */}
{/* キーボードが出たら下パディングを詰める(--kbh は app.jsx が配布) */}
>
);
}
function PlanPendingScreen({ params, startChatOpen }) {
const { go, back, live, toast } = useApp();
const ev = params?.ev || {};
const isLive = !!(ev.live && live.coupleId);
const [chat, setChat] = useS(!!startChatOpen);
const [generating, setGenerating] = useS(false);
const [genError, setGenError] = useS(null); // 生成失敗のメッセージ(null=失敗していない)
const [evDoc, setEvDoc] = useS(null);
const [editOpen, setEditOpen] = useS(false);
const [confirmGen, setConfirmGen] = useS(false);
const wasGenerating = useR(false);
/* 主要スポット選び: Amulet Map の pin をモーダルで一覧 → 選んだものをこの画面に表示(フロント完結) */
const [pins, setPins] = useS(null); // null=読込中 / []=なし
const [selectedIds, setSelectedIds] = useS(() => new Set());
const [spotModal, setSpotModal] = useS(false);
/* live: planStatus を購読(パートナーが生成した場合もここで気づく) */
useE(() => {
if (!isLive) return;
return Amulet.listenEvent(live.coupleId, ev.id, setEvDoc);
}, []);
/* Amulet Map のスポット(couples/{id}/pins)を購読 */
useE(() => {
if (!isLive) { setPins([]); return; }
return Amulet.listenPins(live.coupleId, docs => setPins(docs.map(d => ({
id: d.id, name: d.name, cat: d.category || 'スポット', note: d.note || '', photo: d.photoUrl, lat: d.lat, lng: d.lng,
}))));
}, []);
const allPins = pins || [];
const selectedSpots = allPins.filter(p => selectedIds.has(p.id));
const toggleSpot = (id) => setSelectedIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const planStatus = evDoc?.planStatus || ev.planStatus || 'none';
const partnerGenerating = isLive && planStatus === 'generating' && !generating;
/* パートナー起点の生成が完了したら自動でリビールへ */
useE(() => {
if (partnerGenerating) wasGenerating.current = true;
if (wasGenerating.current && planStatus === 'ready' && !generating) {
wasGenerating.current = false;
go('plan-reveal', { ev }, { replace: true });
}
}, [planStatus]);
/* 失敗はトーストで流さず、オーバーレイをそのまま失敗表示に切り替えて、その場で再試行させる。
(生成は20秒超かかるため、画面を戻して押し直させると「もう一度待つ」心理的負担が大きい) */
const generate = async () => {
setGenError(null);
setGenerating(true);
try {
if (isLive) {
// 選んだ主要スポットを「必ず入れる」として生成リクエストに載せる(晴れ/雨どちらのプランにも組み込ませる)
const requiredSpots = selectedSpots.map(s => ({ name: s.name, category: s.cat, lat: s.lat, lng: s.lng, note: s.note }));
await Promise.all([Amulet.generatePlan(ev.id, undefined, requiredSpots), delay(3000)]);
} else {
await delay(3000);
}
window.track && window.track('plan_generate', { ok: 'true' });
go('plan-reveal', { ev }, { replace: true });
} catch (e) {
window.track && window.track('plan_generate', { ok: 'false' });
console.error(e);
setGenError(Amulet.friendlyError(e)); // generating は立てたまま=オーバーレイを保持
}
};
const closeGenError = () => { setGenError(null); setGenerating(false); };
const planReady = isLive && planStatus === 'ready';
const stats = evDoc?.inputStats || {};
const myCount = (live.user && stats[live.user.uid]?.messageCount) || 0;
const theirCount = live.user
? Object.entries(stats).filter(([k]) => k !== live.user.uid).reduce((a, [, v]) => a + (v?.messageCount || 0), 0)
: 0;
/* ── 基本情報(この画面で後から編集できる)──
live購読(evDoc) を最優先。無ければ params(ev / ev.raw) にフォールバック。
evDoc.date / ev.raw.date は ISO("YYYY-MM-DD")、ev.date は表示ラベル。 */
const raw = evDoc || ev.raw || {};
const curTitle = (raw.title || ev.title || 'ただの週末');
const curDateLabel = raw.dateLabel || ev.date || '';
const curDateISO = (raw.date && /^\d{4}-\d{1,2}-\d{1,2}/.test(raw.date)) ? raw.date : (ev.raw?.date || null);
const curDow = raw.dow || ev.dow || '土';
const curArea = raw.area || ev.area || '';
const curTimeRange = raw.timeRange || null; // ["12:00","19:00"]
const timeLabel = (Array.isArray(curTimeRange) && curTimeRange.length === 2) ? `${curTimeRange[0]}–${curTimeRange[1]}` : null;
const [tStart, tEnd] = (Array.isArray(curTimeRange) && curTimeRange.length === 2) ? curTimeRange : ['13:00', '19:00'];
const saveBasics = async (patch) => {
if (!isLive) { toast('プレビューでは保存されません'); return; }
await Amulet.updateEvent(live.coupleId, ev.id, patch);
toast('変更を保存しました');
};
const deleteEvent = async () => {
if (!isLive) { toast('プレビューでは削除されません'); setEditOpen(false); return; }
await Amulet.deleteEvent(live.coupleId, ev.id);
toast('🗑 デートを削除しました');
go('home', {}, { replace: true });
};
// 生成CTA: 相手の本音が一つも無いまま生み出そうとしたら、まず確認を挟む
const requestGenerate = () => {
if (isLive && theirCount === 0) { setConfirmGen(true); return; }
generate();
};
return (
} />
{/* title + 基本情報(編集ボタンから変更) */}
{planReady ? 'プランができました' : 'プラン考案中'}
{curTitle}
{curDateLabel}({curDow}){curArea ? ` · ${curArea}` : ''}{timeLabel ? ` · ${timeLabel}` : ''}
{/* ふたりの蓄積(量だけ。中身は見えない) */}
{isLive && (
🫙
ふたりの本音、たまってます
あなた {myCount} · 相手 {theirCount}(中身はおたがい見えません)
)}
{/* ① main spots — Amulet Map から選んで主役に据える */}
主要スポット{selectedSpots.length ? ` · ${selectedSpots.length}` : ' · これから'}
{selectedSpots.length > 0 && (
)}
{selectedSpots.length > 0 ? (
{selectedSpots.map(s => (
))}
) : (
)}
{/* ② route — fixed endpoints, 途中は考え中 */}
当日のルート案内
{/* ③ 生成CTA — デモのクライマックスへ */}
{planReady ? 'プランが届いています' : 'そろそろ、かたちにする?'}
{planReady
? 'ふたりの本音をブレンドしたプランができあがりました。'
: 'ふたりの本音と行きたい場所から、AIが一日をデザインします。本音の原文は、おたがいに見えません。'}
go('plan-reveal', { ev }, { replace: true }) : requestGenerate}>
{planReady ? 'できあがったプランを見る' : 'プランを生み出す'}
{/* spacer so FAB never overlaps content */}
{/* FAB → opens chat */}
{!chat && !generating && (
)}
{chat && (
setChat(false)}
intro={raw.homeChatSummary ? [
{ who: 'ai', text: `${raw.homeChatSummary}とのことでしたね。ここからは行き先やプランの中身を一緒に決めていきましょう。` },
] : undefined}
/>
)}
{spotModal && (
setSpotModal(false)}
/>
)}
{editOpen && (
setEditOpen(false)}
/>
)}
{confirmGen && (
{ setConfirmGen(false); generate(); }}
onCancel={() => setConfirmGen(false)}
/>
)}
{(generating || partnerGenerating) && (
)}
);
}
/* ── 主要スポット選択モーダル ──
Amulet Map(couples/{id}/pins)のスポットを一覧表示し、タップで選択/解除する。
選択状態は親(PlanPendingScreen)が保持し、その場で画面の「主要スポット」に反映される。 */
function SpotPickerModal({ pins, selectedIds, onToggle, onClose }) {
const loading = pins === null;
const list = pins || [];
const count = selectedIds.size;
return (
{/* backdrop */}
{/* sheet */}
{/* drag handle */}
{/* header */}
Amulet Map に追加した場所から、この日の軸にする場所を選べます。
{/* list */}
{loading && (
)}
{!loading && list.length === 0 && (
Amulet Map にまだスポットがありません
マップ画面の+から行きたい場所を追加してね
)}
{!loading && list.map(s => {
const on = selectedIds.has(s.id);
return (
);
})}
{/* footer */}
{count > 0 ? `${count}件を主要スポットにする` : '閉じる'}
);
}
/* ── 基本情報の編集シート ──
「新しいデート」フォーム(EventCreateScreen)と同じ MonthGrid / TimeBand / Field を使い、
日付・時間帯・エリア・タイトルをその場で編集する。保存で onSave(patch) を呼ぶ。 */
function EventEditModal({ initial, planReady, titleDateOnly, onSave, onDelete, onClose }) {
const now = new Date();
const p0 = parseISODate(initial.dateISO) || { y: now.getFullYear(), mo: now.getMonth() + 1, d: now.getDate() };
const [calY, setCalY] = useS(p0.y);
const [calM, setCalM] = useS(p0.mo);
const [selDay, setSelDay] = useS(p0.d);
const [title, setTitle] = useS(initial.title === 'ただの週末' ? '' : (initial.title || ''));
const [area, setArea] = useS(initial.area === '未定' ? '' : (initial.area || ''));
const [timeRange, setTimeRange] = useS(parseTimeRange(initial.timeRange) || { s: 13, e: 19 });
const [busy, setBusy] = useS(false);
const [confirmDel, setConfirmDel] = useS(false);
const [deleting, setDeleting] = useS(false);
const shiftMonth = (delta) => {
let m = calM + delta, y = calY;
if (m < 1) { m = 12; y--; } else if (m > 12) { m = 1; y++; }
setCalM(m); setCalY(y);
const maxD = new Date(y, m, 0).getDate();
if (selDay > maxD) setSelDay(maxD);
};
const calToday = (calY === now.getFullYear() && calM === now.getMonth() + 1) ? now.getDate() : null;
const save = async () => {
setBusy(true);
const iso = `${calY}-${String(calM).padStart(2, '0')}-${String(selDay).padStart(2, '0')}`;
const dow = WD[new Date(calY, calM - 1, selDay).getDay()];
const dateLabel = `${calM}月${selDay}日`;
// titleDateOnly: プラン詳細から開いたときは、タイトルと日付だけを更新する
const patch = titleDateOnly
? { title: title.trim() || 'ただの週末', date: iso, dateLabel, dow }
: {
title: title.trim() || 'ただの週末',
date: iso, dateLabel, dow,
area: area.trim() || '都内',
timeRange: [hhmm(timeRange.s), hhmm(timeRange.e)],
};
try {
await onSave(patch);
onClose();
} catch (e) {
console.error(e);
setBusy(false);
}
};
const doDelete = async () => {
setDeleting(true);
try {
await onDelete();
// 成功時は親側で画面遷移するので、ここでは閉じない(アンマウントされる)
} catch (e) {
console.error(e);
setDeleting(false);
setConfirmDel(false);
}
};
return (
{/* header */}
{/* body */}
{planReady && (
プランはできあがっています。日付やエリアを変えたら、プランを作り直すのがおすすめです。
)}
{/* calendar */}
{calM}月 {calY}
{/* time band / area — タイトル・日付のみ編集のときは隠す */}
{!titleDateOnly && (
<>
>
)}
{/* デートを削除(モーダル一番下) */}
{onDelete && (
プランや相談の内容もまとめて消えます。元に戻せません。
)}
{/* footer */}
{busy ? '保存しています…' : '変更を保存'}
{/* 削除の確認(元に戻せない旨を伝える) */}
{confirmDel && (
!deleting && setConfirmDel(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(61,54,44,.5)', backdropFilter: 'blur(2px)' }} />
このデートを削除しますか?
プランや相談の内容もまとめて消えます。元に戻せません。
setConfirmDel(false)}>やめる
)}
);
}
/* ── 生成前の確認ダイアログ ──
相手の本音が一つも溜まっていないのに「生み出す」を押したとき、
まだ相手の意見を聞けていない旨を伝えて意思確認する。 */
function GenerateConfirmModal({ myCount, onConfirm, onCancel }) {
return (
まだ相手の本音がありません
相手はまだ本音を残していないようです。
{myCount > 0 ? '今はあなたの入力だけでプランを組みます。' : 'ふたりの本音がまだ届いていません。'}
このまま生み出しますか?(あとで作り直せます)
このまま生み出す
相手の本音を待つ
);
}
function PlanChatScreen({ params }) { return
; }
Object.assign(window, { HomeChatScreen, PlanPendingScreen, PlanChatScreen, PlanRevealScreen, SurpriseHintScreen, Sheet, BatterySwipe, RouteList });
})();