// 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 (
| Archetype |
Trait |
Value |
Avg delta |
Scans |
Gap score |
{rows.map((r, i) => (
| {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;
})();