// DiveDex — Screens B: Recap, Dex, Creature Detail, Team, Achievements, Side Quests, Memories, Story Card, Settings // Pulls a dive's photos out of IndexedDB and renders them as a thumbnail // grid. Self-contained — handles loading, empty, and revocation of the // object URLs on unmount. Used by Recap (single dive) and Memories // (per-day inline strips). // Aggregated trip gallery — pulls photos for the given list of dive ids, // concatenates them newest-dive-first, and renders as a tap-to-zoom grid. // Used on the Memories screen. function TripGallery({ diveIds }) { const [photos, setPhotos] = React.useState(null); const [lightbox, setLightbox] = React.useState(null); React.useEffect(() => { let cancelled = false; if (!window.DD_PHOTOS) { setPhotos([]); return; } Promise.all(diveIds.map(id => window.DD_PHOTOS.getPhotosByDive(id).catch(() => []))) .then(lists => { if (cancelled) return; const flat = []; lists.forEach((list, i) => list.forEach(p => flat.push({ ...p, _order: lists.length - i }))); flat.sort((a, b) => b._order - a._order || (a.createdAt < b.createdAt ? 1 : -1)); setPhotos(flat); }); return () => { cancelled = true; }; }, [diveIds.join('|')]); const urls = React.useMemo(() => (photos || []).map(p => URL.createObjectURL(p.blob)), [photos]); React.useEffect(() => () => urls.forEach(u => URL.revokeObjectURL(u)), [urls]); if (!photos || photos.length === 0) return null; return ( <>
Trip gallery
{photos.map((p, i) => (
setLightbox(urls[i])} style={{ aspectRatio: '1 / 1', borderRadius: 10, overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(130,236,255,0.10)', cursor: 'zoom-in', }}>
))}
{photos.length} {photos.length === 1 ? 'photo' : 'photos'} across {diveIds.length} {diveIds.length === 1 ? 'dive' : 'dives'}.
{lightbox && (
setLightbox(null)} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(0,0,0,0.92)', display: 'grid', placeItems: 'center', padding: 'env(safe-area-inset-top, 0px) 12px env(safe-area-inset-bottom, 0px)', cursor: 'zoom-out', }}>
)} ); } function DivePhotoStrip({ diveId, title = 'Photos', compact = false }) { const [photos, setPhotos] = React.useState(null); // null = loading, [] = none, [{id, blob, ...}] = ready const [lightbox, setLightbox] = React.useState(null); // url currently zoomed React.useEffect(() => { let cancelled = false; if (!window.DD_PHOTOS || !diveId) { setPhotos([]); return; } window.DD_PHOTOS.getPhotosByDive(diveId).then(arr => { if (!cancelled) setPhotos(arr || []); }).catch(() => { if (!cancelled) setPhotos([]); }); return () => { cancelled = true; }; }, [diveId]); const urls = React.useMemo(() => (photos || []).map(p => URL.createObjectURL(p.blob)), [photos]); React.useEffect(() => () => urls.forEach(u => URL.revokeObjectURL(u)), [urls]); if (!photos || photos.length === 0) return null; return ( <>
{title}
{photos.length} · tap to zoom
{photos.map((p, i) => (
setLightbox(urls[i])} style={{ aspectRatio: '1 / 1', borderRadius: 10, overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(130,236,255,0.10)', cursor: 'zoom-in', }}>
))}
{lightbox && (
setLightbox(null)} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(0,0,0,0.92)', display: 'grid', placeItems: 'center', padding: 'env(safe-area-inset-top, 0px) 12px env(safe-area-inset-bottom, 0px)', cursor: 'zoom-out', }}>
)} ); } function RecapScreen({ nav, id }) { const { state } = useDiveDex(); const dive = id ? selectDiveById(state, id) : selectLatestDive(state); // Audio cues — fire once per dive (the first time its Recap is opened). // The audio engine queues these on the "celebration" channel, so: // - sharknado / hammerhead-reveal / achievement.unlock and airZen.bonus // play one after another instead of stepping on each other // - re-mounting Recap (back-nav) is a no-op while a cue is already in // flight, and a guard below skips replay once the dive has been seen // - we deliberately do NOT yank the queue on unmount: navigating away // mid-jingle lets the cue finish naturally instead of cutting it off React.useEffect(() => { if (!dive || !window.DiveDexAudio) return; const seen = (window.__DD_RECAP_AUDIO_SEEN__ = window.__DD_RECAP_AUDIO_SEEN__ || new Set()); if (seen.has(dive.id)) return; seen.add(dive.id); const A = window.DiveDexAudio; const unlocked = dive.unlockedAchievementIds || []; const sightingIds = (state.sightings || []).filter(s => s.diveId === dive.id).map(s => s.creatureId); const sawLegendary = sightingIds.some(sid => { const c = state.creatures.find(x => x.id === sid); return c && (c.rarity === 'legendary' || c.rarity === 'mythic'); }); if (unlocked.indexOf('sharknado') >= 0) A.playSfx('achievement.sharknado'); else if (sawLegendary) A.playSfx('creature.hammerheadReveal'); else if (unlocked.length > 0) A.playSfx('achievement.unlock'); if (dive.airZen) A.playSfx('airZen.bonus'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dive && dive.id]); if (!dive) { return (
nav('dashboard')}>‹}/>
No dives logged yet. Log a dive to see its recap.
); } // Resolve hero creature using the shared selector — keeps the Story Card // in sync with the Recap on which species gets the spotlight. const diveSightings = state.sightings.filter(s => s.diveId === dive.id); const { creature: heroCreature } = selectDiveHero(state, dive); const heroLabel = heroCreature ? (heroCreature.rarity === 'mythic' || heroCreature.rarity === 'legendary' ? 'LEGENDARY ENCOUNTER' : heroCreature.rarity === 'epic' ? 'EPIC SIGHTING' : 'BEST SIGHTING') : 'DIVE COMPLETE'; const heroName = heroCreature ? heroCreature.commonName : (dive.site || 'Dive'); const heroQuote = dive.notes && dive.notes.trim() ? `"${dive.notes.trim()}" — ${(dive.buddy || 'You').split('&')[0].trim()}` : '"Log after surfacing. Sharks can wait."'; const rick = state.users.find(u => u.id === 'rick'); const siet = state.users.find(u => u.id === 'siet'); // Unlocked-this-dive: from the dive snapshot const unlocks = (dive.unlockedAchievementIds || []).map(aid => state.achievements.find(a => a.id === aid) ).filter(Boolean); const xpRows = dive.xpRows || []; const fmt = (n) => (n >= 0 ? '+' : '') + n.toLocaleString(); return (
nav('dashboard')}>‹} right={} />
{/* Hero card */}
{heroCreature &&
}
{heroLabel}
{heroName}
{heroQuote}
{/* Dive site map — only shows when we have geo data for the site. */} {(() => { const geo = window.resolveDiveSiteGeo && window.resolveDiveSiteGeo(dive.site); if (!geo) return null; return (
Dive site
{geo.lat.toFixed(3)}°N · {Math.abs(geo.lng).toFixed(3)}°{geo.lng >= 0 ? 'E' : 'W'}
{geo.name}
); })()} {/* Dive photos — pulled from IndexedDB by dive id. */} {/* XP breakdown — from saved dive snapshot */}
XP earned
{fmt(dive.totalXp)}
{xpRows.map((r, i) => ( ))}
{/* Pearls */}
Pearls earned
+{dive.pearls}
{/* Rick / Siet split */}
{/* Unlocked badges from this dive */} {unlocks.length > 0 && ( <>
Unlocked
{unlocks.map(b => (
{b.icon || '✦'}
{b.name}
{b.rarity}
))}
)} {/* Cosmetics granted by the bridge — only shows on the dive that earned them */} {(dive.bridgedItemIds && dive.bridgedItemIds.length > 0) && ( <>
Cosmetics unlocked
{dive.bridgedItemIds.map(iid => { const it = (window.BOUTIQUE_ITEMS || []).find(x => x.id === iid); if (!it) return null; return (
nav('item', iid)} className={`dd-rarity-${it.rarity}`} style={{ cursor: 'pointer', padding: 10, borderRadius: 14, background: 'linear-gradient(180deg, rgba(16,30,46,0.92), rgba(7,18,30,0.95))', border: '1px solid var(--r-color)', boxShadow: '0 4px 14px -8px var(--r-glow)', display: 'flex', alignItems: 'center', gap: 10, }}>
{it.name}
{it.rarity} · added
); })}
)} {/* CTAs */}
); } function XpRow({ label, value, hi, foam }) { return (
{label} +{value}
); } function PersonGain({ person, xp, note }) { return (
{person.name}
+{xp}
{note}
); } function DexScreen({ nav }) { const { state } = useDiveDex(); const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId); const region = expedition ? expedition.region : null; // Show the active-expedition's regional set when there is one. const regional = region ? state.creatures.filter(c => (c.regions || []).indexOf(region) >= 0) : state.creatures; const [tab, setTab] = useState('all'); const familyCounts = regional.reduce((acc, c) => { acc[c.familyType] = (acc[c.familyType] || 0) + 1; return acc; }, {}); const tabs = [ { id: 'all', label: 'All', count: regional.length, filter: () => true }, { id: 'shark', label: 'Sharks', count: familyCounts.shark || 0, filter: (c) => c.familyType === 'shark' }, { id: 'ray', label: 'Rays', count: familyCounts.ray || 0, filter: (c) => c.familyType === 'ray' }, { id: 'reef', label: 'Reef', count: familyCounts.reefFish || 0, filter: (c) => c.familyType === 'reefFish' }, { id: 'macro', label: 'Macro', count: familyCounts.macro || 0, filter: (c) => c.familyType === 'macro' }, { id: 'pelagic', label: 'Pelagic', count: familyCounts.pelagic || 0, filter: (c) => c.familyType === 'pelagic' }, { id: 'coral', label: 'Coral', count: familyCounts.coral || 0, filter: (c) => c.familyType === 'coral' }, { id: 'sponge', label: 'Sponges', count: familyCounts.sponge || 0, filter: (c) => c.familyType === 'sponge' }, { id: 'wreck', label: 'Wrecks', count: familyCounts.wreck || 0, filter: (c) => c.familyType === 'wreck' }, { id: 'aircraft',label: 'Aircraft',count: familyCounts.aircraft || 0, filter: (c) => c.familyType === 'aircraft' }, ].filter(t => t.id === 'all' || t.count > 0); const activeTab = tabs.find(t => t.id === tab) || tabs[0]; const [search, setSearch] = useState(''); const [searchOpen, setSearchOpen] = useState(false); const [sort, setSort] = useState('rarity'); // 'rarity' | 'name' | 'sightings' const matchesSearch = (c) => { if (!search) return true; const q = search.toLowerCase(); return (c.commonName || '').toLowerCase().indexOf(q) >= 0 || (c.scientificName || '').toLowerCase().indexOf(q) >= 0; }; const sortRank = { mythic: 6, legendary: 5, epic: 4, rare: 3, uncommon: 2, common: 1 }; const sortFn = { rarity: (a, b) => (sortRank[b.rarity] || 0) - (sortRank[a.rarity] || 0) || (a.commonName || '').localeCompare(b.commonName || ''), name: (a, b) => (a.commonName || '').localeCompare(b.commonName || ''), sightings: (a, b) => (b.sightingsCount || 0) - (a.sightingsCount || 0) || (a.commonName || '').localeCompare(b.commonName || ''), }; const filtered = regional.filter(activeTab.filter).filter(matchesSearch).slice().sort(sortFn[sort] || sortFn.rarity); const discovered = regional.filter(c => c.discovered).length; const legendaryFound = regional.filter(c => c.discovered && (c.rarity === 'legendary' || c.rarity === 'mythic')).length; const stillLocked = regional.length - discovered; const pct = regional.length ? Math.round((discovered / regional.length) * 100) : 0; const toggleSearch = () => { setSearchOpen(v => { if (v) setSearch(''); return !v; }); }; return (
nav('dashboard')}>‹} right={}/>
{expedition ? expedition.title : 'OceanDex'} {pct}%
{discovered} discovered {legendaryFound > 0 && {legendaryFound} legendary} {stillLocked} locked
{searchOpen && (
setSearch(e.target.value)} placeholder="Search by common or scientific name…" style={{ flex: 1, padding: '10px 12px', borderRadius: 12, fontSize: 13, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(130,236,255,0.18)', color: '#EAF5FF', outline: 'none', }} /> {search && ( )}
)}
{tabs.map(t => ( ))}
Sort {[{ id: 'rarity', label: 'Rarity' }, { id: 'name', label: 'A–Z' }, { id: 'sightings', label: 'Sightings' }].map(opt => ( ))}
{filtered.length === 0 ? ( {search ? `No matches for "${search}" in ${activeTab.label.toLowerCase()}.` : 'No creatures in this family yet.'} ) : (
{filtered.map(c => ( nav('creature', c.id)}/> ))}
)}
); } function CreatureCard({ cr, onClick }) { const locked = !cr.discovered; return (
{(cr.rarity === 'legendary' || cr.rarity === 'mythic') && cr.discovered && (
{cr.rarity === 'mythic' ? 'MYTH' : 'LEG'}
)}
{locked ? '— Not yet found' : RARITY_LABEL[cr.rarity]}
{locked ? '???' : cr.commonName}
{!locked && (
×{cr.sightingsCount || 0} {cr.firstSeenBy ? (cr.firstSeenBy === 'rick' ? 'Rick' : 'Siet') : ''}
)}
); } function CreatureDetailScreen({ nav, id }) { const { state } = useDiveDex(); const cr = state.creatures.find(c => c.id === id) || state.creatures[0]; if (!cr) { return (
nav('dex')}>‹}/>
Creature not found.
); } // Build the sighting history from state: join sightings → dives for site/date. const history = state.sightings .filter(s => s.creatureId === cr.id) .map(s => { const dive = state.dives.find(d => d.id === s.diveId); return { id: s.id, site: (dive && dive.site) || 'Unknown', when: s.createdAt, count: s.count, spotter: s.firstSpotter, legendary: cr.rarity === 'legendary' || cr.rarity === 'mythic', }; }) .sort((a, b) => (a.when < b.when ? 1 : -1)); const fmtWhen = (iso) => { const d = new Date(iso); const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); return `${date} · ${time}`; }; const spotterName = (id) => id === 'rick' ? 'Rick' : id === 'siet' ? 'Siet' : 'Crew'; const firstSiteName = (() => { if (!cr.firstSeenDiveId) return '—'; const d = state.dives.find(x => x.id === cr.firstSeenDiveId); return d && d.site ? d.site.split(' ')[0] : '—'; })(); // Related achievements: any whose name mentions the common name, or whose // condition references this creature id, or that are in the same family. const related = state.achievements.filter(a => { if (a.conditionType && a.conditionType.indexOf(cr.id) >= 0) return true; if (a.name && a.name.toLowerCase().indexOf(cr.commonName.toLowerCase()) >= 0) return true; if (cr.familyType === 'shark' && a.conditionType && a.conditionType.indexOf('shark') >= 0) return true; return false; }).slice(0, 3); return (
nav('dex')}>‹} right={} />
{/* Hero portrait */}
{cr.discovered ? RARITY_LABEL[cr.rarity].toUpperCase() : '— LOCKED —'}
{cr.discovered ? cr.commonName : 'Unknown species'}
{cr.discovered && cr.scientificName &&
{cr.scientificName}
} {cr.discovered && cr.flavorText &&
{cr.flavorText}
}
×{cr.sightingsCount || 0}
SIGHTINGS
{/* Stats */}
{/* Sighting history */}
Sighting log
{history.length > 0 ? ( {history.map((s, i, a) => (
{s.site}
{fmtWhen(s.when)} · spotted by {spotterName(s.spotter)}
×{s.count}
))} ) : ( {cr.discovered ? 'No sightings logged yet.' : 'Log a dive that spots this creature to unlock it.'} )} {/* Related badges */} {related.length > 0 && <>
Related badges
{related.map(a => (
{a.icon || '✦'}
{a.name}
))}
}
); } function TeamScreen({ nav }) { const { state } = useDiveDex(); // Team-level totals const teamXp = state.xp.teamXp; const teamTitle = 'Luxury Shark Expedition Duo'; const nextTitleAt = 8000; // Today's XP: sum totalXp on dives created today (local day) const todayKey = new Date().toDateString(); const todayXp = state.dives .filter(d => d.createdAt && new Date(d.createdAt).toDateString() === todayKey) .reduce((sum, d) => sum + (d.totalXp || 0), 0); // Player views — sorted highest XP first (rank #1 wins) const playerViews = ['rick', 'siet'] .map(uid => buildPlayerView(state, uid)) .filter(Boolean) .sort((a, b) => b.xp - a.xp); // Air Zen — count of bonus dives + total XP earned that way const airZenDives = state.dives.filter(d => d.airZen).length; const airZenXpTotal = airZenDives * (window.DD_STATE.SCORING_CONFIG.xp.airZenBonus || 125); // First-spotter aggregation const spotsByUser = state.sightings.reduce((acc, s) => { if (!s.firstSpotter) { acc.unattributed = (acc.unattributed || 0) + 1; return acc; } acc[s.firstSpotter] = (acc[s.firstSpotter] || 0) + 1; return acc; }, {}); const totalSpots = Object.values(spotsByUser).reduce((a, b) => a + b, 0); const pct = (n) => totalSpots > 0 ? Math.round((n / totalSpots) * 100) : 0; const spotterBars = [ { id: 'rick', name: 'Rick', color: '#31D7FF', wins: spotsByUser.rick || 0, pct: pct(spotsByUser.rick || 0) }, { id: 'siet', name: 'Siet', color: '#63FFD2', wins: spotsByUser.siet || 0, pct: pct(spotsByUser.siet || 0) }, ...((spotsByUser.unattributed || 0) > 0 ? [{ id: 'crew', name: 'Crew / unattributed', color: '#D7B46A', wins: spotsByUser.unattributed, pct: pct(spotsByUser.unattributed) }] : []), ]; // Recent first-spots — last 5 attributed sightings, newest first const fmtWhen = (iso) => { if (!iso) return '—'; const d = new Date(iso); const day = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); return `${day} · ${time}`; }; const recentSpots = state.sightings .filter(s => s.firstSpotter) .slice() .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) .slice(0, 5) .map(s => { const c = state.creatures.find(x => x.id === s.creatureId); const d = state.dives.find(x => x.id === s.diveId); return { id: s.id, creatureId: s.creatureId, commonName: c ? c.commonName : s.creatureId, rarity: c ? c.rarity : 'common', firstSpotter: s.firstSpotter, count: s.count, when: fmtWhen(s.createdAt), diveSite: (d && d.site) || 'Unknown', }; }); // Recent bonuses — last 4 unlocked achievements, newest first const recentBonuses = state.achievements .filter(a => a.unlocked) .slice() .sort((a, b) => (a.unlockedAt < b.unlockedAt ? 1 : -1)) .slice(0, 4) .map(a => ({ ...a, when: fmtWhen(a.unlockedAt) })); return (
nav('dashboard')}>‹} right={}/>
{/* Quick jump to Achievements + Side Quests — the only entry points otherwise live in Memories event taps, which isn't discoverable. */}
TEAM XP
{teamXp.toLocaleString()} {todayXp > 0 && +{todayXp.toLocaleString()} today}
{teamTitle}
{Math.max(0, nextTitleAt - teamXp).toLocaleString()} XP to next title · Pelagic Pair
{/* Player splits — rank by per-user XP */}
{playerViews.map((pv, i) => )}
{/* Air Zen — counts from actual dives */}
Air Zen
Calm dive bonuses
{airZenDives === 0 ? 'Surface with at least 50 bar in the tank to earn the bonus.' : `${airZenDives} of ${state.dives.length} dive${state.dives.length === 1 ? '' : 's'} earned the bonus.`}
+{airZenXpTotal.toLocaleString()}
{/* First spotter — who calls what */}
First spotter
{spotterBars.map(b => )} {totalSpots === 0 &&
No sightings logged yet. The R / S toggle on each sighting attributes the first-spot.
}
{/* Recent first-spots — visual feed: who spotted what */} {recentSpots.length > 0 && ( <>
Recent first-spots
{recentSpots.map(s => { const user = state.users.find(u => u.id === s.firstSpotter); if (!user) return null; return (
nav('creature', s.creatureId)} className="dd-glass" style={{ padding: 10, borderRadius: 14, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10, background: `linear-gradient(90deg, ${user.color}14, rgba(11,42,68,0.4) 60%)`, borderColor: `${user.color}44`, }}>
{s.commonName}
{user.displayName} spotted it · {s.diveSite} · {s.when}
×{s.count}
); })}
)} {/* Recent bonuses — latest unlocked achievements */} {recentBonuses.length > 0 && ( <>
Recent bonuses
{recentBonuses.map(b => (
{b.icon || '✦'}
{b.name}
{b.category} · {b.when}
+{(b.rewardXp || 0).toLocaleString()}
))}
)}
); } // Build a player view object from state — used by Team + Settings PlayerCards. function buildPlayerView(state, userId) { const u = state.users.find(x => x.id === userId); if (!u) return null; const xp = state.xp.userXp[userId] || 0; const firstSpots = state.sightings.filter(s => s.firstSpotter === userId).length; const equipped = state.equippedItems[userId] || {}; // Prefer equipped title cosmetic name, otherwise the seeded defaultTitle. let primaryTitle = u.defaultTitle; if (equipped.title) { const titleItem = (window.BOUTIQUE_ITEMS || []).find(i => i.id === equipped.title); if (titleItem) primaryTitle = titleItem.name; } const titles = [primaryTitle, u.secondaryTitle].filter(Boolean); return { id: userId, name: u.displayName, color: u.color, xp, titles, firstSpots }; } function PlayerCard({ person, rank }) { return (
{person.name}
{person.xp.toLocaleString()} XP
{typeof person.firstSpots === 'number' && person.firstSpots > 0 && (
{person.firstSpots} first-spot{person.firstSpots === 1 ? '' : 's'}
)}
#{rank}
{(person.titles || []).slice(0, 2).map(t => {t})}
); } function AirZenRow({ label, value }) { return (
{label} {value}
); } function SpotterBar({ name, pct, color, wins }) { return (
{name} {wins} first · {pct}%
); } function AchievementsScreen({ nav }) { const { state } = useDiveDex(); const all = state.achievements; const cats = ['All', ...Array.from(new Set(all.map(a => a.category)))]; const [cat, setCat] = useState('All'); const items = cat === 'All' ? all : all.filter(b => b.category === cat); const got = all.filter(b => b.unlocked).length; return (
nav('dashboard')}>‹} right={}/>
{cats.map(c => ( ))}
{items.map(b => )}
); } function BadgeTile({ b }) { // On locked tiles, show the unlock condition (description) so the user // knows what to chase — no more guessing. return (
{b.unlocked ? b.icon : '🔒'}
{b.name}
{b.rarity}
{!b.unlocked && b.description && (
{b.description}
)}
); } function SideQuestsScreen({ nav }) { const { state, actions } = useDiveDex(); const quests = state.sideQuests; const active = quests.filter(q => q.state === 'active'); const completed = quests.filter(q => q.state === 'completed'); const locked = quests.filter(q => q.state === 'locked'); // Count how many times each repeatable quest has been logged, for the // "Repeatable · ×N" chip on the card. const repeatCounts = (state.sideQuestCompletions || []).reduce((acc, c) => { acc[c.questId] = (acc[c.questId] || 0) + 1; return acc; }, {}); // Inline "just completed" banner so tapping a quest doesn't feel like a // silent no-op. Computes the delta from before/after the action so we can // show bridged achievement + cosmetic grants too (e.g. Seven-Eleven Snack // Run unlocks Snack Logistics Champion → Snack Pack + Title). const [banner, setBanner] = React.useState(null); const onCompleteWithFeedback = (questId) => { const before = { teamXp: state.xp.teamXp, rickXp: state.xp.userXp.rick, sietXp: state.xp.userXp.siet, pearls: state.currencies.pearls, unlocks: state.unlockedAchievementIds.slice(), inventory: state.inventory.slice(), }; actions.completeSideQuest(questId); // Read latest state on next frame so deltas reflect the action. setTimeout(() => { const now = window.__DD_STATE__ || state; const newUnlocks = now.unlockedAchievementIds.filter(id => before.unlocks.indexOf(id) < 0); const newItems = now.inventory.filter(id => before.inventory.indexOf(id) < 0); const teamDelta = now.xp.teamXp - before.teamXp; const rickDelta = now.xp.userXp.rick - before.rickXp; const sietDelta = now.xp.userXp.siet - before.sietXp; const pearlDelta = now.currencies.pearls - before.pearls; const quest = quests.find(q => q.id === questId); const newAchievements = newUnlocks.map(aid => now.achievements.find(a => a.id === aid)).filter(Boolean); const newCosmetics = newItems.map(iid => (window.BOUTIQUE_ITEMS || []).find(x => x.id === iid)).filter(Boolean); setBanner({ questTitle: quest ? quest.title : 'Side quest', attribution: quest ? (quest.attribution || 'team') : 'team', repeatable: !!(quest && quest.repeatable), teamDelta, rickDelta, sietDelta, pearlDelta, newAchievements, newCosmetics, }); setTimeout(() => setBanner(null), 4200); }, 80); }; return (
nav('dashboard')}>‹}/>
{banner && (() => { const attrColor = banner.attribution === 'rick' ? '#31D7FF' : banner.attribution === 'siet' ? '#63FFD2' : '#D7B46A'; const headerLabel = banner.repeatable ? 'QUEST LOGGED' : 'QUEST COMPLETE'; return (
{headerLabel}
+{banner.teamDelta.toLocaleString()} XP · +{banner.pearlDelta} Pearls
{banner.questTitle}
{/* Per-user XP delta — shows who actually pocketed the XP */}
{banner.rickDelta > 0 && ( Rick +{banner.rickDelta} )} {banner.sietDelta > 0 && ( Siet +{banner.sietDelta} )}
{(banner.newAchievements.length > 0 || banner.newCosmetics.length > 0) && (
{banner.newAchievements.map(a => {a.name})} {banner.newCosmetics.map(it => {it.name})}
)}
); })()} {active.length > 0 && <>
Active
{active.map(q => onCompleteWithFeedback(q.id)}/>)}
} {completed.length > 0 && <>
Completed
{completed.map(q => )}
} {locked.length > 0 && <>
Locked
{locked.map(q => )}
}
Side quests are optional. Diving comes first — these are travel moments that round out the level. Tap an active quest to mark it complete.
); } function SideQuestCard({ q, onComplete, repeatCount = 0 }) { const isComp = q.state === 'completed'; const isLocked = q.state === 'locked'; const xp = q.rewardXp ?? q.xp ?? 0; const attribution = q.attribution || 'team'; const isRick = attribution === 'rick'; const isSiet = attribution === 'siet'; const attrColor = isRick ? '#31D7FF' : isSiet ? '#63FFD2' : '#D7B46A'; const attrLabel = isRick ? 'Rick XP' : isSiet ? 'Siet XP' : 'Team XP'; return (
{q.badge}
{q.title}
{q.sub}
{attribution !== 'team' && ( {attrLabel} )} {q.repeatable && ( Repeatable{repeatCount > 0 ? ` · ×${repeatCount}` : ''} )}
+{xp}
{isLocked && Locked} {!isLocked && q.repeatable && Tap to log} {!isLocked && !q.repeatable && isComp && ✓ Done} {!isLocked && !q.repeatable && !isComp && Tap to complete}
); } // Build a chronological event stream from state. Each entry is an event the // user generated (dive logged, creature discovered, achievement unlocked, // side quest completed, cosmetic granted). Newest day on top; events within // a day sorted oldest first so the day reads naturally top-to-bottom. function buildMemoryEvents(state) { const events = []; const dayKey = (iso) => iso ? new Date(iso).toISOString().slice(0, 10) : 'unknown'; const userName = (id) => id === 'rick' ? 'Rick' : id === 'siet' ? 'Siet' : 'Crew'; const creatureById = (id) => state.creatures.find(c => c.id === id); const catalog = window.BOUTIQUE_ITEMS || []; // Dives state.dives.forEach(d => { events.push({ id: 'dive-' + d.id, kind: 'dive', when: d.createdAt, dayKey: dayKey(d.createdAt), title: `Dive ${d.no} · ${d.site || 'Unknown site'}`, subtitle: `${d.maxDepth || '?'}m · ${d.time || '?'}min · ${d.gas || ''}`.trim(), xp: d.totalXp || 0, pearls: d.pearls || 0, legendary: !!(d.unlockedAchievementIds || []).some(a => { const ach = state.achievements.find(x => x.id === a); return ach && (ach.rarity === 'legendary' || ach.rarity === 'mythic'); }), onClick: (nav) => nav('recap', d.id), }); }); // First-spot discoveries (one event per newly discovered creature) state.creatures.filter(c => c.discovered && c.firstSeenAt).forEach(c => { events.push({ id: 'disc-' + c.id, kind: 'discovery', when: c.firstSeenAt, dayKey: dayKey(c.firstSeenAt), title: `Discovered · ${c.commonName}`, subtitle: c.firstSeenBy ? `Spotted by ${userName(c.firstSeenBy)}` : 'Discovered', creatureId: c.id, rarity: c.rarity, legendary: c.rarity === 'legendary' || c.rarity === 'mythic', onClick: (nav) => nav('creature', c.id), }); }); // Achievements state.achievements.filter(a => a.unlocked && a.unlockedAt).forEach(a => { events.push({ id: 'ach-' + a.id, kind: 'achievement', when: a.unlockedAt, dayKey: dayKey(a.unlockedAt), title: `Achievement · ${a.name}`, subtitle: a.description || a.category, xp: a.rewardXp || 0, pearls: a.rewardPearls || 0, icon: a.icon, rarity: a.rarity, legendary: a.rarity === 'legendary' || a.rarity === 'mythic', onClick: (nav) => nav('achievements'), }); }); // Side quests state.sideQuests.filter(q => q.state === 'completed' && q.completedAt).forEach(q => { events.push({ id: 'sq-' + q.id, kind: 'sidequest', when: q.completedAt, dayKey: dayKey(q.completedAt), title: `Side quest · ${q.title}`, subtitle: q.sub || '', xp: q.rewardXp || 0, pearls: q.rewardPearls || 0, icon: q.badge, onClick: (nav) => nav('sidequests'), }); }); // Cosmetic bridge — one event per item granted by each dive state.dives.forEach(d => { (d.bridgedItemIds || []).forEach(iid => { const item = catalog.find(x => x.id === iid); if (!item) return; events.push({ id: 'cosm-' + d.id + '-' + iid, kind: 'cosmetic', when: d.createdAt, dayKey: dayKey(d.createdAt), title: `Unlocked · ${item.name}`, subtitle: `${item.cat} · ${item.rarity}`, rarity: item.rarity, itemId: iid, legendary: item.rarity === 'legendary' || item.rarity === 'mythic', onClick: (nav) => nav('item', iid), }); }); }); // Sort newest day first; within a day, oldest first const byDay = {}; events.forEach(e => { (byDay[e.dayKey] = byDay[e.dayKey] || []).push(e); }); Object.values(byDay).forEach(arr => arr.sort((a, b) => (a.when < b.when ? -1 : 1))); const days = Object.keys(byDay).sort((a, b) => (a < b ? 1 : -1)); return { days, byDay }; } function MemoriesScreen({ nav }) { const { state } = useDiveDex(); const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId) || state.expeditions[0]; const teamTitle = 'Luxury Shark Expedition Duo'; // Real stats for the hero card const totalXp = state.xp.teamXp; 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; const dayCount = state.dives.length === 0 ? 0 : (() => { const days = new Set(state.dives.map(d => d.createdAt && d.createdAt.slice(0, 10)).filter(Boolean)); return days.size; })(); // Highlights: discovered creatures, newest first, top 9 const highlights = state.creatures .filter(c => c.discovered) .slice() .sort((a, b) => ((a.firstSeenAt || '') < (b.firstSeenAt || '') ? 1 : -1)) .slice(0, 9); // Timeline events const { days, byDay } = buildMemoryEvents(state); const fmtDay = (key) => { if (key === 'unknown') return 'Earlier'; const d = new Date(key + 'T00:00:00'); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', weekday: 'short' }); }; const fmtTime = (iso) => iso ? new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : ''; // Kind → visual config const kindStyle = { dive: { bg: 'rgba(49,215,255,0.16)', fg: '#82ECFF', icon: '🤿', label: 'DIVE' }, discovery: { bg: 'rgba(99,255,210,0.14)', fg: '#63FFD2', icon: '✦', label: 'NEW SPECIES' }, achievement: { bg: 'rgba(215,180,106,0.18)', fg: '#D7B46A', icon: '✸', label: 'BADGE' }, sidequest: { bg: 'rgba(139,123,255,0.16)', fg: '#C8B6FF', icon: '◆', label: 'SIDE QUEST' }, cosmetic: { bg: 'rgba(255,122,107,0.14)', fg: '#FF9D8E', icon: '◈', label: 'COSMETIC' }, }; const latestDiveId = state.dives.length ? state.dives[state.dives.length - 1].id : null; const empty = days.length === 0; return (
nav('dashboard')}>‹} right={latestDiveId ? nav('story', latestDiveId)}>↗ : null} />
{/* Hero recap — real stats */}
TRIP
{teamTitle}
{dayCount > 0 && {dayCount} {dayCount === 1 ? 'day' : 'days'}} {totalXp.toLocaleString()} XP {speciesFound}/{speciesTotal} species {state.dives.length} {state.dives.length === 1 ? 'dive' : 'dives'}
{/* Trip map — all logged dives as pins. Each pin opens that dive's recap. */} {(() => { const pins = state.dives .map(d => ({ dive: d, geo: window.resolveDiveSiteGeo && window.resolveDiveSiteGeo(d.site) })) .filter(x => x.geo) .map(x => ({ name: x.geo.name, lat: x.geo.lat, lng: x.geo.lng, _diveId: x.dive.id, highlight: x.dive.id === latestDiveId })); if (pins.length === 0) return null; // Pins may share the same site — dedupe by name for the marker layer, // but keep the original list to attribute the latest dive's id to its tap. const byName = {}; pins.forEach(p => { if (!byName[p.name] || p.highlight) byName[p.name] = p; }); const uniq = Object.values(byName); const onTap = (name) => { const hit = pins.slice().reverse().find(p => p.name === name); if (hit) nav('recap', hit._diveId); }; return ( <>
Trip map
Tap a pin to jump to that dive's recap. Gold pin · latest dive.
); })()} {/* Trip gallery — all attached photos across logged dives. */} d.id)}/> {/* Highlights — real discovered creatures */} {highlights.length > 0 && ( <>
Highlights
{highlights.map(c => (
nav('creature', c.id)} className={`dd-glass dd-rarity-${c.rarity}`} style={{ aspectRatio: '1 / 1', borderRadius: 12, display: 'grid', placeItems: 'center', cursor: 'pointer', position: 'relative', overflow: 'hidden', borderColor: 'var(--r-color)', }}>
{c.commonName}
))}
)} {/* Timeline — real events grouped by day */}
Timeline
{empty && ( Nothing here yet. Log a dive, complete a side quest, or unlock a badge — they'll all show up on the timeline. )} {!empty && (
{days.map(dKey => (
{fmtDay(dKey)}
{byDay[dKey].length} event{byDay[dKey].length === 1 ? '' : 's'}
{byDay[dKey].map(ev => { const k = kindStyle[ev.kind] || kindStyle.dive; return (
ev.onClick && ev.onClick(nav)} className="dd-glass" style={{ padding: '10px 12px', borderRadius: 12, display: 'flex', alignItems: 'center', gap: 10, cursor: ev.onClick ? 'pointer' : 'default', ...(ev.legendary ? { borderColor: 'rgba(215,180,106,0.35)', background: 'linear-gradient(120deg, rgba(215,180,106,0.10), rgba(11,42,68,0.3))' } : {}), }}> {ev.kind === 'discovery' && ev.creatureId ? (
) : (
{ev.icon || k.icon}
)}
{k.label}{ev.when ? ' · ' + fmtTime(ev.when) : ''}
{ev.title}
{ev.subtitle &&
{ev.subtitle}
}
{(ev.xp > 0 || ev.pearls > 0) && (
{ev.xp > 0 &&
+{ev.xp.toLocaleString()}
} {ev.pearls > 0 &&
+{ev.pearls}P
}
)}
); })}
))}
)} {latestDiveId && ( )}
); } function StoryCardScreen({ nav, id }) { const { state } = useDiveDex(); const dive = id ? selectDiveById(state, id) : selectLatestDive(state); const { creature: heroCreature, sighting: heroSighting } = selectDiveHero(state, dive); // Only play the reveal cue when there's actually a legendary/mythic to reveal. React.useEffect(() => { if (!window.DiveDexAudio) return; if (heroCreature && (heroCreature.rarity === 'legendary' || heroCreature.rarity === 'mythic')) { window.DiveDexAudio.playSfx('creature.hammerheadReveal'); return () => window.DiveDexAudio.stopAudio('creature.hammerheadReveal', 600); } }, [heroCreature && heroCreature.id]); if (!dive) { return (
nav('memories')}>‹}/>
No dives to render yet. Log a dive first to generate a Story Card.
); } return ; } function StoryCardScreenInner({ nav, dive, heroCreature, heroSighting }) { const { state } = useDiveDex(); const [tpl, setTpl] = useState('dive'); const [aspect, setAspect] = useState('9:16'); const templates = [ { id: 'dive', label: 'Dive Recap' }, { id: 'species', label: 'Species' }, { id: 'level', label: 'Level' }, { id: 'quest', label: 'Side Quest' }, ]; const rarityColor = { common: '#B7CCDE', uncommon: '#63FFD2', rare: '#31D7FF', epic: '#8B7BFF', legendary: '#D7B46A', mythic: '#82ECFF', }; const heroLabel = heroCreature ? (heroCreature.rarity === 'mythic' || heroCreature.rarity === 'legendary' ? 'LEGENDARY ENCOUNTER' : heroCreature.rarity === 'epic' ? 'EPIC SIGHTING' : 'BEST SIGHTING') : 'DIVE LOGGED'; const heroName = heroCreature ? heroCreature.commonName : (dive.site || 'Dive'); // Split a 2-word name across two lines if it fits naturally, otherwise leave inline. const heroNameParts = (() => { const parts = heroName.split(' '); if (parts.length === 2 && parts[0].length >= 4 && parts[1].length >= 4) return parts; return [heroName]; })(); const heroColor = heroCreature ? (rarityColor[heroCreature.rarity] || '#82ECFF') : '#82ECFF'; // Prefer the explicit quote field; fall back to notes; final fallback is a generic line. const quote = (dive.quote && dive.quote.trim()) || (dive.notes && dive.notes.trim()) || 'Log after surfacing. Sharks can wait.'; const spotter = heroSighting ? heroSighting.firstSpotter : null; const spotterUser = spotter ? state.users.find(u => u.id === spotter) : null; // Equipped frame cosmetic preview — if either user has a frame equipped, use // their rarity hue as the card's accent border so the cosmetic is visible. const equippedFrameId = (state.equippedItems.rick && state.equippedItems.rick.frame) || (state.equippedItems.siet && state.equippedItems.siet.frame) || null; const equippedFrame = equippedFrameId && (window.BOUTIQUE_ITEMS || []).find(i => i.id === equippedFrameId); const frameRarityColor = equippedFrame ? (rarityColor[equippedFrame.rarity] || '#82ECFF') : null; const cardBorderColor = frameRarityColor || 'rgba(130,236,255,0.18)'; // Template content — what to put in the lower-middle of the card. The hero // imagery on top stays the same across templates so the card always reads as // "this dive"; the body swaps to emphasize what the template is about. const expedition = state.expeditions.find(e => e.id === state.activeExpeditionId) || state.expeditions[0]; 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; const latestQuest = state.sideQuests .filter(q => q.state === 'completed' && q.completedAt) .slice() .sort((a, b) => (a.completedAt < b.completedAt ? 1 : -1))[0]; // Aspect → CSS aspectRatio + a label positioning tweak so the body sits // sensibly across portrait/square/feed shapes. const aspectMap = { '9:16': { ratio: '9 / 16', bodyBottom: 70, quoteBottom: 14 }, '1:1': { ratio: '1 / 1', bodyBottom: 56, quoteBottom: 12 }, '4:5': { ratio: '4 / 5', bodyBottom: 60, quoteBottom: 12 }, }; const A = aspectMap[aspect] || aspectMap['9:16']; // Body content per template let bodyLabel, bodyTitle, bodyStats; if (tpl === 'species' && heroCreature) { bodyLabel = `SPECIES · ${heroCreature.rarity.toUpperCase()}`; bodyTitle = heroNameParts; bodyStats = [ { n: heroCreature.sightingsCount || (heroSighting ? heroSighting.count : 1), l: 'SIGHTINGS' }, { n: heroCreature.maxSize || '—', l: 'MAX SIZE' }, { n: '+' + (dive.totalXp || 0).toLocaleString(), l: 'XP', c: heroColor }, ]; } else if (tpl === 'level' && expedition) { bodyLabel = `LEVEL · ${expedition.emoji || ''} ${expedition.region.toUpperCase()}`; bodyTitle = [expedition.title]; bodyStats = [ { n: `${speciesFound}/${speciesTotal}`, l: 'SPECIES' }, { n: state.dives.length, l: 'DIVES' }, { n: '+' + (state.xp.teamXp || 0).toLocaleString(), l: 'TEAM XP', c: heroColor }, ]; } else if (tpl === 'quest' && latestQuest) { bodyLabel = `SIDE QUEST · ${(latestQuest.region || 'TRIP').toUpperCase()}`; bodyTitle = [latestQuest.title]; bodyStats = [ { n: latestQuest.badge || '◆', l: 'BADGE' }, { n: '+' + (latestQuest.rewardXp || 0), l: 'XP', c: heroColor }, { n: '+' + (latestQuest.rewardPearls || 0), l: 'PEARLS' }, ]; } else { // Default: 'dive' template, or fallback if a template can't render bodyLabel = heroLabel; bodyTitle = heroNameParts; bodyStats = [ { n: dive.maxDepth, u: 'm', l: 'DEPTH' }, { n: dive.time, u: 'min', l: 'TIME' }, { n: '+' + (dive.totalXp || 0).toLocaleString(), l: 'XP', c: heroColor }, ]; } // Honest "share" affordance: copy a short text summary to clipboard. // (A real image-export would need html-to-canvas; out of scope for MVP.) const [shareBanner, setShareBanner] = useState(null); const onShare = async () => { const summary = [ `${expedition.title} · Dive ${dive.no}`, `${dive.site || ''} · ${dive.maxDepth || '?'}m · ${dive.time || '?'}min`, heroCreature ? `${heroLabel}: ${heroCreature.commonName}` : null, `+${(dive.totalXp || 0).toLocaleString()} Team XP`, `"${quote}"`, `via DiveDex`, ].filter(Boolean).join('\n'); try { if (navigator.share) { await navigator.share({ title: 'DiveDex story', text: summary }); setShareBanner('Shared.'); } else if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(summary); setShareBanner('Copied to clipboard.'); } else { setShareBanner('Sharing not supported on this device.'); } } catch (e) { setShareBanner('Share cancelled.'); } setTimeout(() => setShareBanner(null), 2500); }; return (
nav('memories')}>‹} right={} />
{/* Story preview */}
{(expedition.region || '').toUpperCase()} · DIVE {dive.no}
{heroCreature && }
{bodyLabel}
{bodyTitle.map((part, i) => {i > 0 &&
}{part}
)}
{bodyStats.map((s, i) => )}
“{quote}” {spotterUser && }
{/* Equipped frame note */} {equippedFrame && (
FRAME · {equippedFrame.name}
)} {/* Templates — Side Quest tile dims if no completed quests yet */}
Template
{templates.map(t => { const disabled = (t.id === 'quest' && !latestQuest) || (t.id === 'species' && !heroCreature); return ( ); })}
{/* Aspect — actually changes the preview shape now */}
Aspect
Copies a text summary to clipboard (or opens the native share sheet). Image export coming later.
{shareBanner && (
{shareBanner}
)}
); } function SCStat({ n, u, l, c = '#fff' }) { return (
{n} {u && {u}}
{l}
); } // Account block on Settings — switch user, set/change/clear password, lock now. function AccountSection({ state, actions, flash }) { const activeUser = state.users.find(u => u.id === state.activeUserId) || null; const hasPassword = !!state.authPassword; const [pwEditing, setPwEditing] = React.useState(false); const [pwInput, setPwInput] = React.useState(''); const [pwConfirm, setPwConfirm] = React.useState(''); const onSwitchUser = (uid) => { if (uid === state.activeUserId) return; actions.setActiveUser(uid); flash('ok', `Switched to ${uid === 'rick' ? 'Rick' : 'Siet'}.`); }; const onLock = () => { actions.lockApp(); // App re-renders to LockScreen automatically. }; const onSavePassword = () => { if (pwInput !== pwConfirm) { flash('err', 'Passwords don\'t match.'); return; } actions.updateAuthPassword(pwInput); setPwEditing(false); setPwInput(''); setPwConfirm(''); flash('ok', pwInput ? 'Password updated.' : 'Password cleared.'); }; const onClearPassword = () => { if (!window.confirm('Clear the shared password? Anyone with this device will be able to unlock.')) return; actions.updateAuthPassword(''); flash('ok', 'Password cleared.'); }; return ( <>
Account
{/* Switch user */}
Active diver {activeUser ? activeUser.displayName : '—'}
{['rick','siet'].map(uid => { const u = state.users.find(x => x.id === uid); if (!u) return null; const isOn = state.activeUserId === uid; return ( ); })}
{/* Password */}
Shared password
{hasPassword ? 'Set — required on unlock' : 'Not set — anyone can unlock'}
{!pwEditing && ( )}
{pwEditing && (
setPwInput(e.target.value)} placeholder="New password (leave empty to clear)" 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', }}/> setPwConfirm(e.target.value)} 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', }}/>
{hasPassword && ( )}
)}
{/* Lock now */}
Lock app
{hasPassword ? 'Returns to the password screen until unlocked.' : 'Returns to the diver picker until you continue.'}
); } function SettingsScreen({ nav }) { const [aPrefs, setAPrefs] = React.useState(() => (window.DiveDexAudio ? window.DiveDexAudio.getPrefs() : { sfxOn:true, musicOn:false, volume:'medium' })); React.useEffect(() => window.DiveDexAudio && window.DiveDexAudio.onPrefsChange(setAPrefs), []); // Audio prefs are mirrored into state.settings so they survive backup/ // import. The audio manager is the live engine; state.settings is the // backup-safe source of truth. const setSfx = (v) => { window.DiveDexAudio && window.DiveDexAudio.setSoundEnabled(v); actions.updateSettings({ soundEnabled: v }); }; const setMus = (v) => { window.DiveDexAudio && window.DiveDexAudio.setMusicEnabled(v); actions.updateSettings({ musicEnabled: v }); }; const setVol = (v) => { window.DiveDexAudio && window.DiveDexAudio.setMasterVolume(v); actions.updateSettings({ volume: v }); }; // Backup & Data — context-driven const { state, actions } = useDiveDex(); const fileInputRef = React.useRef(null); const [banner, setBanner] = React.useState(null); // { kind, msg } const flash = (kind, msg) => { setBanner({ kind, msg }); setTimeout(() => setBanner(b => (b && b.msg === msg ? null : b)), 4200); }; const onExport = () => { actions.exportBackup(); flash('ok', 'Backup downloaded. Keep it somewhere safe.'); }; const onPickImport = () => fileInputRef.current && fileInputRef.current.click(); const onImport = async (e) => { const file = e.target.files && e.target.files[0]; e.target.value = ''; if (!file) return; const ok = window.confirm('Import this backup? Your current DiveDex progress on this device will be replaced.'); if (!ok) return; try { await actions.importBackupFromFile(file); flash('ok', 'Backup imported. Progress restored.'); } catch (err) { flash('err', err.message || 'Could not import backup.'); } }; const onReset = () => { const ok = window.confirm('This will delete local DiveDex progress on this device. Continue?'); if (!ok) return; actions.resetDemoData(); flash('ok', 'Demo data reset.'); }; const lastBackupLabel = window.DD_BACKUP ? window.DD_BACKUP.formatLastBackup(state.lastBackupAt) : 'unknown'; const backupStale = window.DD_BACKUP ? window.DD_BACKUP.isBackupStale(state.lastBackupAt) : true; return (
nav('dashboard')}>‹}/>
Profiles
{['rick','siet'] .map(uid => buildPlayerView(state, uid)) .filter(Boolean) .sort((a, b) => b.xp - a.xp) .map((pv, i) => )}
{/* Account — active user, switch user, password, lock now */}
Insights
nav('trends')} style={{ padding: '14px 16px', borderRadius: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12 }}>
Trends
Depth profile · gas mix · species/dive · first-spotter race
Audio
Sound effects
Expedition music
Volume
{['low','medium','high'].map(v => ( ))}
{/* Audio test panel — preview each cue without triggering full flows */}
Audio test
{[ { id: 'achievement.unlock', label: 'Test Unlock', kind: 'sfx' }, { id: 'airZen.bonus', label: 'Test Air Zen', kind: 'sfx' }, { id: 'creature.hammerheadReveal', label: 'Test Hammerhead', kind: 'sfx' }, { id: 'achievement.sharknado', label: 'Test Sharknado', kind: 'sfx' }, { id: 'music.japanExpedition', label: 'Test Japan Theme', kind: 'music' }, ].map(t => ( ))}
Music respects the "Expedition music" toggle above. Test Japan Theme is a no-op when music is off.
Units
Gas presets
Reminders
Backup & Data
DiveDex currently saves progress on this device. Clearing browser data can remove local progress. Export a backup to keep it safe.
Last backup {lastBackupLabel}
{banner && (
{banner.msg}
)}
!
Safety disclaimer
DiveDex is a post-dive logbook and memory app. It does not replace dive training, professional guidance, dive planning or a dive computer.
DiveDex v0.1 · MVP
); } function SettingRow({ label, value, last, check, mono, coral }) { return (
{label} {value} {check && value === 'active' ? '✓' : ''} ›
); } // TrendsScreen — derived analytics over the full dive log. Pure read; no // state mutations. Charts are hand-rolled SVG so we don't pull in a charting // library (everything in DiveDex stays vendored / no-build). function TrendsScreen({ nav }) { const { state } = useDiveDex(); const dives = state.dives; const empty = dives.length === 0; const rick = state.users.find(u => u.id === 'rick'); const siet = state.users.find(u => u.id === 'siet'); // ── Totals (header strip) ───────────────────────────────────────────── const totalDives = dives.length; const totalBottomTime = dives.reduce((s, d) => s + (+d.time || 0), 0); const deepest = dives.reduce((m, d) => Math.max(m, +d.maxDepth || 0), 0); const longest = dives.reduce((m, d) => Math.max(m, +d.time || 0), 0); const avgDepth = totalDives ? Math.round(dives.reduce((s, d) => s + (+d.maxDepth || 0), 0) / totalDives) : 0; const avgTime = totalDives ? Math.round(totalBottomTime / totalDives) : 0; // ── Gas mix breakdown ───────────────────────────────────────────────── const gasCounts = dives.reduce((acc, d) => { const nitrox = +d.nitrox || (d.gas === 'Nitrox' ? 32 : 21); const k = nitrox > 21 ? `Nitrox ${nitrox}` : 'Air'; acc[k] = (acc[k] || 0) + 1; return acc; }, {}); const gasRows = Object.entries(gasCounts) .map(([label, count]) => ({ label, count, pct: totalDives ? count / totalDives : 0 })) .sort((a, b) => b.count - a.count); const gasColors = { 'Air': '#82ECFF', 'Nitrox 32': '#63FFD2', 'Nitrox 36': '#D7B46A' }; // ── Per-dive depth bar chart ────────────────────────────────────────── const depthMax = Math.max(deepest, 30); const depthBars = dives.slice(-12).map((d, i) => ({ label: d.no || (i + 1), depth: +d.maxDepth || 0, time: +d.time || 0, })); // ── Species per dive ────────────────────────────────────────────────── const sightingsByDive = state.sightings.reduce((acc, s) => { acc[s.diveId] = (acc[s.diveId] || 0) + 1; return acc; }, {}); const speciesPerDive = dives.map(d => ({ no: d.no, c: sightingsByDive[d.id] || 0 })); const maxSpecies = Math.max(1, ...speciesPerDive.map(x => x.c)); // ── First-spotter race ──────────────────────────────────────────────── const firstSpotter = { rick: 0, siet: 0 }; state.creatures.forEach(c => { if (c.discovered && c.firstSeenBy && firstSpotter[c.firstSeenBy] !== undefined) { firstSpotter[c.firstSeenBy]++; } }); const totalFirsts = firstSpotter.rick + firstSpotter.siet; return (
nav('settings')}>‹}/>
{empty && ( Trends light up once you've logged a few dives. )} {!empty && <> {/* Top stat strip */}
{/* Per-dive depth bars */}
Depth profile · last {depthBars.length}
{depthBars.map(b => (
{b.depth}
#{b.label}
))}
Bar height = max depth · Y axis caps at {depthMax}m.
{/* Gas mix breakdown */}
Gas mix breakdown
{gasRows.map(r => (
))}
{gasRows.map(r => (
{r.label}
{r.count} · {Math.round(r.pct * 100)}%
))}
{/* Species per dive trend */}
Species per dive
{speciesPerDive.length > 1 && ( `${i * 24 + 12},${80 - (p.c / maxSpecies) * 70 - 4}` ).join(' ')} /> )} {speciesPerDive.map((p, i) => ( {p.no || i + 1} ))}
Max on one dive: {maxSpecies} Avg/dive: {totalDives ? (speciesPerDive.reduce((s, x) => s + x.c, 0) / totalDives).toFixed(1) : '0'}
{/* First-spotter race */}
First-spotter race
{totalFirsts === 0 ? (
No first-spotter attributions yet. Mark "first to spot" on a sighting to start the race.
) : <>
{firstSpotter.rick > 0 ? firstSpotter.rick : ''}
{firstSpotter.siet > 0 ? firstSpotter.siet : ''}
{rick.displayName}
{firstSpotter.rick} firsts
{siet.displayName}
{firstSpotter.siet} firsts
}
}
); } Object.assign(window, { RecapScreen, DexScreen, CreatureCard, CreatureDetailScreen, TeamScreen, AchievementsScreen, SideQuestsScreen, MemoriesScreen, StoryCardScreen, SettingsScreen, TrendsScreen, });