/*__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 (
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 }) => (
);
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 */}
{ 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) => (
))}
{milestones.length === 0 &&
記念日が見つかりません
}
)}
{/* ── このアプリについて(ハッカソン作品の注意事項)— 共通ダイアログ ── */}
setLegalOpen(false)} />
);
}
Object.assign(window, { HomeScreen, EventCreateScreen, SettingsScreen, MonthGrid, TimeBand, parseISODate, parseTimeRange, hhmm, WD });
})();