// Florio Analytics — Traits tab (real data via window.FlorioAPI / window.FlorioDB). // Ports js/pages/traits.js to the new design kit. Consumes // analytics_daily.{date}.traits rollups (from daily-aggregate.ts): // • Trait × archetype gap heatmap (byArchetype sum/count → avg delta) // • Gap-ranking table (byTraitValue → avg delta × scan count) // • Swap-rate cards (swapRate.overrideCount / total) // • Retake-rate cards (retakeRate.retakeCount / total) // • Inline avatar_catalog weight editor (Firestore read + setDoc merge) (function () { const NS = window.HyperChartsDesignSystem_e51a95; const I = window.HyperIcons; const { useState, useEffect } = React; const rangeToDays = (r) => ({ Day: 1, Week: 7, Month: 30, Year: 365 }[r] || 30); // ── Constants (mirrors traits.js) ── const ARCHETYPES = [ "flowering_dominant", "foliage_dominant", "structural_dominant", "trailing_hanging", ]; const TRAIT_DIMS = [ "leaf_shape", "leaf_size", "growth_habit", "stem_type", "silhouette", "texture", "size_class", "variegation", "biome", "spikiness", "flower_presence", "flower_color", "color", ]; const ARCHETYPE_LABELS = { flowering_dominant: "Flowering", foliage_dominant: "Foliage", structural_dominant: "Structural", trailing_hanging: "Trailing", }; const TRAIT_LABELS = { leaf_shape: "Leaf shape", leaf_size: "Leaf size", growth_habit: "Growth habit", stem_type: "Stem type", silhouette: "Silhouette", texture: "Texture", size_class: "Size class", variegation: "Variegation", biome: "Biome", spikiness: "Spikiness", flower_presence: "Has flower", flower_color: "Flower color", color: "Leaf color", }; const MIN_SCAN_COUNT_FOR_GAP_TABLE = 10; const DISPLAY_NAMES = { monstera: "Monstera", palm: "Palm", bushy: "Bushy", peace: "Peace Lily", cactus: "Cactus", succulent: "Succulent", aloevera: "Aloe Vera", snake: "Snake Plant", fern: "Fern", hanging: "Hanging", orchid: "Orchid", anthurium: "Anthurium", bonsai: "Bonsai", banana: "Banana", dracaena: "Dracaena", ficus: "Ficus", prayer: "Prayer Plant", clover: "Clover", }; const CATALOG_FIELDS = [ { key: "growth_habit_affinity", label: "Growth habits", type: "csv", full: true }, { key: "size_class_affinity", label: "Size classes", type: "csv" }, { key: "leaf_shape_affinity", label: "Leaf shapes", type: "csv" }, { key: "texture_affinity", label: "Textures", type: "csv" }, { key: "silhouette_affinity", label: "Silhouettes", type: "csv" }, { key: "biome_affinity", label: "Biomes", type: "csv" }, { key: "stem_type_affinity", label: "Stem types", type: "csv" }, { key: "variegation_affinity", label: "Variegation", type: "csv" }, { key: "archetype_affinity", label: "Archetypes", type: "csv", full: true }, { key: "flower_color_affinity", label: "Flower colors", type: "csv" }, { key: "palette", label: "Palette (hex)", type: "csv", full: true }, { key: "spikiness_range", label: "Spikiness [min, max]", type: "range" }, { key: "prominent_feature", label: "Prominent feature", type: "string" }, { key: "has_flowers", label: "Has flowers", type: "bool" }, { key: "has_spines", label: "Has spines", type: "bool" }, ]; // 6 heatmap levels → color ramp (more gap = hotter). const HEAT_COLORS = [ "var(--surface-sunken)", "color-mix(in srgb, #FE7E07 18%, var(--surface-sunken))", "color-mix(in srgb, #FE7E07 38%, var(--surface-sunken))", "color-mix(in srgb, #FE7E07 58%, var(--surface-sunken))", "color-mix(in srgb, #FE7E07 78%, var(--surface-sunken))", "#FE7E07", ]; // ── Helpers ── function deltaToLevel(delta) { if (delta <= 0.05) return 0; if (delta <= 0.15) return 1; if (delta <= 0.3) return 2; if (delta <= 0.5) return 3; if (delta <= 0.7) return 4; return 5; } // Merge per-day `traits` rollups across the selected range. Sum/count pairs add. function mergeTraitRollups(dates, data) { const merged = { byArchetype: {}, byTraitValue: {}, swapRate: {}, retakeRate: {}, scanCount: {}, }; for (const date of dates) { const traits = data[date] && data[date].traits; if (!traits) continue; for (const [arch, dims] of Object.entries(traits.byArchetype || {})) { if (!merged.byArchetype[arch]) merged.byArchetype[arch] = {}; for (const [dim, sc] of Object.entries(dims || {})) { if (!merged.byArchetype[arch][dim]) merged.byArchetype[arch][dim] = { sum: 0, count: 0 }; merged.byArchetype[arch][dim].sum += sc.sum || 0; merged.byArchetype[arch][dim].count += sc.count || 0; } } for (const [arch, dims] of Object.entries(traits.byTraitValue || {})) { if (!merged.byTraitValue[arch]) merged.byTraitValue[arch] = {}; for (const [dim, values] of Object.entries(dims || {})) { if (!merged.byTraitValue[arch][dim]) merged.byTraitValue[arch][dim] = {}; for (const [val, sc] of Object.entries(values || {})) { if (!merged.byTraitValue[arch][dim][val]) merged.byTraitValue[arch][dim][val] = { sum: 0, count: 0 }; merged.byTraitValue[arch][dim][val].sum += sc.sum || 0; merged.byTraitValue[arch][dim][val].count += sc.count || 0; } } } for (const [arch, sr] of Object.entries(traits.swapRate || {})) { if (!merged.swapRate[arch]) merged.swapRate[arch] = { overrideCount: 0, total: 0 }; merged.swapRate[arch].overrideCount += sr.overrideCount || 0; merged.swapRate[arch].total += sr.total || 0; } for (const [arch, rr] of Object.entries(traits.retakeRate || {})) { if (!merged.retakeRate[arch]) merged.retakeRate[arch] = { retakeCount: 0, total: 0 }; merged.retakeRate[arch].retakeCount += rr.retakeCount || 0; merged.retakeRate[arch].total += rr.total || 0; } for (const [arch, n] of Object.entries(traits.scanCount || {})) { merged.scanCount[arch] = (merged.scanCount[arch] || 0) + (n || 0); } } return merged; } function buildGapRows(merged) { const rows = []; for (const arch of ARCHETYPES) { const dims = merged.byTraitValue[arch] || {}; for (const [dim, values] of Object.entries(dims)) { for (const [val, sc] of Object.entries(values)) { if (sc.count < MIN_SCAN_COUNT_FOR_GAP_TABLE) continue; const avg = sc.count > 0 ? sc.sum / sc.count : 0; rows.push({ archetype: arch, dim, value: val, avgDelta: avg, scanCount: sc.count, gapScore: avg * sc.count, }); } } } rows.sort((a, b) => b.gapScore - a.gapScore); return rows.slice(0, 50); } // ── Empty state ── function Empty({ note }) { return (
{I.search ? I.search({ size: 24 }) : null} {note || "No data yet"}
); } // ── Heatmap (Trait × Archetype gap) ── function Heatmap({ merged }) { const grid = `120px repeat(${ARCHETYPES.length}, 1fr)`; return (
{ARCHETYPES.map((arch) => (
{ARCHETYPE_LABELS[arch]}
))} {TRAIT_DIMS.map((dim) => (
{TRAIT_LABELS[dim]}
{ARCHETYPES.map((arch) => { const entry = merged.byArchetype[arch] && merged.byArchetype[arch][dim]; if (!entry || entry.count === 0) { return
; } const avg = entry.sum / entry.count; const lvl = deltaToLevel(avg); const tip = `Δ ${avg.toFixed(2)} · ${entry.count} scan${entry.count !== 1 ? "s" : ""}`; return
; })} ))}
Less gap {HEAT_COLORS.map((c, i) => ( ))} More gap
); } // ── Gap table ── function GapTable({ rows }) { if (!rows.length) { return ; } const th = { textAlign: "left", fontFamily: "var(--font-mono-display)", fontSize: 10, letterSpacing: ".08em", textTransform: "uppercase", color: "var(--text-muted)", padding: "0 10px 10px", borderBottom: "1px solid var(--border-subtle)", whiteSpace: "nowrap" }; const td = { fontFamily: "var(--font-sans)", fontSize: 13, color: "var(--text-secondary)", padding: "10px", borderBottom: "1px solid var(--border-subtle)", whiteSpace: "nowrap" }; const tdNum = { ...td, fontFamily: "var(--font-mono)", textAlign: "right", color: "var(--text-primary)" }; return (
{rows.map((r, i) => ( ))}
Archetype Trait Value Avg delta Scans Gap score
{ARCHETYPE_LABELS[r.archetype] || r.archetype} {TRAIT_LABELS[r.dim] || r.dim} {r.value} {r.avgDelta.toFixed(2)} {r.scanCount} {r.gapScore.toFixed(2)}
); } // ── Swap / retake rate cards ── function RateGrid({ merged, kind }) { const isSwap = kind === "swap"; const src = isSwap ? merged.swapRate : merged.retakeRate; const accent = isSwap ? "var(--app-accent)" : "var(--negative)"; return (
{ARCHETYPES.map((arch) => { const rec = src[arch] || (isSwap ? { overrideCount: 0, total: 0 } : { retakeCount: 0, total: 0 }); const num = isSwap ? rec.overrideCount : rec.retakeCount; const pct = rec.total > 0 ? ((num / rec.total) * 100).toFixed(1) : "0.0"; return (
{ARCHETYPE_LABELS[arch]} {pct}% {num} / {rec.total}
); })}
); } // ── Inline avatar_catalog editor ── function escapeForInput(s) { return s == null ? "" : String(s); } function EditorCard({ id, data, onSave }) { const displayName = DISPLAY_NAMES[id] || id; const initial = {}; for (const f of CATALOG_FIELDS) { const val = data[f.key]; if (f.type === "csv") { initial[f.key] = Array.isArray(val) ? val.join(", ") : escapeForInput(val); } else if (f.type === "range") { initial[f.key] = Array.isArray(val) && val.length === 2 ? `${val[0]}, ${val[1]}` : ""; } else if (f.type === "bool") { initial[f.key] = !!val; } else { initial[f.key] = escapeForInput(val); } } const [vals, setVals] = useState(initial); const [status, setStatus] = useState(null); // {kind:'ok'|'err'|'saving', text} const setField = (key, v) => setVals((prev) => ({ ...prev, [key]: v })); const handleSave = async () => { setStatus({ kind: "saving", text: "Saving…" }); const update = {}; for (const f of CATALOG_FIELDS) { const v = vals[f.key]; if (f.type === "csv") { const raw = String(v || "").trim(); if (raw === "") { update[f.key] = f.key === "flower_color_affinity" ? null : []; } else { update[f.key] = raw.split(",").map((s) => s.trim()).filter(Boolean); } } else if (f.type === "range") { const parts = String(v || "").split(",").map((s) => parseFloat(s.trim())).filter((n) => !Number.isNaN(n)); if (parts.length === 2) update[f.key] = parts; } else if (f.type === "bool") { update[f.key] = !!v; } else { update[f.key] = String(v || "").trim(); } } try { await onSave(id, update); setStatus({ kind: "ok", text: `Saved ${new Date().toLocaleTimeString()}` }); } catch (e) { console.error("[Traits] save failed:", e); setStatus({ kind: "err", text: `Save failed: ${e.message}` }); } }; const statusColor = status ? (status.kind === "ok" ? "var(--app-accent)" : status.kind === "err" ? "var(--negative)" : "var(--text-muted)") : "var(--text-muted)"; return (
{(displayName[0] || "?").toUpperCase()}
{displayName}
{CATALOG_FIELDS.map((f) => { if (f.type === "bool") { return ( ); } return (
setField(f.key, e.target.value)} style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--text-primary)", background: "var(--surface-card)", border: "1px solid var(--border-default)", borderRadius: "var(--radius-md)", padding: "7px 9px", outline: "none", width: "100%", boxSizing: "border-box" }} />
); })}
{status ? status.text : ""}
); } function Editor() { const [open, setOpen] = useState(false); const [state, setState] = useState({ loading: false, error: null, docs: null }); const load = async () => { setState({ loading: true, error: null, docs: null }); try { await window.FlorioReady; const { db, collection, getDocs } = window.FlorioDB; const snap = await getDocs(collection(db, "avatar_catalog")); const docs = []; snap.forEach((d) => docs.push({ id: d.id, data: d.data() })); docs.sort((a, b) => a.id.localeCompare(b.id)); setState({ loading: false, error: null, docs }); } catch (e) { console.error("[Traits] editor load failed:", e); setState({ loading: false, error: e.message, docs: null }); } }; const toggle = () => { const next = !open; setOpen(next); if (next && !state.docs && !state.loading) load(); }; const saveAvatar = async (id, update) => { await window.FlorioReady; const { db, doc, setDoc, serverTimestamp } = window.FlorioDB; const payload = { ...update, schemaVersion: 2, id, updatedAt: serverTimestamp() }; await setDoc(doc(db, "avatar_catalog", id), payload, { merge: true }); }; return ( {open ? "Hide editor" : "Open editor"} } > {!open ? (
Edit per-avatar trait affinities in avatar_catalog. Changes are written with schemaVersion 2 and an updatedAt timestamp.
) : state.loading ? (
) : state.error ? (
Failed to load catalog: {state.error}
) : !state.docs || state.docs.length === 0 ? ( ) : (
{state.docs.map((d) => ( ))}
)} ); } // Pull P out lazily (window.PA defined by Widgets.jsx). let P; function Traits({ range }) { P = window.PA; const [d, setD] = useState(null); useEffect(() => { let alive = true; setD(null); (async () => { await window.FlorioReady; const API = window.FlorioAPI; const dates = (range && range.start && range.end) ? API.getDateRangeBetween(range.start, range.end) : API.getDateRange(rangeToDays(range && range.key ? range.key : range)); let data = {}; try { data = await API.fetchDailyAggregates(dates); } catch (e) { console.error("[Traits] fetchDailyAggregates failed:", e); data = {}; } if (!alive) return; const merged = mergeTraitRollups(dates, data); const gapRows = buildGapRows(merged); const heatHasData = ARCHETYPES.some((a) => TRAIT_DIMS.some((dim) => { const e = merged.byArchetype[a] && merged.byArchetype[a][dim]; return e && e.count > 0; }) ); const swapHasData = ARCHETYPES.some((a) => merged.swapRate[a] && merged.swapRate[a].total > 0); const retakeHasData = ARCHETYPES.some((a) => merged.retakeRate[a] && merged.retakeRate[a].total > 0); setD({ merged, gapRows, dayCount: dates.length, heatHasData, swapHasData, retakeHasData }); })(); return () => { alive = false; }; }, [range]); if (!d) { return (
); } return (
{d.heatHasData ? : } {d.swapHasData ? : } {d.retakeHasData ? : }
); } (window.FlorioTabs = window.FlorioTabs || {}).traits = Traits; })();