feat: add tag discovery analytics and reporting

This commit is contained in:
2026-03-17 18:23:38 +01:00
parent b3fc889452
commit 2728644477
29 changed files with 2660 additions and 112 deletions

View File

@@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
{!loading &&
results.map((tag) => {
const isSelected = selected.some((t) => t.id === tag.id)
const recentClicks = Number(tag.recent_clicks || 0)
const usageCount = Number(tag.usage_count || 0)
return (
<button
key={tag.id}
@@ -174,7 +176,9 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
<span className="text-xs text-slate-500">
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
</span>
</button>
)
})}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import BulkTagModal from './BulkTagModal'
describe('BulkTagModal', () => {
beforeEach(() => {
document.head.innerHTML = '<meta name="csrf-token" content="test-token">'
global.fetch = vi.fn(async (url) => {
const requestUrl = String(url)
if (requestUrl.includes('?q=high')) {
return {
json: async () => ([
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
]),
}
}
return {
json: async () => ([
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
]),
}
})
})
afterEach(() => {
vi.restoreAllMocks()
document.head.innerHTML = ''
})
it('shows recent click momentum for initial results', async () => {
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={() => {}} />)
await waitFor(() => {
expect(screen.getByText('Popular Pick')).not.toBeNull()
})
expect(screen.getByText('9 recent clicks')).not.toBeNull()
})
it('returns selected tag ids and shows recent click momentum in search results', async () => {
const onConfirm = vi.fn()
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={onConfirm} />)
const input = screen.getByPlaceholderText('Search tags…')
await userEvent.type(input, 'high')
await waitFor(() => {
expect(screen.getByText('18 recent clicks')).not.toBeNull()
})
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i }))
expect(onConfirm).toHaveBeenCalledWith([2])
})
})

View File

@@ -52,7 +52,8 @@ function toSuggestionItems(raw) {
key: item?.id ?? tag,
label: item?.name || item?.tag || item?.slug || tag,
tag,
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
usageCount: Number.isFinite(Number(item?.usage_count)) ? Number(item.usage_count) : null,
recentClicks: Number.isFinite(Number(item?.recent_clicks)) ? Number(item.recent_clicks) : 0,
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
}
})
@@ -173,6 +174,9 @@ function SuggestionDropdown({
{!loading && !error && suggestions.map((item, index) => {
const active = highlightedIndex === index
const detailLabel = item.recentClicks > 0
? `${item.recentClicks.toLocaleString()} recent`
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
return (
<li
key={item.key}
@@ -192,8 +196,8 @@ function SuggestionDropdown({
</span>
)}
</div>
{typeof item.usageCount === 'number' && (
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
{detailLabel && (
<span className="shrink-0 text-[11px] text-white/50">{detailLabel}</span>
)}
</li>
)

View File

@@ -26,8 +26,8 @@ describe('TagInput', () => {
return {
data: {
data: [
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 },
{ id: 2, name: 'city', slug: 'city', usage_count: 30, recent_clicks: 14 },
],
},
}
@@ -74,6 +74,17 @@ describe('TagInput', () => {
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
})
it('shows recent click momentum when suggestions provide it', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.type(input, 'city')
await waitFor(() => {
expect(screen.getByText('14 recent')).not.toBeNull()
})
})
it('supports comma-separated paste', async () => {
render(<Harness />)

View File

@@ -37,15 +37,18 @@ function toListItem(item) {
if (!item) return null
if (typeof item === 'string') {
const slug = normalizeSlug(item)
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
return slug ? { key: slug, slug, name: slug, usageCount: null, recentClicks: 0, isAi: false } : null
}
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
if (!slug) return null
const usageCount = Number(item.usage_count)
const recentClicks = Number(item.recent_clicks)
return {
key: String(item.id ?? slug),
slug,
name: item.name || item.tag || item.slug || slug,
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
usageCount: Number.isFinite(usageCount) ? usageCount : null,
recentClicks: Number.isFinite(recentClicks) ? recentClicks : 0,
isAi: Boolean(item.is_ai || item.source === 'ai'),
}
}
@@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) {
}
function ListRow({ item, isSelected, onToggle, disabled }) {
const detailLabel = item.recentClicks > 0
? `${item.recentClicks.toLocaleString()} recent clicks`
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
return (
<button
type="button"
@@ -154,9 +161,9 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
)}
</span>
{typeof item.usageCount === 'number' && (
{detailLabel && (
<span className="ml-3 shrink-0 text-[11px] text-white/40">
{item.usageCount.toLocaleString()} uses
{detailLabel}
</span>
)}
</button>

View File

@@ -0,0 +1,76 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagPicker from './TagPicker'
function Harness({ initial = [] }) {
const [tags, setTags] = React.useState(initial)
return (
<TagPicker
value={tags}
onChange={setTags}
suggestedTags={['sunset']}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
/>
)
}
describe('TagPicker', () => {
beforeEach(() => {
window.axios = {
get: vi.fn(async (url) => {
if (url.startsWith('/api/tags/search')) {
return {
data: {
data: [
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
],
},
}
}
return {
data: {
data: [
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
],
},
}
}),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows recent click momentum for popular tags on mount', async () => {
render(<Harness />)
await waitFor(() => {
expect(screen.getByText('Popular Pick')).not.toBeNull()
})
expect(screen.getByText('9 recent clicks')).not.toBeNull()
})
it('shows recent click momentum in search results and lets the user select a tag', async () => {
render(<Harness />)
const input = screen.getByLabelText('Search or add tags')
await userEvent.type(input, 'high')
await waitFor(() => {
expect(screen.getByText('18 recent clicks')).not.toBeNull()
})
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
expect(screen.getByText('High Contrast')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
})
})