Hot Topics - Frontend Integration Guide
Purpose: Guide for Frontend Claude to integrate the Hot Topics visualization feature Last Updated: 2026-01-20 Status: Ready for implementation
Overview
Hot Topics is a privacy-preserving analytics feature that shows trending query topics within an organization. Users can see what topics are being queried most frequently without exposing raw query text or PII.
Privacy Features:
- Raw queries are never stored or exposed - only derived topic tags
- Minimum threshold (โฅ3 queries) prevents identifying individual queries
- Unique user counts hidden if below threshold
- Topics older than 90 days excluded from visualization
API Endpoint
Base URL: https://<project-ref>.supabase.co/functions/v1/dashboardApi/hot-topics
Authentication: JWT in Authorization header
Authorization: Bearer <user-jwt>
Response Formats
The API supports three response formats via the format query parameter.
1. Ranking Format (Default)
Best for: Leaderboard tables, ranked lists
Request:
const response = await fetch("/dashboardApi/hot-topics?days=30&limit=20", {
headers: { Authorization: `Bearer ${jwt}` },
});
Response:
{
format: "ranking",
data: [
{
rank: 1, // 1-based rank
topic: "budget-planning", // Hyphenated tag (for filtering/grouping)
display_name: "Budget Planning", // Human-readable (for display)
query_count: 47, // Total queries with this topic
unique_users: 12, // Number of unique users (null if <3)
trend: {
direction: "up", // "up" | "down" | "stable"
change_percent: 25 // vs previous period (null if new topic)
},
first_seen: "2026-01-05T10:30:00Z",
last_seen: "2026-01-19T15:45:00Z"
},
// ... more topics
],
summary: {
total_topics: 45, // Total distinct topics
total_queries_analyzed: 342, // Total queries with topics
coverage_percent: 0, // Reserved for future use
trending_up: 12, // Topics with upward trend
new_this_period: 8 // Topics first seen this period
},
period: {
days: 30,
start_date: "2025-12-21T00:00:00Z",
end_date: "2026-01-20T00:00:00Z"
}
}
2. Time Series Format
Best for: Line charts, trend visualization
Request:
const response = await fetch(
"/dashboardApi/hot-topics?format=timeseries&days=14",
{
headers: { Authorization: `Bearer ${jwt}` },
},
);
Response:
{
format: "timeseries",
data: [
{
date: "2026-01-06", // YYYY-MM-DD
topics: {
"budget-planning": 5,
"compliance-audit": 3,
"transcript-meeting": 7
}
},
{
date: "2026-01-07",
topics: {
"budget-planning": 8,
"compliance-audit": 2,
"transcript-meeting": 4
}
},
// ... more days
],
topics_included: [ // All topics in the data
"budget-planning",
"compliance-audit",
"transcript-meeting"
],
period: {
days: 14,
start_date: "2026-01-06T00:00:00Z",
end_date: "2026-01-20T00:00:00Z"
}
}
3. Word Cloud Format
Best for: Word cloud visualizations (with libraries like wordcloud2.js)
Request:
const response = await fetch(
"/dashboardApi/hot-topics?format=wordcloud&limit=50",
{
headers: { Authorization: `Bearer ${jwt}` },
},
);
Response:
{
format: "wordcloud",
data: [
{ text: "Budget Planning", value: 47 },
{ text: "Compliance Audit", value: 32 },
{ text: "Meeting Transcript", value: 28 },
// ... more topics
],
period: {
days: 30,
start_date: "2025-12-21T00:00:00Z",
end_date: "2026-01-20T00:00:00Z"
}
}
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
days |
integer | 30 | Lookback period (max 90) |
limit |
integer | 20 | Number of topics to return (max 50) |
min_count |
integer | 3 | Minimum query count threshold (enforced min: 3) |
format |
string | "ranking" | Response format: ranking, timeseries, wordcloud |
org_id |
string | - | Filter by organization (defaults to user's org) |
access_filter |
integer | - | Min access level to include (reduces noise from lower tiers) |
Access Level Filtering
By default, users only see topics from queries at โค their access_level (matching document RLS). This is enforced server-side based on the JWT claims.
The access_filter parameter allows users to optionally filter out topics from lower access levels. This is useful when:
- Users with high access levels want to see only topics relevant to their tier
- Lower-tier topics create "white noise" that obscures more relevant trends
Example: A user with access_level=3 will see topics from levels 0, 1, 2, and 3 by default. Adding ?access_filter=2 will show only topics from levels 2 and 3.
// Filter to show only topics from access_level >= 2
const response = await fetch("/dashboardApi/hot-topics?access_filter=2", {
headers: { Authorization: `Bearer ${jwt}` },
});
TypeScript Types
Copy these to your frontend codebase:
// Trend direction for topic comparison
type TrendDirection = "up" | "down" | "stable";
// Individual topic item in ranking format
interface HotTopicItem {
rank: number;
topic: string; // Hyphenated format: "budget-planning"
display_name: string; // Human-readable: "Budget Planning"
query_count: number;
unique_users: number | null; // Null if below privacy threshold
trend: {
direction: TrendDirection;
change_percent: number | null; // Null if new topic
};
first_seen: string; // ISO timestamp
last_seen: string; // ISO timestamp
}
// Summary statistics
interface HotTopicsSummary {
total_topics: number;
total_queries_analyzed: number;
coverage_percent: number;
trending_up: number;
new_this_period: number;
}
// Period information (common to all formats)
interface HotTopicsPeriod {
days: number;
start_date: string;
end_date: string;
}
// Ranking format response
interface HotTopicsRankingResponse {
format: "ranking";
data: HotTopicItem[];
summary: HotTopicsSummary;
period: HotTopicsPeriod;
}
// Time series data point
interface HotTopicsTimeSeriesPoint {
date: string; // YYYY-MM-DD
topics: Record<string, number>; // { "budget-planning": 5, ... }
}
// Time series format response
interface HotTopicsTimeSeriesResponse {
format: "timeseries";
data: HotTopicsTimeSeriesPoint[];
topics_included: string[];
period: HotTopicsPeriod;
}
// Word cloud item
interface HotTopicsWordCloudItem {
text: string; // Human-readable display name
value: number; // Weight (query count)
}
// Word cloud format response
interface HotTopicsWordCloudResponse {
format: "wordcloud";
data: HotTopicsWordCloudItem[];
period: HotTopicsPeriod;
}
// Union type for all response formats
type HotTopicsResponse =
| HotTopicsRankingResponse
| HotTopicsTimeSeriesResponse
| HotTopicsWordCloudResponse;
Component Implementations
1. Hot Topics Ranking Table
async function renderHotTopicsTable(container, jwt, days = 30) {
const response = await fetch(
`/dashboardApi/hot-topics?days=${days}&limit=20`,
{
headers: { Authorization: `Bearer ${jwt}` },
},
);
const { data, summary, period } = await response.json();
// Build table HTML
let html = `
<div class="hot-topics-header">
<h3>Hot Topics</h3>
<small class="text-muted">Last ${period.days} days</small>
</div>
<div class="hot-topics-summary">
<span class="badge bg-primary">${summary.total_topics} topics</span>
<span class="badge bg-success">${summary.trending_up} trending up</span>
<span class="badge bg-info">${summary.new_this_period} new</span>
</div>
<table class="table table-hover">
<thead>
<tr>
<th>Rank</th>
<th>Topic</th>
<th>Queries</th>
<th>Users</th>
<th>Trend</th>
</tr>
</thead>
<tbody>
`;
data.forEach((topic) => {
const trendIcon = getTrendIcon(topic.trend.direction);
const trendClass = getTrendClass(topic.trend.direction);
const changeText =
topic.trend.change_percent !== null
? `${topic.trend.change_percent > 0 ? "+" : ""}${
topic.trend.change_percent
}%`
: "New";
html += `
<tr>
<td><span class="rank-badge rank-${
topic.rank <= 3 ? topic.rank : "other"
}">${topic.rank}</span></td>
<td>${escapeHtml(topic.display_name)}</td>
<td>${topic.query_count}</td>
<td>${topic.unique_users !== null ? topic.unique_users : "โ"}</td>
<td class="${trendClass}">${trendIcon} ${changeText}</td>
</tr>
`;
});
html += "</tbody></table>";
container.innerHTML = html;
}
function getTrendIcon(direction) {
switch (direction) {
case "up":
return "โ";
case "down":
return "โ";
default:
return "โ";
}
}
function getTrendClass(direction) {
switch (direction) {
case "up":
return "text-success";
case "down":
return "text-danger";
default:
return "text-muted";
}
}
function escapeHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
2. Hot Topics Trend Chart (Chart.js)
async function renderHotTopicsTrendChart(canvasId, jwt, days = 14) {
const response = await fetch(
`/dashboardApi/hot-topics?format=timeseries&days=${days}`,
{
headers: { Authorization: `Bearer ${jwt}` },
},
);
const { data, topics_included, period } = await response.json();
const ctx = document.getElementById(canvasId).getContext("2d");
// Prepare datasets for each topic
const colors = [
"#FF6384",
"#36A2EB",
"#FFCE56",
"#4BC0C0",
"#9966FF",
"#FF9F40",
"#FF6384",
"#C9CBCF",
"#7BC225",
"#E8C3B9",
];
const datasets = topics_included.map((topic, index) => ({
label: prettifyTopic(topic),
data: data.map((day) => day.topics[topic] || 0),
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length] + "20",
fill: false,
tension: 0.4,
}));
new Chart(ctx, {
type: "line",
data: {
labels: data.map((day) => formatDate(day.date)),
datasets: datasets,
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: `Topic Trends - Last ${period.days} Days`,
},
legend: {
position: "bottom",
},
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: "Query Count" },
},
},
},
});
}
function prettifyTopic(topic) {
return topic
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
3. Hot Topics Word Cloud (wordcloud2.js)
<!-- Include wordcloud2.js -->
<script src="https://cdn.jsdelivr.net/npm/wordcloud@1.2.2/src/wordcloud2.min.js"></script>
<canvas id="wordcloud-canvas" width="800" height="400"></canvas>
<script>
async function renderWordCloud(canvasId, jwt, limit = 50) {
const response = await fetch(
`/dashboardApi/hot-topics?format=wordcloud&limit=${limit}`,
{
headers: { Authorization: `Bearer ${jwt}` },
},
);
const { data, period } = await response.json();
// Transform data for wordcloud2.js
// Format: [[word, weight], [word, weight], ...]
const wordList = data.map((item) => [item.text, item.value]);
// Calculate weight factor to normalize sizes
const maxValue = Math.max(...data.map((d) => d.value));
const weightFactor = 50 / maxValue; // Max font size ~50px
const canvas = document.getElementById(canvasId);
WordCloud(canvas, {
list: wordList.map(([text, value]) => [text, value * weightFactor]),
gridSize: 8,
weightFactor: 1,
fontFamily: "system-ui, -apple-system, sans-serif",
color: function (word, weight) {
// Color based on weight
const hue = Math.floor((1 - weight / 50) * 200); // Blue to red
return `hsl(${hue}, 70%, 50%)`;
},
rotateRatio: 0.3,
rotationSteps: 2,
backgroundColor: "transparent",
click: function (item) {
// Handle click on word
console.log(`Clicked: ${item[0]} (${item[1]} queries)`);
// Could filter other views by this topic
},
});
}
</script>
CSS Styling
/* Hot Topics Table Styling */
.hot-topics-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 1rem;
}
.hot-topics-summary {
margin-bottom: 1rem;
}
.hot-topics-summary .badge {
margin-right: 0.5rem;
}
/* Rank badges */
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
font-weight: bold;
font-size: 0.9rem;
}
.rank-1 {
background: linear-gradient(135deg, #ffd700, #ffa500);
color: #333;
}
.rank-2 {
background: linear-gradient(135deg, #c0c0c0, #a0a0a0);
color: #333;
}
.rank-3 {
background: linear-gradient(135deg, #cd7f32, #8b4513);
color: white;
}
.rank-other {
background: #e9ecef;
color: #666;
}
/* Trend indicators */
.text-success {
color: #28a745 !important;
}
.text-danger {
color: #dc3545 !important;
}
.text-muted {
color: #6c757d !important;
}
/* Dark mode support */
[data-bs-theme="dark"] .rank-other {
background: #495057;
color: #adb5bd;
}
[data-bs-theme="dark"] .hot-topics-header small {
color: #adb5bd;
}
/* Word cloud container */
#wordcloud-canvas {
width: 100%;
max-width: 800px;
height: 400px;
margin: 0 auto;
display: block;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.hot-topics-table th:nth-child(4),
.hot-topics-table td:nth-child(4) {
display: none; /* Hide Users column on mobile */
}
#wordcloud-canvas {
height: 250px;
}
}
Dashboard Integration Patterns
KPI Card
async function renderHotTopicsKPI(container, jwt) {
const response = await fetch("/dashboardApi/hot-topics?days=7&limit=5", {
headers: { Authorization: `Bearer ${jwt}` },
});
const { data, summary } = await response.json();
const topTopic = data[0];
container.innerHTML = `
<div class="kpi-card">
<div class="kpi-icon">๐ฅ</div>
<div class="kpi-content">
<div class="kpi-label">Top Topic This Week</div>
<div class="kpi-value">${topTopic?.display_name || "No data"}</div>
<div class="kpi-subtext">
${topTopic ? `${topTopic.query_count} queries` : ""}
${
summary.trending_up > 0
? ` ยท ${summary.trending_up} trending up`
: ""
}
</div>
</div>
</div>
`;
}
Period Selector Integration
// Period selector handler
document.querySelectorAll(".period-selector button").forEach((btn) => {
btn.addEventListener("click", async (e) => {
const days = parseInt(e.target.dataset.days);
// Update all Hot Topics visualizations
await Promise.all([
renderHotTopicsTable(document.getElementById("topics-table"), jwt, days),
renderHotTopicsTrendChart("topics-chart", jwt, Math.min(days, 30)),
renderWordCloud("wordcloud-canvas", jwt, 50),
]);
// Update active state
document
.querySelectorAll(".period-selector button")
.forEach((b) => b.classList.remove("active"));
e.target.classList.add("active");
});
});
Error Handling
async function fetchHotTopics(jwt, params = {}) {
const queryString = new URLSearchParams(params).toString();
const url = `/dashboardApi/hot-topics${queryString ? "?" + queryString : ""}`;
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Hot Topics fetch error:", error);
// Return empty state for graceful degradation
return {
format: params.format || "ranking",
data: [],
summary: {
total_topics: 0,
total_queries_analyzed: 0,
coverage_percent: 0,
trending_up: 0,
new_this_period: 0,
},
period: {
days: params.days || 30,
start_date: new Date().toISOString(),
end_date: new Date().toISOString(),
},
};
}
}
Testing
Manual Testing Checklist
-
Ranking Format
- [ ] Topics display in correct rank order
- [ ] Trend indicators show correct direction
- [ ] New topics show "New" instead of percentage
- [ ] Unique users shows "โ" when below threshold
-
Time Series Format
- [ ] Chart displays all included topics
- [ ] Dates are in correct order
- [ ] Legend shows human-readable topic names
-
Word Cloud Format
- [ ] All topics visible
- [ ] Size reflects query count
- [ ] Click handler works (if implemented)
-
Period Selection
- [ ] 7/14/30/90 day options work
- [ ] Data refreshes correctly
- [ ] Loading states display
-
Error States
- [ ] Empty state displays when no data
- [ ] API errors show user-friendly message
- [ ] Network errors handled gracefully
Related Files
- API Handler: dashboardApi/handlers/hot-topics.ts
- Types: dashboardApi/types.ts - HotTopics* interfaces
- Migration: 20260119000001_hot_topics_support.sql
- Topic Extraction: _shared/core/topic-extraction.ts
- API README: dashboardApi/README.md
Important: Contributor ID / Document Ownership
The Hot Topics endpoint does not return contributor information (topics are aggregated query analytics). However, other Dashboard API endpoints that return contributor_id and contributor_name now use authoritative document ownership from documents_${orgId}.user_id, NOT the historical transactions.contributor_id.
What This Means for Frontend
No frontend changes required - the API response shape is unchanged. The contributor_id and contributor_name fields are still present in responses from:
/transactions/transactions/:id/leaderboard/contributors/leaderboard/content/leaderboard
The API now returns the current owner of each document rather than the original uploader (if ownership was transferred).
Display Considerations
For historical transactions where ownership was transferred, the displayed contributor reflects the current owner who benefits from RoC earnings. This is intentional - the system now correctly attributes content to its rightful owner.
For full details, see: dashboardApi/README.md#authoritative-document-ownership
Questions?
If you need clarification on:
- Tag structure and canonical terms: See rag/README.md#hot-topics
- Database functions: See 20260119000001_hot_topics_support.sql
- Privacy considerations: See dashboardApi/README.md#10-get-hot-topics