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" && (
<>
{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}
}
)}
{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 (
);
}
function SortPill({ label, active, dir, onClick }) {
return (
);
}