Per Ryan 2026-05-15: - Removed Vector pass footer (no audience value) - Removed PHASE 6b comment - Removed corner brackets + monitoring subtitle + rank prefix - Top 5 entities now show as Sony +12 / Riedel +8 — entity name serif + count mono, right-aligned - Background #F5EFEB (beige; #F5EFFEB had 7 hex chars, interpreted as #F5EFEB) via new --color-trends-bg token - Text #2F4F5B (navy) via new --color-trends-text token - Tokens added to redesign-tokens.css palette section Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
import { createClient } from "@supabase/supabase-js";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const revalidate = 21600; // 6h
|
|
|
|
interface Article {
|
|
title: string;
|
|
excerpt: string | null;
|
|
tags: string[] | null;
|
|
published_at: string | null;
|
|
}
|
|
|
|
function detectTrends(recent: Article[], baseline: Article[]) {
|
|
const recentCounts: Record<string, number> = {};
|
|
for (const a of recent) for (const t of a.tags || []) {
|
|
const k = t.toLowerCase();
|
|
recentCounts[k] = (recentCounts[k] || 0) + 1;
|
|
}
|
|
const baseCounts: Record<string, number> = {};
|
|
for (const a of baseline) for (const t of a.tags || []) {
|
|
const k = t.toLowerCase();
|
|
baseCounts[k] = (baseCounts[k] || 0) + 1;
|
|
}
|
|
const scored = Object.entries(recentCounts).map(([k, c]) => {
|
|
const baseline_per_day = (baseCounts[k] || 0) / 30;
|
|
const recent_per_day = c / 7;
|
|
const lift = baseline_per_day > 0 ? recent_per_day / baseline_per_day : recent_per_day * 3;
|
|
return { entity: k, lift, count: c };
|
|
});
|
|
scored.sort((a, b) => b.lift - a.lift);
|
|
return scored.slice(0, 5);
|
|
}
|
|
|
|
function titleCase(s: string): string {
|
|
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
export default async function TrendsSidebar() {
|
|
const sb = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
db: { schema: (process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb") as "public" },
|
|
auth: { persistSession: false },
|
|
}
|
|
);
|
|
|
|
const since7d = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
const since30d = new Date(Date.now() - 30 * 86400000).toISOString();
|
|
const [recent, baseline] = await Promise.all([
|
|
sb
|
|
.from("wp_imported_posts")
|
|
.select("title, excerpt, tags, wp_published_at")
|
|
.eq("status", "published")
|
|
.gte("wp_published_at", since7d)
|
|
.order("wp_published_at", { ascending: false })
|
|
.limit(200),
|
|
sb
|
|
.from("wp_imported_posts")
|
|
.select("title, excerpt, tags, wp_published_at")
|
|
.eq("status", "published")
|
|
.gte("wp_published_at", since30d)
|
|
.lt("wp_published_at", since7d)
|
|
.order("wp_published_at", { ascending: false })
|
|
.limit(800),
|
|
]);
|
|
|
|
const recentArts: Article[] = (recent.data || []).map((r: any) => ({
|
|
title: r.title, excerpt: r.excerpt, tags: r.tags, published_at: r.wp_published_at,
|
|
}));
|
|
const baselineArts: Article[] = (baseline.data || []).map((r: any) => ({
|
|
title: r.title, excerpt: r.excerpt, tags: r.tags, published_at: r.wp_published_at,
|
|
}));
|
|
const trends = detectTrends(recentArts, baselineArts);
|
|
|
|
return (
|
|
<aside
|
|
className="rounded p-4"
|
|
style={{
|
|
background: "var(--color-trends-bg, #F5EFEB)",
|
|
color: "var(--color-trends-text, #2F4F5B)",
|
|
borderRadius: "var(--border-radius-md, 6px)",
|
|
}}
|
|
aria-label="BB AI detected trends"
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="font-serif text-base font-semibold">BB AI · detected trends</h2>
|
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider opacity-70">
|
|
<span className="bb-pulse-dot" style={{ background: "var(--color-trends-text, #2F4F5B)" }} />
|
|
live
|
|
</span>
|
|
</div>
|
|
|
|
<ul className="space-y-1.5">
|
|
{trends.length === 0 && (
|
|
<li className="text-xs opacity-60">No trend data yet.</li>
|
|
)}
|
|
{trends.map((t) => (
|
|
<li key={t.entity} className="flex items-center justify-between text-sm">
|
|
<span className="truncate font-serif">{titleCase(t.entity)}</span>
|
|
<span className="font-mono text-[12px] opacity-80">
|
|
+{Math.round((t.lift - 1) * 100) || t.count}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</aside>
|
|
);
|
|
}
|