// 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 (
setPicked(u.id)} className="dd-btn" style={{
flex: 1, padding: '18px 12px', borderRadius: 18,
background: isOn
? `linear-gradient(160deg, ${u.color}22, rgba(11,42,68,0.5))`
: 'rgba(255,255,255,0.04)',
border: isOn ? `1.5px solid ${u.color}` : '1px solid rgba(130,236,255,0.14)',
boxShadow: isOn ? `0 0 22px ${u.color}55, inset 0 0 0 1px ${u.color}33` : 'none',
color: '#EAF5FF',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
cursor: 'pointer',
}}>
{u.displayName}
{u.defaultTitle}
);
};
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 && (
)}
{!needsPassword && (
{!addPwOpen ? (
{ setAddPwOpen(true); setError(null); }} className="dd-btn dd-btn-ghost" style={{
width: '100%', height: 42, borderRadius: 12, fontSize: 12.5, fontWeight: 700,
borderStyle: 'dashed',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}>
+
Add a shared password
) : (
New shared password
{ setAddPwOpen(false); setNewPw(''); setNewPwConfirm(''); setError(null); }} className="dd-btn" style={{
background: 'transparent', color: 'var(--ink-3)', fontSize: 12, fontWeight: 600,
}}>Cancel
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}
)}
{!picked
? 'Pick a profile'
: (addPwOpen && !needsPassword
? `Set password & continue as ${state.users.find(u => u.id === picked).displayName} →`
: `Continue as ${state.users.find(u => u.id === picked).displayName} →`)}
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 (
nav('dashboard')} className="dd-btn" style={{ background: 'transparent', color: 'var(--ink-3)', fontSize: 13, fontWeight: 600 }}>Skip ›
{/* 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} .
nav('dashboard')} className="dd-btn dd-btn-primary" style={{ height: 52, borderRadius: 16, fontSize: 15.5 }}>
Begin {expedition.title} →
nav('levels')} className="dd-btn dd-btn-ghost" style={{ height: 44, borderRadius: 14, fontSize: 13 }}>See all expeditions
!
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 (
<>
✓
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 */}
{(() => {
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
nav('log')} className="dd-btn" style={{ background:'transparent', color:'#82ECFF', fontSize: 12, fontWeight: 600 }}>Log next dive →
{(() => {
// 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
nav('sidequests')} className="dd-btn" style={{ background:'transparent', color:'#82ECFF', fontSize: 12, fontWeight: 600 }}>All ({state.sideQuests.length}) →
{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 ? 'Done' : 'Save'} }
/>
{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 && }
{step < steps.length - 1
? `Continue → ${steps[step + 1]}`
: (editing ? 'Save edits →' : 'Save dive · see Recap →')}
{editing && (
Delete this dive
)}
);
}
function Field({ label, children, hint, style }) {
return (
{label}
{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 (
onPick(item)} className="dd-btn" style={{
flex: '0 0 auto', padding: '5px 10px', borderRadius: 999, fontSize: 11, fontWeight: 600,
whiteSpace: 'nowrap',
background: on ? 'rgba(49,215,255,0.18)' : 'rgba(255,255,255,0.04)',
color: on ? '#82ECFF' : 'var(--ink-2)',
border: on ? '1px solid rgba(49,215,255,0.36)' : '1px solid rgba(130,236,255,0.12)',
}}>{item}
);
})}
);
}
function SegSwitch({ value, onChange, options }) {
return (
{options.map(o => (
onChange(o.value)} className="dd-btn" style={{
flex: 1, padding: '8px 10px', borderRadius: 10, fontSize: 12.5,
background: value === o.value ? 'rgba(49,215,255,0.15)' : 'transparent',
color: value === o.value ? '#82ECFF' : 'var(--ink-2)',
border: value === o.value ? '1px solid rgba(49,215,255,0.32)' : '1px solid transparent',
}}>{o.label}
))}
);
}
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',
}}
/>
set('loggedAt')(isoToDatetimeLocal(new Date().toISOString()))} className="dd-btn" style={{
padding: '4px 10px', borderRadius: 999, fontSize: 11, fontWeight: 600,
background: 'rgba(255,255,255,0.04)', color: 'var(--ink-2)',
border: '1px solid rgba(130,236,255,0.12)',
}}>Now
{
const d = new Date(); d.setHours(d.getHours() - 1);
set('loggedAt')(isoToDatetimeLocal(d.toISOString()));
}} className="dd-btn" style={{
padding: '4px 10px', borderRadius: 999, fontSize: 11, fontWeight: 600,
background: 'rgba(255,255,255,0.04)', color: 'var(--ink-2)',
border: '1px solid rgba(130,236,255,0.12)',
}}>1h ago
{
const d = new Date(); d.setHours(d.getHours() - 3);
set('loggedAt')(isoToDatetimeLocal(d.toISOString()));
}} className="dd-btn" style={{
padding: '4px 10px', borderRadius: 999, fontSize: 11, fontWeight: 600,
background: 'rgba(255,255,255,0.04)', color: 'var(--ink-2)',
border: '1px solid rgba(130,236,255,0.12)',
}}>3h ago
{
const d = new Date(); d.setDate(d.getDate() - 1); d.setHours(9, 0, 0, 0);
set('loggedAt')(isoToDatetimeLocal(d.toISOString()));
}} className="dd-btn" style={{
padding: '4px 10px', borderRadius: 999, fontSize: 11, fontWeight: 600,
background: 'rgba(255,255,255,0.04)', color: 'var(--ink-2)',
border: '1px solid rgba(130,236,255,0.12)',
}}>Yesterday 09:00
{allSites.length > 0 && }
{allOps.length > 0 && }
setForm({ ...form, _pendingPhotos: [...(form._pendingPhotos || []), ...files] })}
onRemovePending={i => setForm({ ...form, _pendingPhotos: (form._pendingPhotos || []).filter((_, j) => j !== i) })}
onRemoveExisting={id => {
if (window.DD_PHOTOS) window.DD_PHOTOS.deletePhoto(id).catch(() => {});
setForm({ ...form, _existingPhotos: (form._existingPhotos || []).filter(p => p.id !== id) });
}}
/>
);
}
// Multi-photo file picker with thumbnail grid + remove. Splits the photos
// into "existing" (already in IndexedDB, removable via DD_PHOTOS.deletePhoto)
// and "pending" (File objects from this session, removed in-memory). Object
// URLs are revoked on unmount to avoid blob leaks.
function PhotoAttachField({ pending, existing, onAdd, onRemovePending, onRemoveExisting }) {
const fileRef = React.useRef(null);
const pendingUrls = React.useMemo(() => pending.map(f => URL.createObjectURL(f)), [pending]);
React.useEffect(() => () => pendingUrls.forEach(u => URL.revokeObjectURL(u)), [pendingUrls]);
const existingUrls = React.useMemo(() => existing.map(p => URL.createObjectURL(p.blob)), [existing]);
React.useEffect(() => () => existingUrls.forEach(u => URL.revokeObjectURL(u)), [existingUrls]);
const total = pending.length + existing.length;
return (
fileRef.current && fileRef.current.click()} className="dd-btn dd-btn-ghost" style={{
height: 40, width: '100%', borderRadius: 12, fontSize: 13, fontWeight: 700,
}}>+ Add photos
{ onAdd(Array.from(e.target.files || [])); e.target.value = ''; }}
style={{ display: 'none' }}/>
{total > 0 && (
{existing.map((p, i) => (
onRemoveExisting(p.id)} className="dd-btn" style={{
position: 'absolute', top: 4, right: 4, width: 22, height: 22, padding: 0, borderRadius: 11,
background: 'rgba(0,0,0,0.65)', color: '#FF7A6B', fontSize: 13, lineHeight: 1, fontWeight: 700,
border: '1px solid rgba(255,122,107,0.36)',
}}>×
))}
{pending.map((f, i) => (
NEW
onRemovePending(i)} className="dd-btn" style={{
position: 'absolute', top: 4, right: 4, width: 22, height: 22, padding: 0, borderRadius: 11,
background: 'rgba(0,0,0,0.65)', color: '#FF7A6B', fontSize: 13, lineHeight: 1, fontWeight: 700,
border: '1px solid rgba(255,122,107,0.36)',
}}>×
))}
)}
{total} {total === 1 ? 'photo' : 'photos'} · resized to 1600px and stored locally on this device.
);
}
function LogStepImport() {
return (
📥
Import a Suunto .FIT file
Drag in or pick from Files
Choose file…
Continue with manual entry
You can also import later from settings.
›
Recent imports · Shark Scramble — 6 May 14:22.fit
);
}
function SightingsBuilder({ picked, setPicked }) {
const { state } = useDiveDex();
const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId);
const region = expedition ? expedition.region : null;
const lookup = (id) => {
const fromState = state.creatures.find(c => c.id === id);
if (fromState) return { name: fromState.commonName, rarity: fromState.rarity, familyType: fromState.familyType };
const fromLegacy = (window.CREATURES || []).find(c => c.id === id);
if (fromLegacy) return { name: fromLegacy.name, rarity: fromLegacy.rarity, familyType: 'reefFish' };
return { name: id, rarity: 'common', familyType: 'reefFish' };
};
// Editor actions
const removeAt = (i) => setPicked(picked.filter((_, j) => j !== i));
const incCount = (i, delta) => setPicked(picked.map((p, j) =>
j === i ? { ...p, count: Math.max(1, (p.count || 1) + delta) } : p));
const setCount = (i, raw) => {
// Allow empty string while editing; clamp on commit via blur in the input.
const n = Math.max(1, Math.min(999, parseInt(raw, 10) || 1));
setPicked(picked.map((p, j) => j === i ? { ...p, count: n } : p));
};
const setSpotter = (i, spotter) => setPicked(picked.map((p, j) =>
j === i ? { ...p, firstSpotter: spotter } : p));
const addCreature = (creatureId) => {
if (picked.some(p => p.creatureId === creatureId)) return;
// Default first-spotter to whoever's actively logging — saves a tap.
// User can change with the R/S/— toggle on the sighting row.
const defaultSpotter = state.activeUserId || null;
setPicked([...picked, { creatureId, count: 1, firstSpotter: defaultSpotter }]);
};
// Swap the creatureId at index `i`, preserving the existing count and
// firstSpotter. No-ops if the target is already in the list at another index.
const swapCreatureAt = (i, newCreatureId) => {
if (picked.some((p, j) => j !== i && p.creatureId === newCreatureId)) return;
setPicked(picked.map((p, j) => j === i ? { ...p, creatureId: newCreatureId } : p));
};
// Picker state — when swapTarget !== null, picking a creature replaces
// the sighting at that index instead of appending a new one.
const [pickerOpen, setPickerOpen] = React.useState(false);
const [swapTarget, setSwapTarget] = React.useState(null);
const [search, setSearch] = React.useState('');
const closePicker = () => { setPickerOpen(false); setSwapTarget(null); setSearch(''); };
const openPickerForAdd = () => { setSwapTarget(null); setPickerOpen(true); };
const openPickerForSwap = (i) => { setSwapTarget(i); setPickerOpen(true); };
const handlePick = (creatureId) => {
if (swapTarget !== null) swapCreatureAt(swapTarget, creatureId);
else addCreature(creatureId);
closePicker();
};
const pickedIds = new Set(
swapTarget !== null
? picked.filter((_, j) => j !== swapTarget).map(p => p.creatureId) // hide everything except the one being swapped
: picked.map(p => p.creatureId)
);
const candidates = state.creatures
.filter(c => !region || (c.regions || []).indexOf(region) >= 0)
.filter(c => !pickedIds.has(c.id))
.filter(c => !search || c.commonName.toLowerCase().indexOf(search.toLowerCase()) >= 0);
const rarityOrder = { mythic: 6, legendary: 5, epic: 4, rare: 3, uncommon: 2, common: 1 };
const candidatesByFamily = candidates.reduce((acc, c) => {
(acc[c.familyType] = acc[c.familyType] || []).push(c);
return acc;
}, {});
Object.values(candidatesByFamily).forEach(arr =>
arr.sort((a, b) => (rarityOrder[b.rarity] || 0) - (rarityOrder[a.rarity] || 0))
);
const familyOrder = [
'shark','ray','turtle','reefFish','macro','pelagic',
'cephalopod','mammal','invertebrate','crustacean',
'coral','sponge','wreck','aircraft',
];
// Catch unknown families at the bottom in registry order
Object.keys(candidatesByFamily).forEach(f => {
if (familyOrder.indexOf(f) < 0) familyOrder.push(f);
});
const sortedFamilies = familyOrder.filter(f => candidatesByFamily[f]);
// Suggested = top-rarity not-yet-picked in this region, max 5
const suggested = candidates.slice().sort((a, b) =>
(rarityOrder[b.rarity] || 0) - (rarityOrder[a.rarity] || 0)
).slice(0, 5);
const familyLabel = (f) => ({
shark: 'Sharks', ray: 'Rays', turtle: 'Turtles', reefFish: 'Reef fish',
macro: 'Macro', pelagic: 'Pelagic', cephalopod: 'Cephalopods',
mammal: 'Mammals', invertebrate: 'Inverts', crustacean: 'Crustaceans',
coral: 'Coral', sponge: 'Sponges', wreck: 'Wrecks', aircraft: 'Aircraft',
}[f] || f);
return (
Sightings on this dive
{picked.length === 0 && (
No sightings yet. Tap a suggestion below or "Add sighting" to pick from the Dex.
)}
{picked.map((p, i) => {
const cr = lookup(p.creatureId);
const isSwapping = swapTarget === i;
return (
{/* Identity row: tap silhouette/name to swap creature, preserves count + spotter.
Uses div+role=button instead of
so the nested input/buttons stay valid. */}
openPickerForSwap(i)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPickerForSwap(i); } }}
title="Tap to change species" style={{
cursor: 'pointer', background: 'transparent', padding: 0, color: 'inherit', textAlign: 'left',
display: 'flex', alignItems: 'center', gap: 12, flex: '1 1 auto', minWidth: 0,
}}>
{cr.name}
✎
{cr.rarity}
{/* Count stepper — type a number directly, or use −/+ */}
e.stopPropagation()}>
{ e.stopPropagation(); incCount(i, -1); }} className="dd-btn" style={{ width: 22, height: 22, borderRadius: 6, background: 'transparent', color: 'var(--ink-3)', fontSize: 14, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>−
×
setCount(i, e.target.value)}
onFocus={e => e.target.select()}
onClick={e => e.stopPropagation()}
className="dd-num"
style={{
width: 36, height: 22, border: 0, outline: 'none',
background: 'transparent', color: '#EAF5FF',
fontSize: 12, fontWeight: 700, textAlign: 'center',
appearance: 'textfield', MozAppearance: 'textfield',
}}
/>
{ e.stopPropagation(); incCount(i, 1); }} className="dd-btn" style={{ width: 22, height: 22, borderRadius: 6, background: 'transparent', color: 'var(--ink-3)', fontSize: 14, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>+
{/* Spotter toggle */}
e.stopPropagation()}>
{[{ id: null, label: '—' }, { id: 'rick', label: 'R' }, { id: 'siet', label: 'S' }].map(opt => {
const on = p.firstSpotter === opt.id;
return (
{ e.stopPropagation(); setSpotter(i, opt.id); }} className="dd-btn" style={{
width: 22, height: 22, borderRadius: 6, fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: on ? 'rgba(49,215,255,0.18)' : 'transparent',
color: on ? '#82ECFF' : 'var(--ink-3)',
border: on ? '1px solid rgba(49,215,255,0.36)' : '1px solid transparent',
}}>{opt.label}
);
})}
removeAt(i)} className="dd-btn" style={{ width: 30, height: 30, borderRadius: 10, background: 'rgba(255,255,255,0.04)', color: 'var(--ink-3)', border: '1px solid rgba(130,236,255,0.12)' }}>×
);
})}
(pickerOpen ? closePicker() : openPickerForAdd())} className="dd-btn dd-btn-ghost" style={{ marginTop: 12, width: '100%', height: 46, borderRadius: 14, fontSize: 13, borderStyle: 'dashed' }}>
{pickerOpen
? (swapTarget !== null ? '× Cancel swap' : '× Close picker')
: '+ Add sighting from Dex'}
{pickerOpen && (
{swapTarget !== null && (
Swap → {lookup(picked[swapTarget].creatureId).name}
Cancel
)}
setSearch(e.target.value)}
placeholder={swapTarget !== null ? 'Search replacement…' : 'Search creature…'}
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',
}}
/>
{sortedFamilies.length === 0 && (
No matches in this region.
)}
{sortedFamilies.map(f => (
{familyLabel(f)}
{candidatesByFamily[f].map(c => (
handlePick(c.id)} className={`dd-btn dd-rarity-${c.rarity}`} style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 10,
background: 'rgba(255,255,255,0.04)', border: '1px solid var(--r-color)',
color: '#EAF5FF', textAlign: 'left',
}}>
{c.commonName}
{c.rarity}{c.discovered ? '' : ' · new'}
))}
))}
)}
{suggested.length > 0 && (
<>
Suggested · this region
{suggested.map(c => (
addCreature(c.id)} className="dd-glass dd-btn" style={{
flex: '0 0 108px', padding: '8px 6px', borderRadius: 12, textAlign: 'center', cursor: 'pointer',
color: 'var(--ink)', whiteSpace: 'normal',
}}>
{c.commonName}
+ {c.rarity}
))}
>
)}
);
}
function LogStepVibe({ form, setForm, sightings }) {
const { state } = useDiveDex();
const set = (k) => (v) => setForm({ ...form, [k]: v });
const moods = [
{ v: 'zen', e: '😌', l: 'Zen' }, { v: 'awe', e: '🤩', l: 'Awe' },
{ v: 'stoked', e: '🤿', l: 'Stoked' }, { v: 'rinsed', e: '🥶', l: 'Rinsed' },
];
// Live Air Zen preview — same threshold the scoring engine uses
const safeReserve = state.settings.safeReserveBar || 50;
const qualifies = Number(form.endBar) >= safeReserve;
const xp = window.DD_STATE.SCORING_CONFIG.xp;
const prl = window.DD_STATE.SCORING_CONFIG.pearls;
return (
{moods.map(m => (
set('mood')(m.v)} className="dd-btn" style={{
padding: '12px 4px', borderRadius: 14, fontSize: 11.5, fontWeight: 600,
background: form.mood === m.v ? 'rgba(49,215,255,0.16)' : 'rgba(255,255,255,0.04)',
color: form.mood === m.v ? '#82ECFF' : 'var(--ink-2)',
border: form.mood === m.v ? '1px solid rgba(49,215,255,0.36)' : '1px solid rgba(130,236,255,0.10)',
}}>
{m.e}
{m.l}
))}
set('quote')(e.target.value)}
placeholder="“Was that a hammerhead or an underwater Boeing?”"
style={{ background: 'transparent', border: 0, outline: 'none', color: '#fff', width: '100%', fontFamily: 'Manrope', fontStyle: 'italic', fontSize: 13.5 }}
/>
{/* Air Zen bonus — actual qualification based on current end-of-dive bar. */}
AIR ZEN BONUS · {qualifies ? 'EARNED' : 'NOT YET'}
{qualifies ? <>
Surfaced ≥ {safeReserve} bar · +{xp.airZenBonus} XP
+{prl.airZenBonus} Pearls
> : (
Surface at {safeReserve}+ bar to earn · currently {form.endBar || 0} bar
)}
Air Zen rewards calm, safe diving — not pushing limits. Edit your end-of-dive pressure on the Basics step.
);
}
function LogStepRecapPreview({ form, sightings, editing, onGo }) {
const { state } = useDiveDex();
if (editing) {
// Edits don't re-score — show a simple summary instead of the unlock preview.
return (
✎
Save edits
Updates the dive record only. XP, Pearls and badges stay as they were the first time you saved.
);
}
// Preview: compute the same scoring the action will apply.
const preview = computeDiveScoring(
form || {}, sightings || [], state.creatures,
window.DD_STATE.SCORING_CONFIG, state.unlockedAchievementIds,
);
// Also surface "first-dive" if this would be the very first.
const previewAchievements = preview.achievementsToUnlock.slice();
if (state.dives.length === 0 && previewAchievements.indexOf('first-dive') < 0) {
previewAchievements.unshift('first-dive');
}
const ach = (id) => state.achievements.find(a => a.id === id);
return (
✨
Ready to log
Tap below to save and see recap
{previewAchievements.length > 0 && (
Will unlock
{previewAchievements.map(id => {
const a = ach(id);
if (!a) return null;
return (
);
})}
)}
);
}
Object.assign(window, {
LockScreen,
WelcomeScreen, LevelsScreen, LevelCard, DashboardScreen, LogDiveScreen,
SightingsBuilder, Field, TextIn, SegSwitch,
});