Frontend API Updates - January 2026

New features for handling large document uploads, fire-and-forget processing, and queue management. Modified: 2026-Jan-31 03:15:32 UTC

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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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

  1. 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
  2. Time Series Format

    • [ ] Chart displays all included topics
    • [ ] Dates are in correct order
    • [ ] Legend shows human-readable topic names
  3. Word Cloud Format

    • [ ] All topics visible
    • [ ] Size reflects query count
    • [ ] Click handler works (if implemented)
  4. Period Selection

    • [ ] 7/14/30/90 day options work
    • [ ] Data refreshes correctly
    • [ ] Loading states display
  5. Error States

    • [ ] Empty state displays when no data
    • [ ] API errors show user-friendly message
    • [ ] Network errors handled gracefully


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: