// DiveDex — Screens A: Welcome, Levels, Dashboard, Log Dive, Sightings, Recap const { useState, useEffect, useMemo } = React; // Convert an ISO timestamp to/from the value expected by . // datetime-local uses local time "YYYY-MM-DDTHH:MM" with no timezone. function isoToDatetimeLocal(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return ''; const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function datetimeLocalToIso(local) { if (!local) return null; const d = new Date(local); // browser parses as local time if (isNaN(d.getTime())) return null; return d.toISOString(); } // LockScreen — first screen any non-authenticated user sees. // Always offers a user-pick (Rick / Siet). Shows a password input only if // the shared password is set. Once unlocked, state.locked → false and // state.activeUserId → the picked user; persists, so a normal browser // reload skips this screen until the user explicitly Locks from Settings. function LockScreen() { const { state, actions } = useDiveDex(); const rick = state.users.find(u => u.id === 'rick'); const siet = state.users.find(u => u.id === 'siet'); const [picked, setPicked] = useState(state.activeUserId || null); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const needsPassword = !!state.authPassword; // First-run affordance: when no password is set, offer to create one // inline so users don't have to unlock-then-go-to-Settings. const [addPwOpen, setAddPwOpen] = useState(false); const [newPw, setNewPw] = useState(''); const [newPwConfirm, setNewPwConfirm] = useState(''); const handleUnlock = () => { if (!needsPassword && addPwOpen) { if (!newPw) { setError('Type a password or close the password-setup card.'); return; } if (newPw !== newPwConfirm) { setError('Passwords don\'t match.'); return; } const result = actions.unlockApp(picked, '', { setNewPassword: newPw }); if (!result.ok) setError(result.reason); else setError(null); return; } const result = actions.unlockApp(picked, password); if (!result.ok) setError(result.reason); else setError(null); }; const onKey = (e) => { if (e.key === 'Enter') handleUnlock(); }; const userCard = (u) => { const isOn = picked === u.id; return ( ); }; return (
WHO'S DIVING
Pick a profile.
{needsPassword ? 'Choose your profile and enter the shared password to continue.' : 'Choose your profile to continue. No password is set yet — anyone with this device can unlock. Add one below if you want to keep things private.'}
{rick && userCard(rick)} {siet && userCard(siet)}
{needsPassword && (
Password
setPassword(e.target.value)} onKeyDown={onKey} autoFocus placeholder="Enter shared password" style={{ background: 'transparent', border: 0, outline: 'none', color: '#EAF5FF', width: '100%', fontFamily: 'Manrope', fontSize: 15, fontWeight: 600, }} />
)} {!needsPassword && (
{!addPwOpen ? ( ) : (
New shared password
setNewPw(e.target.value)} placeholder="Pick a shared password" autoFocus style={{ width: '100%', padding: '8px 10px', borderRadius: 10, fontSize: 13, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(130,236,255,0.14)', color: '#EAF5FF', outline: 'none', }} /> setNewPwConfirm(e.target.value)} onKeyDown={onKey} placeholder="Confirm" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, fontSize: 13, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(130,236,255,0.14)', color: '#EAF5FF', outline: 'none', }} />
Both you and your buddy will use this to unlock. You can change or clear it later in Settings.
)}
)} {error && (
{error}
)}
Casual access protection only. The password is stored on this device in plain text and travels with backups.
); } function WelcomeScreen({ nav }) { const { state } = useDiveDex(); const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId) || state.expeditions[0]; const heroCreature = expedition && expedition.hero ? expedition.hero : 'scalloped-hammerhead'; const heroName = (state.creatures.find(c => c.id === heroCreature) || {}).commonName || expedition.target || 'shark'; return (
{/* Silhouette sized to sit inside the sonar circle without the dorsal fin or cephalofoil clipping past the ring. */}

Turn every dive trip
into a level.

Log dives. Spot creatures. Level up.
First trip: hunt for the {heroName}.

!
Log after surfacing. Never use during a dive.
); } function LevelsScreen({ nav }) { const { state } = useDiveDex(); const expeditions = state.expeditions; const activeId = state.activeExpeditionId; // Compute progress per expedition from state. Active expedition uses real // dives + discovered creatures filtered by region; locked/upcoming ones // show their target species + dates only. const progressFor = (exp) => { const regional = state.creatures.filter(c => (c.regions || []).indexOf(exp.region) >= 0); const speciesFound = regional.filter(c => c.discovered).length; const speciesTotal = regional.length || exp.speciesTotal || 0; const divesDone = exp.id === activeId ? state.dives.length : 0; const divesTotal = exp.diveTotal || 4; // XP attributable to this expedition — for now, only the active one earns // dive XP, so allocate teamXp to it. Locked expeditions show 0. const xpEarned = exp.id === activeId ? state.xp.teamXp : 0; return { speciesFound, speciesTotal, divesDone, divesTotal, xpEarned }; }; const active = expeditions.find(e => e.id === activeId) || expeditions[0]; const upcoming = expeditions.filter(e => e.id !== activeId); // All-time totals across every expedition const allRegional = expeditions.reduce((acc, exp) => { state.creatures.forEach(c => { if ((c.regions || []).indexOf(exp.region) >= 0) acc.add(c.id); }); return acc; }, new Set()); const allTotalSpecies = allRegional.size; const allDiscovered = state.creatures.filter(c => c.discovered && allRegional.has(c.id)).length; const allDiveCap = expeditions.reduce((sum, e) => sum + (e.diveTotal || 0), 0); const allDives = state.dives.length; return (
} right={}/>
Active
nav('dashboard')}/> {upcoming.length > 0 && <>
Upcoming
{upcoming.map(l => )}
}
All-time totals
across all levels
); } function LevelCard({ level, index, progress, primary, onClick }) { const locked = level.state === 'locked'; const accent = level.accent || '#31D7FF'; return (
{primary &&
} {locked &&
LOCKED
}
{level.emoji}
LEVEL {(typeof index === 'number' ? index : 0) + 1} {primary && Active}
{level.title}
{level.sub} · {level.date}
{primary && progress && (
{progress.divesDone}/{progress.divesTotal} dives {progress.speciesFound}/{progress.speciesTotal} species {progress.xpEarned.toLocaleString()} XP
)} {!primary && (
Target · {level.target}
)}
); } function DashboardScreen({ nav }) { const { state } = useDiveDex(); const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId) || state.expeditions[0]; const rick = state.users.find(u => u.id === 'rick'); const siet = state.users.find(u => u.id === 'siet'); // Expedition music is owned by App (so it survives screen navigation) — // see the music effect in app.jsx. Nothing to do here. const region = expedition ? expedition.region : null; const regionalCreatures = region ? state.creatures.filter(c => (c.regions || []).indexOf(region) >= 0) : state.creatures; const speciesFound = regionalCreatures.filter(c => c.discovered).length; const speciesTotal = regionalCreatures.length || expedition.speciesTotal || 0; const divesDone = state.dives.length; const divesTotal = expedition.diveTotal || 4; const badgesUnlocked = state.unlockedAchievementIds.length; const teamXp = state.xp.teamXp; const nextTitleAt = 8000; // placeholder until ranks system lands const pearls = state.currencies.pearls; // Backup reminder — surfaced when local progress exists and no recent backup. const showBackupNudge = (divesDone > 0 || badgesUnlocked > 0) && window.DD_BACKUP && window.DD_BACKUP.isBackupStale(state.lastBackupAt); return (
nav('levels')}>‹} right={ nav('settings')}>⌥} />
{/* Hero progression */}
TEAM XP
{teamXp.toLocaleString()}
{showBackupNudge && (
nav('settings')} className="dd-glass" style={{ marginTop: 10, padding: '10px 12px', borderRadius: 12, cursor: 'pointer', background: 'linear-gradient(160deg, rgba(99,255,210,0.08), rgba(11,42,68,0.4))', borderColor: 'rgba(99,255,210,0.22)', display: 'flex', alignItems: 'center', gap: 10, }}>
Progress saved locally. Export a backup if you want to keep it safe.
)} {/* Quick stats — tappable. Dives → most recent recap, Species → Dex, Badges → Achievements. */}
0 ? () => nav('recap', state.dives[state.dives.length - 1].id) : () => nav('log')}/> nav('dex')}/> nav('achievements')}/>
{/* Next mission — derive from real state */} {(() => { const lastDive = state.dives.length ? state.dives[state.dives.length - 1] : null; const tripFull = state.dives.length >= divesTotal; if (tripFull) { return ( <>
Trip complete
EXPEDITION DONE
{state.dives.length} of {divesTotal} dives logged
Tap a dive in the trip plan below for its recap.
); } const nextNo = state.dives.length + 1; const carryOverSite = lastDive && lastDive.site ? lastDive.site : (expedition.target ? `Hunt: ${expedition.target}` : 'Upcoming dive'); const carryOverOp = lastDive && lastDive.op ? lastDive.op : (expedition.sub || 'Trip plan'); const carryOverGas = lastDive && lastDive.gas ? lastDive.gas : 'Nitrox 32'; return ( <>
Next mission
Dive {nextNo} of {divesTotal}
lastDive ? nav('log-similar', lastDive.id) : nav('log')} style={{ padding: 0, borderRadius: 20, overflow: 'hidden', cursor: 'pointer', background: 'linear-gradient(140deg, rgba(215,180,106,0.16), rgba(11,42,68,0.4) 60%)', borderColor: 'rgba(215,180,106,0.3)' }}>
🦈
{lastDive ? 'LOG NEXT DIVE' : 'LOG FIRST DIVE'}
{lastDive ? `Dive ${nextNo} · ${carryOverSite}` : `Begin ${expedition.title}`}
{lastDive ? `${carryOverOp} · ${carryOverGas}` : `Target · ${expedition.target}`}
{expedition.target && Target · {expedition.target}} {lastDive ? Tap to log similar : Tap to log} {lastDive && lastDive.gas && {lastDive.gas} {lastDive.nitrox || ''}}
); })()} {/* Status chips — derived from real side-quest + Air Zen state */}
Status
{(() => { const completedToday = state.sideQuests.filter(q => q.state === 'completed').length; const activeQuests = state.sideQuests.filter(q => q.state === 'active'); const lockedQuests = state.sideQuests.filter(q => q.state === 'locked'); // Air Zen streak — count consecutive most-recent dives that earned the bonus let zenStreak = 0; for (let i = state.dives.length - 1; i >= 0; i--) { if (state.dives[i].airZen) zenStreak++; else break; } const chips = []; if (activeQuests.length > 0) chips.push( {activeQuests.length} active side quest{activeQuests.length === 1 ? '' : 's'} ); if (completedToday > 0) chips.push( {completedToday} side quest{completedToday === 1 ? '' : 's'} done ); if (zenStreak > 0) chips.push( Air Zen streak ×{zenStreak} ); if (lockedQuests.length > 0) chips.push( {lockedQuests.length} locked ); if (chips.length === 0) chips.push( No status yet — log a dive or complete a side quest ); return chips; })()}
{/* Trip plan */}
Trip plan
{(() => { // Build trip plan: real logged dives + upcoming placeholders. const logged = state.dives.map(d => ({ ...d, status: 'logged', date: d.createdAt ? new Date(d.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '', xp: d.totalXp || 0, sightings: (state.sightings.filter(s => s.diveId === d.id).length), })); const remaining = Math.max(0, divesTotal - logged.length); const upcoming = Array.from({ length: remaining }).map((_, i) => { const isNext = i === 0; return { id: 'plan-' + i, no: logged.length + i + 1, // Next dive gets the "Hunt" hint; later placeholders just // read "Dive N · —" so the trip plan doesn't show four // identical "Hunt: Hammerhead" rows. The em-dash is the // "TBD" placeholder for sites you haven't logged yet. site: isNext ? (expedition.target ? `Hunt: ${expedition.target}` : 'Next dive') : '—', op: isNext ? (expedition.sub || 'Trip plan') : 'Upcoming', date: '', status: isNext ? 'next' : 'locked', }; }); const rows = [...logged, ...upcoming]; return rows.length === 0 ? (
No dives planned yet. Log your first dive to start the trip plan.
) : rows.map((d, i) => ( { if (d.status === 'locked') return; if (d.status === 'logged') nav('recap', d.id); else nav('log'); }}/> )); })()}
{/* Side quests pull */}
Side quests
{state.sideQuests.slice(0, 4).map(sq => (
nav('sidequests')} className="dd-glass" style={{ flex: '0 0 150px', padding: 12, borderRadius: 14, cursor: 'pointer' }}>
{sq.badge}
{sq.title}
{sq.sub}
{sq.state === 'completed' ? '✓ Done' : sq.state === 'active' ? '· Active' : 'Locked'} +{sq.rewardXp || sq.xp || 0}
))}
{/* Safety reminder */}
!
Post-dive logbook only. Not a dive computer.
); } function RickSietRow({ person }) { return (
{person.name}
{person.xp.toLocaleString()}
); } function QuickStat({ icon, value, label, color, onClick }) { return (
{icon}
{value}
{label} {onClick && }
); } function DivePlanRow({ dive, last, onClick }) { const isLogged = dive.status === 'logged'; const isNext = dive.status === 'next'; const isLocked = dive.status === 'locked'; return (
{isLogged ? '✓' : dive.no}
Dive {dive.no} · {dive.site}
{dive.date} {isLogged && `· ${dive.maxDepth}m / ${dive.time}min`}
{isLogged && +{dive.xp}} {isNext && Next →}
); } function LogDiveScreen({ nav, editDiveId, templateDiveId }) { const { state, actions } = useDiveDex(); const editing = !!editDiveId; const templating = !editing && !!templateDiveId; const existingDive = editing ? state.dives.find(d => d.id === editDiveId) : null; const templateDive = templating ? state.dives.find(d => d.id === templateDiveId) : null; const existingSightings = editing && existingDive ? state.sightings .filter(s => s.diveId === existingDive.id) .map(s => ({ creatureId: s.creatureId, count: s.count, firstSpotter: s.firstSpotter })) : null; const [step, setStep] = useState(0); const [form, setForm] = useState(() => { if (existingDive) { return { loggedAt: isoToDatetimeLocal(existingDive.createdAt) || isoToDatetimeLocal(new Date().toISOString()), site: existingDive.site || '', op: existingDive.op || '', buddy: existingDive.buddy || 'Rick & Siet', maxDepth: existingDive.maxDepth || 0, time: existingDive.time || 0, temp: existingDive.temp || 0, vis: existingDive.vis || 0, gas: existingDive.gas || 'Air', nitrox: existingDive.nitrox || 21, startBar: existingDive.startBar || 200, endBar: existingDive.endBar || 50, mood: existingDive.mood || 'zen', current: existingDive.current || 'mild', notes: existingDive.notes || '', quote: existingDive.quote || '', buddyCheck: existingDive.buddyCheck !== false, photos: existingDive.photos || existingDive.photoCount || 0, }; } if (templateDive) { // Carry the site/op/buddy/conditions over, reset everything dive-specific // (pressures, notes, photos) so the user fills the new-dive details. // Default loggedAt to NOW (template is for "another dive today" not a // copy of the original timestamp). return { loggedAt: isoToDatetimeLocal(new Date().toISOString()), site: templateDive.site || '', op: templateDive.op || '', buddy: templateDive.buddy || 'Rick & Siet', maxDepth: templateDive.maxDepth || 0, time: templateDive.time || 0, temp: templateDive.temp || 0, vis: templateDive.vis || 0, gas: templateDive.gas || 'Air', nitrox: templateDive.nitrox || 21, startBar: 200, endBar: 50, mood: 'zen', current: templateDive.current || 'mild', notes: '', quote: '', buddyCheck: true, photos: 0, }; } return { loggedAt: isoToDatetimeLocal(new Date().toISOString()), site: 'Mikomoto Hammers', op: 'Ito Diving Service', buddy: 'Rick & Siet', maxDepth: 24, time: 35, temp: 20, vis: 18, gas: 'Nitrox', nitrox: 32, startBar: 210, endBar: 65, mood: 'awe', current: 'strong', notes: '', quote: '', buddyCheck: true, photos: 3, }; }); const [sightings, setSightings] = useState(() => { if (existingSightings) return existingSightings; if (templating) return []; // Fresh sightings list — this is a new dive return [ { creatureId: 'scalloped-hammerhead', count: 14, firstSpotter: 'siet' }, { creatureId: 'eagle-ray', count: 3, firstSpotter: 'rick' }, { creatureId: 'green-turtle', count: 1, firstSpotter: 'siet' }, ]; }); const steps = ['Basics', 'Sightings', 'Vibe', 'Recap']; // When editing, pull the existing photos for this dive out of IndexedDB so // they show up in the attach field with delete affordances. React.useEffect(() => { if (!editing || !editDiveId || !window.DD_PHOTOS) return; window.DD_PHOTOS.getPhotosByDive(editDiveId).then(existing => { setForm(f => ({ ...f, _existingPhotos: existing })); }).catch(() => {}); }, [editing, editDiveId]); const saveAndGo = async () => { // Convert the form's local-time picker value back to an ISO timestamp // for storage. Strip helper fields (loggedAt, _pendingPhotos, // _existingPhotos) so they don't leak into the dive record. const { loggedAt, _pendingPhotos, _existingPhotos, ...rest } = form; const isoLoggedAt = datetimeLocalToIso(loggedAt); // Total photo count for scoring: already-stored + freshly attached. const photoCount = ((_existingPhotos && _existingPhotos.length) || 0) + ((_pendingPhotos && _pendingPhotos.length) || 0); const payload = { ...rest, createdAt: isoLoggedAt, photos: photoCount }; let diveId; if (editing) { actions.updateDive(editDiveId, payload, sightings); diveId = editDiveId; } else { diveId = actions.logDive(payload, sightings); } // Persist any pending photos against the dive id (don't block nav on it — // the Recap will pick them up reactively from IndexedDB on render). if (window.DD_PHOTOS && _pendingPhotos && _pendingPhotos.length > 0) { window.DD_PHOTOS.addPhotos(diveId, _pendingPhotos).catch(() => {}); } nav('recap', diveId); }; const next = () => step < steps.length - 1 ? setStep(step + 1) : saveAndGo(); const back = () => step > 0 ? setStep(step - 1) : nav(editing ? 'recap' : 'dashboard', editing ? editDiveId : undefined); const onDelete = () => { if (!editing) return; // Build an impact preview: which creatures will be un-discovered because // this dive is the only place they've been spotted? (Pure read — same // logic as deleteDive's rollback in context.jsx.) const undiscoverNames = state.creatures .filter(c => c.firstSeenDiveId === editDiveId && !state.sightings.some(s => s.diveId !== editDiveId && s.creatureId === c.id) ) .map(c => c.commonName); const undiscoverNote = undiscoverNames.length > 0 ? `\n\nThese ${undiscoverNames.length === 1 ? 'species will be removed' : 'species will be removed'} from the Dex (only spotted on this dive):\n• ${undiscoverNames.join('\n• ')}` : '\n\nNo creatures will leave the Dex — every species you saw was also logged on another dive.'; const xpLost = (existingDive && existingDive.totalXp) ? existingDive.totalXp : 0; const pearlsLost = (existingDive && existingDive.pearls) ? existingDive.pearls : 0; const ok = window.confirm( `Delete Dive ${existingDive ? existingDive.no : ''}?\n\n` + `−${xpLost.toLocaleString()} Team XP, −${pearlsLost} Pearls (clamped at 0). ` + `Achievements you unlocked stay yours.` + undiscoverNote ); if (!ok) return; actions.deleteDive(editDiveId); nav('memories'); }; return (
‹} right={} />
{editing && (
Editing this dive updates the record. XP, Pearls and badges stay as they were.
)} {templating && (
Carried over from Dive {templateDive.no}: site, operator, buddy, gas, conditions. Pressures, sightings and notes are fresh.
)}
{step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && } {editing && ( )}
); } function Field({ label, children, hint, style }) { return (
{hint && {hint}}
{children}
); } function TextIn({ value, onChange, suffix, mono, type = 'text' }) { return (
onChange(type === 'number' ? Number(e.target.value) : e.target.value)} style={{ background: 'transparent', border: 0, outline: 'none', color: '#fff', flex: 1, fontFamily: mono ? 'JetBrains Mono' : 'Manrope', fontSize: 15, fontWeight: 600, width: '100%', minWidth: 0, }}/> {suffix && {suffix}}
); } // Horizontal scroll row of pickable chips. The currently-matching chip is // highlighted. Pure suggestion layer — the underlying TextIn always wins. function QuickPickRow({ items, value, onPick }) { return (
{items.map(item => { const on = value === item; return ( ); })}
); } function SegSwitch({ value, onChange, options }) { return (
{options.map(o => ( ))}
); } function LogStepBasics({ form, setForm }) { const { state } = useDiveDex(); const set = (k) => (v) => setForm({ ...form, [k]: v }); // Pull regional sites + operators (plus any site the user has already used) // so the chips include both curated and history-derived options. const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId); const region = expedition ? expedition.region : null; const curatedSites = (window.DD_STATE.SEED_DIVE_SITES || {})[region] || []; const historySites = Array.from(new Set(state.dives.map(d => d.site).filter(Boolean))); const allSites = Array.from(new Set([...historySites, ...curatedSites])); const curatedOps = (window.DD_STATE.SEED_DIVE_OPERATORS || {})[region] || []; const historyOps = Array.from(new Set(state.dives.map(d => d.op).filter(Boolean))); const allOps = Array.from(new Set([...historyOps, ...curatedOps])); return (
set('loggedAt')(e.target.value)} style={{ background: 'transparent', border: 0, outline: 'none', color: '#EAF5FF', flex: 1, fontFamily: 'Manrope', fontSize: 14, fontWeight: 600, width: '100%', colorScheme: 'dark', }} />
{allSites.length > 0 && } {allOps.length > 0 && }