/*__IIFE_WRAPPED__*/ ;(function(){ // screens-main.jsx — Home / Event-Create / Settings const { useState: useStateM, useRef: useRefM, useEffect: useEffM } = React; const W = window; const { Icon, Btn } = window; /* ─────────── 名前フォールバック / ホームのサジェスト(旧MOCK由来)─────────── */ const ME_FALLBACK = 'あなた'; const PARTNER_FALLBACK = 'パートナー'; const HOME_SUGGEST = ['デートのアイデア出して', '最近ちょっとすれ違い気味', 'アプリのここが使いにくい', 'サプライズを相談']; /* 設定 →「このアプリについて」。ハッカソン提出作品としての位置づけと、 データの扱いをまとめてここに集約する(利用規約とプライバシーポリシーは分けない)*/ const LEGAL_SECTIONS = [ { icon: 'sparkle', title: 'ハッカソンの作品です', body: 'Amulet は「DevOps × AI Agent Hackathon」に向けて制作したプロトタイプです。商用サービスではなく、予告なく停止したり、データが削除されることがあります。' }, { icon: 'note', title: 'あつめる情報', body: 'メールアドレス、表示名、最寄駅、付き合った日、予定・本音・チャットに入力した内容。これらは Google Cloud(Firebase Authentication / Firestore)に保存されます。' }, { icon: 'chat', title: 'AIへの送信', body: 'プランの生成や相談のため、入力内容は Google Cloud の Vertex AI に送信されます。モデルの学習には使われません。' }, { icon: 'heart', title: 'ふたりの本音', body: '「本音」に書いた原文は、相手には表示されません。AIが受け取って要約した結果だけが、プランというかたちで共有されます。' }, { icon: 'lock', title: '利用状況の計測', body: 'Google Analytics 4 と Microsoft Clarity で、画面遷移などの匿名イベントのみを計測しています。氏名・メールアドレス・チャット本文は送信していません。' }, { icon: 'clock', title: '免責', body: '提案されるプランやお店の情報には AI が生成した内容が含まれ、正確性は保証されません。営業時間や料金などは、おでかけ前にご自身でご確認ください。' }, ]; /* 「このアプリについて」ダイアログ。設定画面とサインイン画面の両方から使う共通コンポーネント。 open=false のときは何も描画しない。 */ function AboutAppDialog({ open, onClose }) { if (!open) return null; return (
e.stopPropagation()} style={{ width: '100%', background: 'var(--paper)', borderRadius: '26px 26px 0 0', padding: '14px 22px 34px', boxShadow: '0 -10px 40px rgba(0,0,0,.18)', animation: 'floatup .3s ease', maxHeight: '78%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
このアプリについて
ハッカソン作品の注意事項・プライバシーの扱い
{LEGAL_SECTIONS.map(sec => (
{sec.title}

{sec.body}

))}
本アプリを使いはじめた時点で、上記に同意したものとみなします。
データの削除をご希望の場合は、制作者までご連絡ください。
閉じる
); } window.AboutAppDialog = AboutAppDialog; /* ─────────── 日付ユーティリティ ─────────── */ const todayMid = () => { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }; // "YYYY-MM-DD" → {y,m,d} / 失敗時 null function parseISODate(s) { const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(s || ''); return m ? { y: +m[1], mo: +m[2], d: +m[3] } : null; } // 指定年月の events / since からカレンダーのマークを作る function buildMarks(year, month, events, since) { const marks = {}; (events || []).forEach(ev => { const p = parseISODate(ev.date); if (p && p.y === year && p.mo === month) marks[p.d] = { c: 'var(--partner)', title: ev.title || 'よてい' }; }); if (since) { const start = new Date(since.year, since.month - 1, since.day); const ds = Math.floor((todayMid() - start) / 86400000); // 直近の100日マイルストーン日 + 当年の周年日がこの月に入るならマーク const nextHundred = Math.ceil((Math.max(ds, 0) + 1) / 100) * 100; const mDate = new Date(start.getTime() + nextHundred * 86400000); if (mDate.getFullYear() === year && mDate.getMonth() + 1 === month) marks[mDate.getDate()] = { c: 'var(--gold)', title: `${nextHundred}日記念` }; const annivThisYear = new Date(year, since.month - 1, since.day); if (annivThisYear.getMonth() + 1 === month) marks[since.day] = { c: 'var(--amethyst)', title: '記念日' }; } return marks; } /* ─────────── shared month grid(実年月で描画)─────────── */ const WD = ['日', '月', '火', '水', '木', '金', '土']; function MonthGrid({ year, month, selected, onSelect, marks = {}, today, compact = false }) { const now = new Date(); const y = year || now.getFullYear(); const mo = month || (now.getMonth() + 1); const days = new Date(y, mo, 0).getDate(); const startDow = new Date(y, mo - 1, 1).getDay(); const cells = []; for (let i = 0; i < startDow; i++) cells.push(null); for (let d = 1; d <= days; d++) cells.push(d); while (cells.length % 7) cells.push(null); // compact: ホームでカレンダーが場所を取りすぎないよう、セル・文字・マークを一回り小さく const dim = compact ? 25 : 30; const numFont = compact ? 13 : 15.5; const dotSize = compact ? 4 : 5; return (
{WD.map((w, i) => (
{w}
))}
{cells.map((d, i) => { if (!d) return
; const dow = i % 7; const isToday = d === today; const isSel = d === selected; const mk = marks[d]; return ( ); })}
); } /* ════════ HOME ════════ */ function HomeScreen() { const { go, t, live } = W.useApp(); const isLive = !!(live.user && live.coupleId); const myName = live.userDoc?.name || ME_FALLBACK; const partnerName = live.partner?.name || PARTNER_FALLBACK; const events = (isLive && live.events) ? live.events.map(d => W.normalizeEvent(d, live.user.uid)) .sort((a, b) => { const pa = parseISODate(a.raw?.date), pb = parseISODate(b.raw?.date); const ta = pa ? new Date(pa.y, pa.mo - 1, pa.d).getTime() : Infinity; const tb = pb ? new Date(pb.y, pb.mo - 1, pb.d).getTime() : Infinity; return ta - tb; }) : []; // 予定は昇順。ただし今日より前(過去)の予定は下部の折りたたみへ回す。 const nowMid = todayMid().getTime(); const eventTime = ev => { const p = parseISODate(ev.raw?.date); return p ? new Date(p.y, p.mo - 1, p.d).getTime() : Infinity; }; const upcomingEvents = events.filter(ev => eventTime(ev) >= nowMid); const pastEvents = events.filter(ev => eventTime(ev) < nowMid); const unpaired = !!live.user && !live.coupleId; /* 記念日: live.since から計算(未設定なら null → 設定導線を出す)*/ const since = live.since; const anniv = (() => { if (!since) return null; const start = new Date(since.year, since.month - 1, since.day); const daysSince = Math.floor((todayMid() - start) / 86400000); if (daysSince < 0) return null; const target = Math.ceil((daysSince + 1) / 100) * 100; const mDate = new Date(start.getTime() + target * 86400000); return { daysSince, target, daysToNext: target - daysSince, pct: Math.min(100, (daysSince / target) * 100), milestoneLabel: `${mDate.getMonth() + 1}月${mDate.getDate()}日`, milestoneISO: `${mDate.getFullYear()}-${String(mDate.getMonth() + 1).padStart(2, '0')}-${String(mDate.getDate()).padStart(2, '0')}`, sinceShort: `${since.year}.${String(since.month).padStart(2, '0')}.${String(since.day).padStart(2, '0')}`, }; })(); /* カレンダー: 実月から開始、前後ナビ */ const now = new Date(); const [calY, setCalY] = useStateM(now.getFullYear()); const [calM, setCalM] = useStateM(now.getMonth() + 1); 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); setSelDay(null); }; const calMarks = buildMarks(calY, calM, live.events, since); const calToday = (calY === now.getFullYear() && calM === now.getMonth() + 1) ? now.getDate() : null; /* カレンダーの予定がある日をタップ → 下部の該当日の予定カードへスクロール (1日に複数予定があり得るので、詳細を開かず一覧の該当位置へ寄せる)*/ const cardRefs = useRefM({}); cardRefs.current = {}; // 毎レンダーでクリア(ref callback が commit 時に再登録) const [selDay, setSelDay] = useStateM(null); const [pastOpen, setPastOpen] = useStateM(false); // 過去の予定の折りたたみ const pendingScroll = useRefM(null); // 過去展開後にスクロールする対象 iso const pad2 = n => String(n).padStart(2, '0'); const isoOf = (y, m, d) => `${y}-${pad2(m)}-${pad2(d)}`; // 該当カードへスムーススクロール+一瞬ハイライト(リング+淡い点滅) const scrollToCard = (iso) => { const node = cardRefs.current[iso]; if (!node) return; node.scrollIntoView({ behavior: 'smooth', block: 'center' }); const card = node.firstElementChild || node; if (card && card.animate) { const ac = getComputedStyle(document.documentElement).getPropertyValue('--amethyst').trim() || '#8E7DA6'; const drop = '0 14px 30px -22px rgba(61,54,44,.5)'; setTimeout(() => { card.animate([ { boxShadow: `0 0 0 3px ${ac}, ${drop}`, offset: 0 }, { boxShadow: `0 0 0 3px ${ac}, ${drop}`, offset: 0.55 }, { boxShadow: `0 0 0 0 rgba(0,0,0,0), ${drop}`, offset: 1 }, ], { duration: 1300, easing: 'ease-out' }); }, 320); // スムーススクロールの完了を待ってから光らせる } }; const onDayTap = (day) => { setSelDay(day); const iso = isoOf(calY, calM, day); const isPast = new Date(calY, calM - 1, day).getTime() < nowMid; // 過去の予定なら、まず折りたたみを開いてからスクロール(DOM反映を待つ) if (isPast && !pastOpen) { pendingScroll.current = iso; setPastOpen(true); } else scrollToCard(iso); }; useEffM(() => { if (pastOpen && pendingScroll.current) { const iso = pendingScroll.current; pendingScroll.current = null; setTimeout(() => scrollToCard(iso), 60); // 展開描画後にスクロール } }, [pastOpen]); /* 「なんでも、相談」は上へスクロールして通り過ぎたら、画面下部に固定バーとして残す。 まだ下にあって未到達のうちは出さない(=Bodyの上端より上へ抜けたときだけ) */ const bodyRef = useRefM(null); const chatEntryRef = useRefM(null); const [chatPinned, setChatPinned] = useStateM(false); useEffM(() => { const root = bodyRef.current, target = chatEntryRef.current; if (!root || !target || !window.IntersectionObserver) return; const io = new IntersectionObserver(([e]) => { const rb = e.rootBounds; setChatPinned(!e.isIntersecting && !!rb && e.boundingClientRect.bottom <= rb.top); }, { root, threshold: 0 }); io.observe(target); return () => io.disconnect(); }, []); /* チャットへの遷移: タップしたピルの矩形を渡して shared-element モーフさせる。 ジェスチャ内で __prefocus → キーボードを先に呼び、フォーカスは遷移先の textarea が引き継ぐ。 */ const openChat = (seed, el) => { let hero = null; if (el) { const r = el.getBoundingClientRect(); hero = { left: r.left, top: r.top, width: r.width, height: r.height }; } window.__prefocus && window.__prefocus(); go('home-chat', { seed, hero, focus: true }); }; // 予定カード1枚(これから/過去 で共通利用。過去は compact で一回り小さく) const renderCard = (ev, compact) => { const p = parseISODate(ev.raw?.date); const iso = p ? isoOf(p.y, p.mo, p.d) : null; return (
{ if (el && iso && !cardRefs.current[iso]) cardRefs.current[iso] = el; }}> go(ev.planStatus === 'ready' ? 'plan-reveal' : 'plan-pending', { ev })} style={{ cursor: 'pointer' }}>
{ev.date}({ev.dow}) {ev.planStatus !== 'ready' && ( 考え中 )}
{ev.title}
{ev.area}
); }; return (
{/* slim header */}
こんにちは、{myName}
{/* ⓪ unpaired banner */} {unpaired && ( )} {/* ① anniversary counter(live.since 未設定なら設定導線)*/} {anniv ? ( ) : (!unpaired && ( ))} {/* ② shared month calendar(コンパクト表示) */}
{calM}月{calY}
{[['記念日', 'var(--amethyst)'], ['1000日', 'var(--gold)'], ['よてい', 'var(--partner)']].map(([l, col]) => (
{l}
))}
{/* ③ chat entry */}
なんでも、相談
{HOME_SUGGEST.map(s => ( ))}
{/* ④ schedule cards */}
よていカード · {upcomingEvents.length}
{isLive && events.length === 0 && (
まだ予定がありません
最初のデートをつくって、
ふたりの本音を集め始めましょう
)}
{upcomingEvents.map(ev => renderCard(ev))}
{isLive && events.length > 0 && upcomingEvents.length === 0 && (
これからの予定はまだありません
)} {/* 過去の予定(折りたたみ)— 開いた中身が「トグルの中」に見えるよう、 ヘッダと一覧をひとつの枠で包む。中身は一回り小さいカードで階層をつける。 */} {pastEvents.length > 0 && (
{pastOpen && (
{pastEvents.map(ev => renderCard(ev, true))}
)}
)} {/* add */} {chatPinned &&
/* 固定バーが最後の要素に被らないように */} {/* 「なんでも、相談」固定バー(本体を通り過ぎたあいだだけ) */} {chatPinned && ( )}
); } /* ─────────── drag-to-select time band ─────────── */ const H0 = 6, H1 = 24; function fmtH(h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return `${String(hh).padStart(2, '0')}:${mm === 30 ? '30' : '00'}`; } function isNight(h) { return h < 7 || h >= 18; } function TimeBand({ value, onChange }) { const [range, setRange] = useStateM(value || { s: 13, e: 19 }); const trackRef = useRefM(null); const drag = useRefM(null); const snap = useRefM(null); const pct = h => ((h - H0) / (H1 - H0)) * 100; // 範囲が変わったら親に通知(プリフィル値で初期化+ドラッグ変更を反映) useEffM(() => { if (onChange) onChange(range); }, [range.s, range.e]); useEffM(() => { const hourAt = clientX => { const r = trackRef.current.getBoundingClientRect(); let h = H0 + ((clientX - r.left) / r.width) * (H1 - H0); h = Math.round(h * 2) / 2; return Math.max(H0, Math.min(H1, h)); }; const move = e => { if (!drag.current) return; e.preventDefault(); if (drag.current === 'band') { const r = trackRef.current.getBoundingClientRect(); const dh = Math.round(((e.clientX - snap.current.x) / r.width) * (H1 - H0) * 2) / 2; const dur = snap.current.e - snap.current.s; let ns = snap.current.s + dh, ne = snap.current.e + dh; if (ns < H0) { ns = H0; ne = H0 + dur; } if (ne > H1) { ne = H1; ns = H1 - dur; } setRange({ s: ns, e: ne }); } else { const h = hourAt(e.clientX); setRange(R => drag.current === 's' ? { s: Math.min(h, R.e - 1), e: R.e } : { s: R.s, e: Math.max(h, R.s + 1) }); } }; const up = () => { drag.current = null; }; window.addEventListener('pointermove', move, { passive: false }); window.addEventListener('pointerup', up); return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; }, []); const startDrag = (mode, e) => { e.preventDefault(); drag.current = mode; snap.current = { x: e.clientX, s: range.s, e: range.e }; }; const ticks = [6, 9, 12, 15, 18, 21, 24]; const handle = (which, h) => { const night = isNight(h); return (
startDrag(which, e)} style={{ position: 'absolute', left: `${pct(h)}%`, top: '50%', transform: 'translate(-50%,-50%)', width: 44, height: 44, borderRadius: '50%', cursor: 'grab', touchAction: 'none', zIndex: 3, background: '#fff', border: `2.5px solid ${night ? 'var(--amethyst)' : 'var(--gold)'}`, boxShadow: '0 6px 16px -6px rgba(61,54,44,.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', }}>
{fmtH(h)}
); }; const dur = range.e - range.s; return (
時間帯
{fmtH(range.s)} – {fmtH(range.e)} 約{Math.round(dur)}時間
{/* track */}
{/* dim outside selection */}
{/* selection band */}
startDrag('band', e)} style={{ position: 'absolute', top: 0, bottom: 0, left: `${pct(range.s)}%`, width: `${pct(range.e) - pct(range.s)}%`, cursor: 'grab', touchAction: 'none', borderRadius: 4, boxShadow: 'inset 0 0 0 2.5px rgba(255,255,255,.9)', }} /> {handle('s', range.s)} {handle('e', range.e)}
{/* ticks */}
{ticks.map(tk => ( {tk} ))}
日中 ハンドルをドラッグして範囲を選択
); } /* ════════ EVENT CREATE ════════ */ // 時間ヘルパ: "13:30"→13.5 / 13.5→"13:30"(TimeBand は 0.5 刻み) const parseHM = s => { const [H, M] = String(s).split(':').map(Number); return (H || 0) + (M >= 30 ? 0.5 : 0); }; const hhmm = h => `${String(Math.floor(h)).padStart(2, '0')}:${(h % 1) ? '30' : '00'}`; const parseTimeRange = tr => (Array.isArray(tr) && tr.length === 2) ? { s: parseHM(tr[0]), e: parseHM(tr[1]) } : null; function EventCreateScreen({ params }) { const { go, back, live, toast } = W.useApp(); // initISO/initTitle/initArea/initTimeRange: 記念日タップや「デート作成サジェスト」から事前入力で開かれる const now = new Date(); const init = (() => { const p = params?.initISO && parseISODate(params.initISO); return p ? { y: p.y, mo: p.mo, d: p.d } : { y: now.getFullYear(), mo: now.getMonth() + 1, d: now.getDate() }; })(); const [calY, setCalY] = useStateM(init.y); const [calM, setCalM] = useStateM(init.mo); const [selDay, setSelDay] = useStateM(init.d); const [title, setTitle] = useStateM(params?.initTitle || ''); const [area, setArea] = useStateM(params?.initArea || '渋谷あたり'); const [timeRange, setTimeRange] = useStateM(parseTimeRange(params?.initTimeRange) || { s: 13, e: 19 }); const [busy, setBusy] = useStateM(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 create = async () => { if (!live.coupleId) { toast('ペアリングが必要です'); return; } 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}日`; setBusy(true); try { const evData = { title: title.trim() || 'ただの週末', date: iso, dateLabel, dow, area: area.trim() || '都内', timeRange: [hhmm(timeRange.s), hhmm(timeRange.e)], }; // ホームチャットからの事前入力に要約があれば、秘密チャットへの引き継ぎ用に一緒に保存する。 // 末尾の句読点は正規化しておく(プラン相談画面のイントロ文に埋め込むため。AIが句点付きで // 返しても「〜。とのことだったね。」のように文が二重に終止しないようにする保険)。 if (params?.initSummary) { const cleanSummary = params.initSummary.trim().replace(/[。.!!??]+$/, ''); if (cleanSummary) evData.homeChatSummary = cleanSummary; } const id = await window.Amulet.createEvent(live.coupleId, evData); // raw に ISO/timeRange を載せておくと、plan-pending 側で即座に編集・時間表示ができる // 作成済みなので、戻ってこの入力フォームに帰らせない(履歴を置き換える) go('plan-pending', { ev: { id, live: true, date: dateLabel, dow, title: evData.title, area: evData.area, progress: 0.04, weather: 'sun', planStatus: 'none', raw: { id, ...evData } }, fresh: true }, { replace: true }); } catch (e) { console.error(e); toast(window.Amulet.friendlyError(e)); } finally { setBusy(false); } }; return (

大枠だけ、ざっくり決めましょう。
細かいことは、これから少しずつ。

{/* date — calendar */}
{calM}月 {calY}
{/* time band */}
{busy ? 'つくっています…' : 'つくる'}
); } /* ─────────── 記念日マイルストーン計算 ─────────── */ function computeMilestones(since) { const DOW = ['日', '月', '火', '水', '木', '金', '土']; const start = new Date(since.year, since.month - 1, since.day); const today = new Date(); today.setHours(0, 0, 0, 0); const daysSince = Math.floor((today - start) / 86400000); if (daysSince < 0) return []; const all = []; let d = Math.ceil((daysSince + 1) / 100) * 100; for (let i = 0; i < 10; i++, d += 100) { const date = new Date(start.getTime() + d * 86400000); all.push({ date, label: `${d}日記念` }); } for (let yr = 1; yr <= 8; yr++) { const date = new Date(since.year + yr, since.month - 1, since.day); if (date > today) all.push({ date, label: `${yr}周年記念` }); } return all .filter(m => m.date > today) .sort((a, b) => a.date - b.date) .slice(0, 8) .map(m => { const daysLeft = Math.floor((m.date - today) / 86400000); const dt = m.date; return { label: m.label, dateStr: `${dt.getFullYear()}年${dt.getMonth() + 1}月${dt.getDate()}日(${DOW[dt.getDay()]})`, daysLeft, }; }); } /* ════════ SETTINGS ════════ */ const DEFAULT_SINCE = { year: new Date().getFullYear(), month: 1, day: 1 }; function SettingsScreen() { const { go, live, toast, theme, setTheme, themes } = W.useApp(); const myName = live.userDoc?.name || ME_FALLBACK; const myEmail = live.userDoc?.email || ''; const partnerName = live.partner?.name || PARTNER_FALLBACK; const logout = async () => { try { await window.Amulet.signOut(); } catch (e) { console.error(e); } location.href = location.origin + location.pathname; // 状態を完全リセット }; const [datePicker, setDatePicker] = useStateM(false); const [milestoneOpen, setMilestoneOpen] = useStateM(false); const [legalOpen, setLegalOpen] = useStateM(false); // 最寄駅(プロフィール): 真実は Firestore(live.userDoc)。保存すると listener で即反映 const myStation = live.userDoc?.nearestStation || ''; const [stationOpen, setStationOpen] = useStateM(false); const [tmpStation, setTmpStation] = useStateM(myStation); const [stationBusy, setStationBusy] = useStateM(false); const saveStation = async () => { setStationBusy(true); try { await window.Amulet.saveProfile({ nearestStation: tmpStation.trim().slice(0, 30) }); setStationOpen(false); } catch (e) { console.error(e); toast(window.Amulet.friendlyError(e)); } finally { setStationBusy(false); } }; const sinceDate = live.since || null; // 真実は Firestore(live)。保存すると listener で即反映 const [tmpDate, setTmpDate] = useStateM(sinceDate || DEFAULT_SINCE); const saveSince = async () => { if (!live.coupleId) { toast('ペアリングが必要です'); setDatePicker(false); return; } setDatePicker(false); try { await window.Amulet.setAnniversary(live.coupleId, { ...tmpDate }); } catch (e) { console.error(e); toast(window.Amulet.friendlyError(e)); } }; const sinceLabelFmt = d => (d ? `${d.year}年${d.month}月${d.day}日` : '未設定'); const milestones = sinceDate ? computeMilestones(sinceDate) : []; const daysSince = sinceDate ? Math.floor((new Date() - new Date(sinceDate.year, sinceDate.month - 1, sinceDate.day)) / 86400000) : null; const nextMilestone = milestones[0]; const Row = ({ icon, label, detail, danger, onClick, last, toggleVal, onToggle }) => (
{label} {detail && {detail}} {onToggle ? ( ) : onClick && }
); const Group = ({ header, children }) => (
{header}
{children}
); const selStyle = { flex: 1, padding: '11px 10px', borderRadius: 10, border: '1px solid var(--line-2)', background: 'var(--surface-2)', fontFamily: 'var(--font-ui)', fontSize: 15, color: 'var(--ink)', outline: 'none' }; return (
{/* profile */}
{myName}
{myEmail}
go('onboarding')}>編集
{ setTmpStation(myStation); setStationOpen(true); }} last />
{partnerName}
ペアリング済み{daysSince != null ? ` · ${daysSince}日` : ''}
{/* ふたりの記念日 */} { setTmpDate(sinceDate ? { ...sinceDate } : DEFAULT_SINCE); setDatePicker(true); }} /> setMilestoneOpen(true)} last />
{(themes || []).map(th => { const on = th.id === theme; return ( ); })}
setLegalOpen(true)} last />
amulet · v1.0
{/* ── 最寄駅 編集 overlay ── */} {stationOpen && (
setStationOpen(false)} style={{ position: 'absolute', inset: 0, zIndex: 60, background: 'rgba(40,34,28,.32)', backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'flex-end', animation: 'risefade .25s' }}>
e.stopPropagation()} style={{ width: '100%', background: 'var(--paper)', borderRadius: '26px 26px 0 0', padding: '14px 22px 40px', boxShadow: '0 -10px 40px rgba(0,0,0,.18)', animation: 'floatup .3s ease' }}>
最寄駅を設定

プラン作成のとき、出発点や待ち合わせ場所の参考にします。
ふだん使う駅をひとつ入れてください。

setTmpStation(e.target.value)} placeholder="例: 渋谷駅" onKeyDown={e => e.key === 'Enter' && !stationBusy && saveStation()} style={{ width: '100%', padding: '13px 14px', borderRadius: 12, border: '1px solid var(--line-2)', background: 'var(--surface-2)', fontFamily: 'var(--font-ui)', fontSize: 16, color: 'var(--ink)', outline: 'none', marginBottom: 22, boxSizing: 'border-box' }} />
setStationOpen(false)}>キャンセル {stationBusy ? '保存中…' : '保存'}
)} {/* ── 付き合った日 date picker overlay ── */} {datePicker && (
setDatePicker(false)} style={{ position: 'absolute', inset: 0, zIndex: 60, background: 'rgba(40,34,28,.32)', backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'flex-end', animation: 'risefade .25s' }}>
e.stopPropagation()} style={{ width: '100%', background: 'var(--paper)', borderRadius: '26px 26px 0 0', padding: '14px 22px 40px', boxShadow: '0 -10px 40px rgba(0,0,0,.18)', animation: 'floatup .3s ease' }}>
付き合った日を選択
setDatePicker(false)}>キャンセル 保存
)} {/* ── 次の記念日リスト overlay ── */} {milestoneOpen && (
setMilestoneOpen(false)} style={{ position: 'absolute', inset: 0, zIndex: 60, background: 'rgba(40,34,28,.32)', backdropFilter: 'blur(2px)', display: 'flex', alignItems: 'flex-end', animation: 'risefade .25s' }}>
e.stopPropagation()} style={{ width: '100%', background: 'var(--paper)', borderRadius: '26px 26px 0 0', padding: '14px 22px 40px', boxShadow: '0 -10px 40px rgba(0,0,0,.18)', animation: 'floatup .3s ease', maxHeight: '70%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
これからの記念日
{milestones.map((m, i) => (
{m.label}
{m.dateStr}
あと{m.daysLeft}日
))} {milestones.length === 0 &&
記念日が見つかりません
}
)} {/* ── このアプリについて(ハッカソン作品の注意事項)— 共通ダイアログ ── */} setLegalOpen(false)} />
); } Object.assign(window, { HomeScreen, EventCreateScreen, SettingsScreen, MonthGrid, TimeBand, parseISODate, parseTimeRange, hhmm, WD }); })();