A Next.js 16 + Prisma/Postgres dashboard that pairs with a local Hermes agent over a Postgres message bus: dispatch work, approve side-effecting actions, browse the agent's memory, and watch it run. Ships with an agent-onboarding prompt (ONBOARDING.md) so your Hermes can install it for you step by step. All secrets are env-configured; nothing sensitive is bundled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
export const dynamic = "force-dynamic";
|
|
import { NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
/** Extract the latest metrics from either a flat metrics object or a checkpoints time-series */
|
|
function extractMetrics(raw: unknown): { views: number; likes: number; bookmarks: number; replies: number; retweets: number } {
|
|
if (!raw || typeof raw !== "object") return { views: 0, likes: 0, bookmarks: 0, replies: 0, retweets: 0 };
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
// Flat metrics: { views, likes, bookmarks, replies, retweets }
|
|
if (typeof obj.views === "number" || typeof obj.likes === "number") {
|
|
return {
|
|
views: Number(obj.views) || 0,
|
|
likes: Number(obj.likes) || 0,
|
|
bookmarks: Number(obj.bookmarks) || 0,
|
|
replies: Number(obj.replies) || 0,
|
|
retweets: Number(obj.retweets) || 0,
|
|
};
|
|
}
|
|
|
|
// Checkpoints time-series: { "1h": {...}, "grok_2026-02-15": {...}, ... }
|
|
// Pick the checkpoint with the highest views
|
|
let best = { views: 0, likes: 0, bookmarks: 0, replies: 0, retweets: 0 };
|
|
for (const val of Object.values(obj)) {
|
|
if (val && typeof val === "object") {
|
|
const cp = val as Record<string, unknown>;
|
|
const v = Number(cp.views) || 0;
|
|
if (v > best.views) {
|
|
best = {
|
|
views: v,
|
|
likes: Number(cp.likes) || 0,
|
|
bookmarks: Number(cp.bookmarks) || 0,
|
|
replies: Number(cp.replies) || 0,
|
|
retweets: Number(cp.retweets) || 0,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export async function GET() {
|
|
try {
|
|
// Fetch posted drafts
|
|
const dbDrafts = await prisma.draft.findMany({
|
|
where: { status: "posted" },
|
|
orderBy: { postedAt: "desc" },
|
|
});
|
|
|
|
// Fetch all tweet metrics (includes external tweets not in drafts)
|
|
const dbMetrics = await prisma.tweetMetric.findMany();
|
|
const existingDraftIds = new Set(dbDrafts.map((d) => d.id));
|
|
const matchedTweetIds = new Set<string>();
|
|
|
|
const tweets: {
|
|
id: string;
|
|
text: string;
|
|
views: number;
|
|
likes: number;
|
|
bookmarks: number;
|
|
replies: number;
|
|
retweets: number;
|
|
engRate: number;
|
|
postedAt: Date | string | null;
|
|
tweetUrl: string | null;
|
|
}[] = [];
|
|
|
|
// Process drafts
|
|
for (const d of dbDrafts) {
|
|
// Find matching metric for this draft
|
|
const metric = dbMetrics.find(
|
|
(m) => m.draftId === d.id || (d.tweetId && m.tweetId === d.tweetId)
|
|
);
|
|
if (metric?.tweetId) matchedTweetIds.add(metric.tweetId);
|
|
|
|
// Use draft.metrics if available, otherwise fall back to metric checkpoints
|
|
const rawMetrics = d.metrics ?? metric?.checkpoints;
|
|
if (!rawMetrics && !d.tweetUrl) continue; // skip if no data at all
|
|
|
|
const { views, likes, bookmarks, replies, retweets } = extractMetrics(rawMetrics);
|
|
const engRate =
|
|
views > 0
|
|
? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100
|
|
: 0;
|
|
|
|
tweets.push({
|
|
id: d.id,
|
|
text: d.text || "",
|
|
views,
|
|
likes,
|
|
bookmarks,
|
|
replies,
|
|
retweets,
|
|
engRate,
|
|
postedAt: d.postedAt,
|
|
tweetUrl: d.tweetUrl,
|
|
});
|
|
}
|
|
|
|
// Add external tweets (posted directly on X, not through Hermy HQ)
|
|
for (const m of dbMetrics) {
|
|
if (!m.tweetId || matchedTweetIds.has(m.tweetId)) continue;
|
|
const draftIdExists = m.draftId && existingDraftIds.has(m.draftId);
|
|
if (draftIdExists) continue;
|
|
|
|
const { views, likes, bookmarks, replies, retweets } = extractMetrics(m.checkpoints);
|
|
if (views === 0 && likes === 0) continue; // skip empty entries
|
|
|
|
const engRate =
|
|
views > 0
|
|
? Math.round(((likes + bookmarks + replies) / views) * 10000) / 100
|
|
: 0;
|
|
|
|
tweets.push({
|
|
id: m.draftId || `tweet-${m.tweetId}`,
|
|
text: "",
|
|
views,
|
|
likes,
|
|
bookmarks,
|
|
replies,
|
|
retweets,
|
|
engRate,
|
|
postedAt: m.postedAt,
|
|
tweetUrl: m.url,
|
|
});
|
|
|
|
matchedTweetIds.add(m.tweetId);
|
|
}
|
|
|
|
// Sort by views desc
|
|
tweets.sort((a, b) => b.views - a.views);
|
|
|
|
// Build heatmap: 7 days x 24 hours
|
|
const heatmap: Record<string, { sum: number; count: number }> = {};
|
|
for (const t of tweets) {
|
|
if (!t.postedAt || t.views === 0) continue;
|
|
const dt = new Date(t.postedAt as string);
|
|
const key = `${dt.getUTCDay()}-${dt.getUTCHours()}`;
|
|
if (!heatmap[key]) heatmap[key] = { sum: 0, count: 0 };
|
|
heatmap[key].sum += t.views;
|
|
heatmap[key].count++;
|
|
}
|
|
const heatmapData = Object.entries(heatmap).map(([key, v]) => {
|
|
const [day, hour] = key.split("-").map(Number);
|
|
return {
|
|
day,
|
|
hour,
|
|
avgViews: Math.round(v.sum / v.count),
|
|
count: v.count,
|
|
};
|
|
});
|
|
|
|
return NextResponse.json({ tweets, heatmap: heatmapData });
|
|
} catch {
|
|
return NextResponse.json({ tweets: [], heatmap: [] });
|
|
}
|
|
}
|