import { useState, useEffect, useRef } from "react"; const CURRENCIES = ["HKD","JPY","USD","EUR","GBP","CNY","TWD","KRW","SGD","AUD","CAD","THB","MYR","PHP"]; const CATEGORIES = ["Food & Drink","Shopping","Transport","Entertainment","Sightseeing","Other"]; const CAT_COLOR = {"Food & Drink":"#FF6B6B","Shopping":"#4ECDC4","Transport":"#45B7D1","Entertainment":"#FF9F43","Sightseeing":"#A29BFE","Other":"#8E8E93"}; const FALLBACK = {HKD:1,JPY:0.052,USD:7.78,EUR:8.45,GBP:9.85,CNY:1.07,TWD:0.24,KRW:0.0057,SGD:5.78,AUD:4.90,CAD:5.68,THB:0.22,MYR:1.74,PHP:0.135}; const fmt = n => `HK$${Number(n||0).toFixed(2)}`; const fmtDate = d => d ? new Date(d+"T00:00:00").toLocaleDateString("en-HK",{day:"numeric",month:"short",year:"numeric"}) : ""; const uid = () => `${Date.now()}_${Math.random().toString(36).slice(2,7)}`; const toB64 = f => new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result.split(",")[1]); r.onerror=rej; r.readAsDataURL(f); }); const S = { input: { width:"100%", padding:"10px 12px", border:"1.5px solid #E5E5EA", borderRadius:10, fontSize:15, fontFamily:"inherit", boxSizing:"border-box", outline:"none", background:"#FAFAFA", color:"#1D1D1F" }, inputErr: { width:"100%", padding:"10px 12px", border:"1.5px solid #FF3B30", borderRadius:10, fontSize:15, fontFamily:"inherit", boxSizing:"border-box", outline:"none", background:"#FFF5F5", color:"#1D1D1F" }, menuBtn: { background:"none", border:"none", fontSize:22, cursor:"pointer", padding:"2px 8px", color:"#8E8E93", lineHeight:1, borderRadius:8 }, dropdown: { position:"absolute", right:0, top:"110%", background:"#fff", borderRadius:14, boxShadow:"0 6px 24px rgba(0,0,0,0.14)", zIndex:200, minWidth:170, overflow:"hidden" }, dropItem: { display:"block", width:"100%", padding:"13px 16px", border:"none", background:"none", textAlign:"left", fontSize:14, cursor:"pointer", fontFamily:"inherit", color:"#1D1D1F" }, }; const btn = (t="primary") => ({ background: t==="primary"?"#007AFF": t==="danger"?"#FF3B30": "transparent", color: t==="outline"?"#007AFF":"#fff", border: t==="outline"?"1.5px solid #007AFF":"none", borderRadius:10, padding:"10px 18px", fontSize:14, fontWeight:600, cursor:"pointer", fontFamily:"inherit", transition:"opacity .15s" }); // Returns null if valid, or an error string if outside range function validateEntryDate(date, trip) { if (!trip || !date) return null; if (trip.startDate && date < trip.startDate) return `Date is before trip start (${fmtDate(trip.startDate)})`; if (trip.endDate && date > trip.endDate) return `Date is after trip end (${fmtDate(trip.endDate)})`; return null; } export default function App() { const [trips, setTrips] = useState([]); const [entries, setEntries] = useState([]); const [rates, setRates] = useState(FALLBACK); const [view, setView] = useState("main"); const [selTrip, setSelTrip] = useState(null); const [sortBy, setSortBy] = useState("date"); const [sortDir, setSortDir] = useState("desc"); const [openMenu, setOpenMenu] = useState(null); const [tripModal, setTripModal] = useState(null); const [entryModal, setEntryModal] = useState(null); const [delConfirm, setDelConfirm] = useState(null); useEffect(() => { (async () => { try { const r = await window.storage.get("trips"); if(r) setTrips(JSON.parse(r.value)); } catch {} try { const r = await window.storage.get("entries"); if(r) setEntries(JSON.parse(r.value)); } catch {} })(); fetchRates(); }, []); const fetchRates = async () => { try { const res = await fetch("https://open.er-api.com/v6/latest/HKD"); const data = await res.json(); if (data.rates) { const r = { HKD: 1 }; Object.entries(data.rates).forEach(([k,v]) => r[k] = 1/v); setRates(r); } } catch {} }; const saveTrips = async t => { setTrips(t); try { await window.storage.set("trips", JSON.stringify(t)); } catch {} }; const saveEntries = async e => { setEntries(e); try { await window.storage.set("entries", JSON.stringify(e)); } catch {} }; const toHKD = (amt, cur) => parseFloat(amt||0) * (rates[cur]||1); const tripSpend = id => entries.filter(e=>e.tripId===id).reduce((s,e)=>s+e.amountHKD,0); const handleSaveTrip = data => { if (data.id) { const updated = trips.map(t => t.id===data.id ? data : t); saveTrips(updated); if (selTrip?.id === data.id) setSelTrip(data); } else { saveTrips([...trips, { ...data, id: uid() }]); } setTripModal(null); }; const handleDeleteTrip = id => { saveTrips(trips.filter(t => t.id!==id)); saveEntries(entries.filter(e => e.tripId!==id)); if (selTrip?.id===id) { setView("main"); setSelTrip(null); } setDelConfirm(null); }; const handleSaveEntry = data => { const amountHKD = toHKD(data.amount, data.currency); const entry = { ...data, amountHKD }; if (entry.id) saveEntries(entries.map(e => e.id===entry.id ? entry : e)); else saveEntries([...entries, { ...entry, id: uid() }]); setEntryModal(null); }; const handleDeleteEntry = id => { saveEntries(entries.filter(e => e.id!==id)); setDelConfirm(null); }; const openTrip = trip => { setSelTrip(trip); setView("trip"); setSortBy("date"); setSortDir("desc"); }; const sortedEntries = selTrip ? [...entries.filter(e => e.tripId===selTrip.id)].sort((a,b) => { const m = sortDir==="asc" ? 1 : -1; return sortBy==="date" ? m*(new Date(a.date)-new Date(b.date)) : m*(a.amountHKD-b.amountHKD); }) : []; useEffect(() => { const h = () => setOpenMenu(null); window.addEventListener("click", h); return () => window.removeEventListener("click", h); }, []); return (
setOpenMenu(null)} style={{fontFamily:'-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', maxWidth:480,margin:"0 auto",minHeight:"100vh",background:"#F2F2F7",color:"#1D1D1F"}}> {/* ── MAIN PAGE ── */} {view==="main" && ( <>
✈️ Travel Budget
{trips.length===0 ? (
🗺️

No trips yet

Tap "+ Trip" to create your first trip

) : trips.map(trip => { const spend = tripSpend(trip.id); const flight = parseFloat(trip.flightCost)||0; const accom = parseFloat(trip.accomCost)||0; const budget = parseFloat(trip.budget)||0; const grand = spend + flight + accom; const remain = budget - grand; const over = budget>0 && remain<0; const cnt = entries.filter(e=>e.tripId===trip.id).length; const hasDates = trip.startDate || trip.endDate; return (
openTrip(trip)} style={{padding:"16px 16px 12px",cursor:"pointer"}}>
{trip.name}
{hasDates && (
📅 {trip.startDate ? fmtDate(trip.startDate) : "—"} → {trip.endDate ? fmtDate(trip.endDate) : "—"}
)} {budget>0 &&
Budget: {fmt(budget)}
}
e.stopPropagation()}> {openMenu===trip.id && (
)}
0 ? (over?"🔴 Over Budget":"🟢 Remaining") : "💰 Total Spent"} value={budget>0 ? fmt(Math.abs(remain)) : fmt(grand)} color={budget>0 ? (over?"#FF3B30":"#34C759") : "#007AFF"} />
openTrip(trip)} style={{padding:"10px 16px",borderTop:"1px solid #F2F2F7",cursor:"pointer",display:"flex",justifyContent:"space-between",alignItems:"center"}}> {cnt} {cnt===1?"entry":"entries"} View →
); })}
)} {/* ── TRIP DETAIL ── */} {view==="trip" && selTrip && ( <>
{selTrip.name}
{(selTrip.startDate || selTrip.endDate) && (
📅 {selTrip.startDate ? fmtDate(selTrip.startDate) : "—"} → {selTrip.endDate ? fmtDate(selTrip.endDate) : "—"}
)}
Sort: { sortBy==="date" ? setSortDir(d=>d==="asc"?"desc":"asc") : (setSortBy("date"),setSortDir("desc")); }} /> { sortBy==="amount" ? setSortDir(d=>d==="asc"?"desc":"asc") : (setSortBy("amount"),setSortDir("desc")); }} />
{sortedEntries.length===0 ? (
🧾

No entries yet

Tap "+ Entry" to log your first purchase

) : sortedEntries.map(entry => (
{entry.shopName} {entry.category}
{fmtDate(entry.date)} · {entry.origAmt} {entry.currency}
{fmt(entry.amountHKD)}
e.stopPropagation()}> {openMenu===entry.id && (
)}
))}
)} {tripModal && setTripModal(null)} />} {entryModal && setEntryModal(null)} />} {delConfirm && ( setDelConfirm(null)}>

Are you sure?

{delConfirm.type==="trip" ? "This will permanently delete the trip and all its entries." : "This entry will be permanently deleted."}

)}
); } // ── Trip Modal ───────────────────────────────────────────── function TripModal({ modal, onSave, onClose }) { const t = modal.trip; const [name, setName] = useState(t?.name||""); const [startDate, setStartDate] = useState(t?.startDate||""); const [endDate, setEndDate] = useState(t?.endDate||""); const [budget, setBudget] = useState(t?.budget||""); const [flight, setFlight] = useState(t?.flightCost||""); const [accom, setAccom] = useState(t?.accomCost||""); const [dateErr, setDateErr] = useState(""); const handleStartDate = v => { setStartDate(v); if (endDate && v && v > endDate) setDateErr("Start date cannot be after end date"); else setDateErr(""); }; const handleEndDate = v => { setEndDate(v); if (startDate && v && v < startDate) setDateErr("End date cannot be before start date"); else setDateErr(""); }; const canSave = name.trim() && !dateErr; const save = () => { if (!canSave) return; onSave({ id:t?.id, name:name.trim(), startDate, endDate, budget, flightCost:flight, accomCost:accom }); }; return (

{t ? "Edit Trip" : "New Trip"}

setName(e.target.value)} placeholder="e.g. Osaka Spring 2026" style={S.input} />
handleStartDate(e.target.value)} style={{...( dateErr ? S.inputErr : S.input ), flex:1}} /> to handleEndDate(e.target.value)} min={startDate||undefined} style={{...( dateErr ? S.inputErr : S.input ), flex:1}} />
{dateErr &&

⚠️ {dateErr}

}
setBudget(e.target.value)} placeholder="0.00" style={S.input} min="0" /> setFlight(e.target.value)} placeholder="0.00" style={S.input} min="0" /> setAccom(e.target.value)} placeholder="0.00" style={S.input} min="0" />
); } // ── Entry Modal ──────────────────────────────────────────── function EntryModal({ modal, trips, rates, onSave, onClose }) { const e = modal.entry; const [tripId, setTripId] = useState(e?.tripId || modal.tripId || trips[0]?.id || ""); const [shop, setShop] = useState(e?.shopName||""); const [date, setDate] = useState(e?.date || new Date().toISOString().split("T")[0]); const [amount, setAmount] = useState(e?.origAmt!=null ? String(e.origAmt) : ""); const [currency, setCurrency] = useState(e?.currency||"JPY"); const [category, setCategory] = useState(e?.category||"Shopping"); const [scanning, setScanning] = useState(false); const [scanErr, setScanErr] = useState(""); const fileRef = useRef(); const selectedTrip = trips.find(t => t.id===tripId); const dateError = validateEntryDate(date, selectedTrip); const preview = amount ? (parseFloat(amount)*(rates[currency]||1)).toFixed(2) : null; const canSave = shop.trim() && amount && date && tripId && !dateError; const scanReceipt = async file => { setScanning(true); setScanErr(""); try { const b64 = await toB64(file); const res = await fetch("https://api.anthropic.com/v1/messages", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ model:"claude-sonnet-4-20250514", max_tokens:500, messages:[{ role:"user", content:[ { type:"image", source:{ type:"base64", media_type: file.type||"image/jpeg", data:b64 }}, { type:"text", text:'Extract from this receipt: store/merchant name, date (YYYY-MM-DD), total amount paid (digits only, no symbols), currency code (e.g. JPY USD HKD EUR GBP TWD KRW). Respond ONLY with valid JSON: {"shopName":string|null,"date":string|null,"amount":number|null,"currency":string|null}' } ]}] }) }); const data = await res.json(); const text = (data.content||[]).map(c=>c.text||"").join(""); const m = text.match(/\{[\s\S]*?\}/); if (m) { const p = JSON.parse(m[0]); if (p.shopName) setShop(p.shopName); if (p.date) setDate(p.date); if (p.amount) setAmount(String(p.amount)); if (p.currency && CURRENCIES.includes(p.currency)) setCurrency(p.currency); } } catch { setScanErr("Couldn't read the receipt — please fill in manually."); } setScanning(false); }; const save = () => { if (!canSave) return; onSave({ id:e?.id, tripId, shopName:shop.trim(), date, amount:parseFloat(amount), origAmt:parseFloat(amount), currency, category }); }; return (

{e ? "Edit Entry" : "Add Entry"}

{!e && (
ev.target.files[0] && scanReceipt(ev.target.files[0])} /> {scanning &&
} {scanErr &&

{scanErr}

}
or fill in manually
)} {trips.length > 1 && ( )} {/* Date range hint */} {selectedTrip && (selectedTrip.startDate || selectedTrip.endDate) && (
📅 Trip dates: {selectedTrip.startDate ? fmtDate(selectedTrip.startDate) : "—"}{selectedTrip.endDate ? fmtDate(selectedTrip.endDate) : "—"}
)} setShop(ev.target.value)} placeholder="e.g. Donki Shinjuku" style={S.input} /> setDate(ev.target.value)} style={dateError ? S.inputErr : S.input} min={selectedTrip?.startDate||undefined} max={selectedTrip?.endDate||undefined} /> {dateError && (
⚠️ {dateError}. Please correct the date to save this entry.
)}
setAmount(ev.target.value)} placeholder="0" style={{...S.input,flex:1}} min="0" />
{preview &&
≈ HK${preview}
}
{CATEGORIES.map(cat=>( ))}
); } // ── Shared UI ────────────────────────────────────────────── function Overlay({ onClose, children }) { return (
e.stopPropagation()}> {children}
); } function Field({ label, children }) { return (
{children}
); } function Stat({ label, value, color="#1D1D1F" }) { return (
{label}
{value}
); } function SortPill({ label, active, dir, onClick }) { return ( ); }