// Florio Analytics — Gaps tab (real data via window.FlorioDB). // Ported from js/pages/gaps.js. Lists labeling_queue items flagged status:"no_match" // — photos where no close-matching avatar exists. These drive which new avatars to design. // Range prop is ignored (this page is not date-range based). (function () { const NS = window.HyperChartsDesignSystem_e51a95; const { Badge } = NS; const I = window.HyperIcons; const { useState, useEffect, useCallback } = React; // Lazy Firestore accessor — window.FlorioDB is only present after FlorioReady. const FB = () => window.FlorioDB; function Spinner() { return (
); } function EmptyState({ title, note }) { return (
{I.sparkle ? I.sparkle({ size: 30 }) : null} {title} {note && {note}}
); } function GapCard({ item, onReopen, onDelete }) { const [busy, setBusy] = useState(false); const [imgErr, setImgErr] = useState(false); const run = async (fn) => { if (busy) return; setBusy(true); try { await fn(); } finally { setBusy(false); } }; return (
{item.photoUrl && !imgErr ? {item.species setImgErr(true)} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> : image unavailable}
{item.species || 'unknown species'} source: {item.source || 'manual'}
); } function Gaps({ range }) { const [items, setItems] = useState(null); // null = loading const [error, setError] = useState(null); useEffect(() => { let alive = true; (async () => { try { await window.FlorioReady; const { db, collection, query, where, getDocs } = FB(); const q = query(collection(db, 'labeling_queue'), where('status', '==', 'no_match')); const snap = await getDocs(q); const list = []; snap.forEach((d) => list.push(Object.assign({ id: d.id }, d.data()))); // Most recently flagged first. list.sort((a, b) => (b.resolvedAt?.seconds || 0) - (a.resolvedAt?.seconds || 0)); if (alive) setItems(list); } catch (e) { console.error('[Gaps] load failed:', e); if (alive) { setError(e.message || String(e)); setItems([]); } } })(); return () => { alive = false; }; }, []); const reopen = useCallback(async (id) => { const { db, doc, setDoc } = FB(); try { await setDoc(doc(db, 'labeling_queue', id), { status: 'pending', resolvedAt: null }, { merge: true }); setItems((prev) => (prev || []).filter((it) => it.id !== id)); } catch (e) { console.error('[Gaps] reopen failed:', e); window.alert('Reopen failed: ' + (e.message || e)); } }, []); const remove = useCallback(async (id) => { const { db, doc, deleteDoc } = FB(); try { await deleteDoc(doc(db, 'labeling_queue', id)); setItems((prev) => (prev || []).filter((it) => it.id !== id)); } catch (e) { console.error('[Gaps] delete failed:', e); window.alert('Delete failed: ' + (e.message || e)); } }, []); if (items === null) return ; const count = items.length; return (
{/* Header */}
{I.search ? I.search({ size: 20 }) : null}
Avatar catalog gaps
Photos flagged "no close matching avatar" — the highest-signal candidates for new avatar designs.
{Badge ? {count} {count === 1 ? 'gap' : 'gaps'} : {count}}
{error ?
: count === 0 ?
:
{items.map((item) => ( ))}
}
); } (window.FlorioTabs = window.FlorioTabs || {}).gaps = Gaps; })();