Expand taste profile: show up to 60 tags with score bars
Top 10 shown as variable-size tag cloud, all tags below as a two-column bar chart. Backend limit raised from 20 to 60. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -114,7 +114,7 @@ def get_stats(
|
|||||||
SELECT tag, score FROM user_tag_affinity
|
SELECT tag, score FROM user_tag_affinity
|
||||||
WHERE user_id = :uid AND score > 0
|
WHERE user_id = :uid AND score > 0
|
||||||
ORDER BY score DESC
|
ORDER BY score DESC
|
||||||
LIMIT 20
|
LIMIT 60
|
||||||
"""),
|
"""),
|
||||||
{"uid": uid},
|
{"uid": uid},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export default function Stats() {
|
|||||||
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
|
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
|
||||||
|
|
||||||
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
|
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
|
||||||
const topTags = (data.taste_profile || []).slice(0, 12);
|
const allTags = data.taste_profile || [];
|
||||||
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1);
|
const maxTagScore = Math.max(...allTags.map(t => t.score || 0), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
||||||
@@ -235,40 +235,76 @@ export default function Stats() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Taste profile */}
|
{/* Taste profile */}
|
||||||
{topTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-4">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
|
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
|
||||||
<p className="text-[11px] text-zinc-600">built from your watches, likes and bookmarks</p>
|
<p className="text-[11px] text-zinc-600">{allTags.length} interests · built from watches, likes & bookmarks</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top tier tag cloud */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-zinc-600 uppercase tracking-wider mb-2">Top interests</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{topTags.map(t => {
|
{allTags.slice(0, 10).map(t => {
|
||||||
const intensity = t.score / maxTagScore;
|
const intensity = t.score / maxTagScore;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={t.tag}
|
key={t.tag}
|
||||||
title={`score: ${t.score.toFixed(1)}`}
|
title={`score: ${t.score.toFixed(1)}`}
|
||||||
className="flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium"
|
className="flex items-center gap-1 px-3 py-1 rounded-full font-semibold"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `rgba(250,204,21,${0.08 + intensity * 0.18})`,
|
backgroundColor: `rgba(250,204,21,${0.1 + intensity * 0.2})`,
|
||||||
color: `hsl(50,95%,${55 + intensity * 20}%)`,
|
color: `hsl(50,95%,${55 + intensity * 20}%)`,
|
||||||
fontSize: `${11 + intensity * 4}px`,
|
fontSize: `${12 + intensity * 5}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.tag}
|
{t.tag}
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteTag.mutate(t.tag)}
|
onClick={() => deleteTag.mutate(t.tag)}
|
||||||
disabled={deleteTag.isPending}
|
disabled={deleteTag.isPending}
|
||||||
className="opacity-40 hover:opacity-100 transition-opacity leading-none ml-0.5"
|
className="opacity-30 hover:opacity-100 transition-opacity leading-none ml-0.5"
|
||||||
title="Remove from taste profile"
|
title="Remove"
|
||||||
>
|
>×</button>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* All tags as score bars */}
|
||||||
|
{allTags.length > 10 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-zinc-600 uppercase tracking-wider mb-3">All interests</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
|
{allTags.map(t => {
|
||||||
|
const pct = (t.score / maxTagScore) * 100;
|
||||||
|
const intensity = t.score / maxTagScore;
|
||||||
|
return (
|
||||||
|
<div key={t.tag} className="flex items-center gap-2 group/tag">
|
||||||
|
<span className="text-[12px] text-zinc-300 w-32 shrink-0 truncate">{t.tag}</span>
|
||||||
|
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: `hsl(50,95%,${40 + intensity * 25}%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteTag.mutate(t.tag)}
|
||||||
|
disabled={deleteTag.isPending}
|
||||||
|
className="opacity-0 group-hover/tag:opacity-40 hover:!opacity-100 transition-opacity text-zinc-400 text-xs leading-none shrink-0"
|
||||||
|
title="Remove"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user