diff --git a/src/app/api/digest/route.ts b/src/app/api/digest/route.ts index f56e89b2..d10551f4 100644 --- a/src/app/api/digest/route.ts +++ b/src/app/api/digest/route.ts @@ -1,103 +1,18 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - generateDailyDigest, - digestToMarkdown, - getTopNews, - getNewSkills, - getTrendingMCP, -} from "@/lib/digest"; +import { NextResponse } from "next/server"; +import { generateWeeklyDigest } from "@/lib/weeklyDigest"; /** * GET /api/digest * - * Returns the daily digest data for Premium subscribers. - * - * Query params: - * - format: "md" for markdown output (default: JSON) - * - news_limit: Number of news items (default: 10) - * - skills_limit: Number of skills (default: 3) - * - mcp_limit: Number of MCP servers (default: 5) - * - hours: Hours to look back for news (default: 24) - * - * Examples: - * GET /api/digest → Full digest as JSON - * GET /api/digest?format=md → Full digest as Markdown - * GET /api/digest?news_limit=5 → Digest with only 5 news items - * GET /api/digest?hours=48 → News from last 48 hours + * Weekly digest computed from real activity: + * - new skills (from skill creation dates) + * - new reviews (data/skill-reviews.json) + * - top trending skills (same ranking source as /api/trending) + * - bounties (new + claimed) */ -export async function GET(request: NextRequest) { - const { searchParams } = request.nextUrl; - - // Parse query params - const format = searchParams.get("format"); - const newsLimit = parseInt(searchParams.get("news_limit") ?? "10", 10); - const skillsLimit = parseInt(searchParams.get("skills_limit") ?? "3", 10); - const mcpLimit = parseInt(searchParams.get("mcp_limit") ?? "5", 10); - const hours = parseInt(searchParams.get("hours") ?? "24", 10); - - // Calculate the since date based on hours - const since = new Date(Date.now() - hours * 60 * 60 * 1000); - - // Check if requesting full digest or custom - const isCustom = - searchParams.has("news_limit") || - searchParams.has("skills_limit") || - searchParams.has("mcp_limit") || - searchParams.has("hours"); - - // Check Accept header for markdown preference - const acceptsMd = - format === "md" || - request.headers.get("accept")?.includes("text/markdown"); - - // Generate digest - let digest; - - if (isCustom) { - // Custom query with specific limits - const news = getTopNews(newsLimit, since); - const skills = getNewSkills(skillsLimit); - const trending = getTrendingMCP(mcpLimit); - - digest = { - generated_at: new Date().toISOString(), - period: { - start: since.toISOString(), - end: new Date().toISOString(), - hours, - }, - news: { - items: news, - count: news.length, - }, - skills: { - items: skills, - count: skills.length, - }, - mcp_servers: { - trending, - count: trending.length, - }, - }; - } else { - // Standard 24h digest - digest = generateDailyDigest(); - } - - // Return markdown if requested - if (acceptsMd) { - const fullDigest = isCustom ? generateDailyDigest() : digest; - const markdown = digestToMarkdown(fullDigest as ReturnType); - - return new NextResponse(markdown, { - headers: { - "Content-Type": "text/markdown; charset=utf-8", - "Cache-Control": "public, max-age=300", // 5 minute cache - }, - }); - } +export async function GET() { + const digest = await generateWeeklyDigest(); - // Return JSON return NextResponse.json(digest, { headers: { "Cache-Control": "public, max-age=300", diff --git a/src/app/digest/page.tsx b/src/app/digest/page.tsx index 218ace81..9d5ea4ce 100644 --- a/src/app/digest/page.tsx +++ b/src/app/digest/page.tsx @@ -1,95 +1,15 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { headers } from "next/headers"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; - -import weeklyDigestsData from "../../../data/weekly-digests.json"; - -type WeeklyDigest = { - id: string; - weekNumber?: number; - startDate: string; // YYYY-MM-DD - endDate: string; // YYYY-MM-DD - title: string; - highlights: string[]; - stats: { - prsShipped: number; - newPages: number; - newFeatures: number; - }; - topSkills: string[]; -}; - -function formatDateRange(startDate: string, endDate: string): string { - const start = new Date(startDate); - const end = new Date(endDate); - - // If dates are invalid, fall back to raw strings. - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return `${startDate} – ${endDate}`; - } - - const sameYear = start.getFullYear() === end.getFullYear(); - const sameMonth = sameYear && start.getMonth() === end.getMonth(); - - // Feb 3–8, 2026 - if (sameMonth) { - const month = start.toLocaleDateString("en-US", { month: "short" }); - return `${month} ${start.getDate()}–${end.getDate()}, ${end.getFullYear()}`; - } - - // Jan 30 – Feb 2, 2026 - if (sameYear) { - const startFmt = start.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - const endFmt = end.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - return `${startFmt} – ${endFmt}`; - } - - // Dec 30, 2025 – Jan 5, 2026 - const startFmt = start.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - const endFmt = end.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - return `${startFmt} – ${endFmt}`; -} - -function getDigests(): WeeklyDigest[] { - const raw = Array.isArray(weeklyDigestsData) ? (weeklyDigestsData as WeeklyDigest[]) : []; - - return raw - .filter((d) => !!d && typeof d === "object") - .filter((d) => typeof d.id === "string" && typeof d.title === "string") - .slice() - .sort((a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime()); -} +import type { WeeklyDigestResponse } from "@/lib/weeklyDigest"; export function generateMetadata(): Metadata { const base = process.env.NEXT_PUBLIC_SITE_URL || "https://foragents.dev"; - - const digests = getDigests(); - const latest = digests[0]; - const title = "Weekly Digest — forAgents.dev"; - - const description = latest - ? `${latest.title} (${formatDateRange(latest.startDate, latest.endDate)}).` - : "A weekly summary of what's shipped on forAgents.dev."; - + const description = "A weekly summary of real activity across skills, reviews, trending, and bounties."; const ogImage = `${base}/api/og/digest`; return { @@ -101,14 +21,7 @@ export function generateMetadata(): Metadata { url: `${base}/digest`, siteName: "forAgents.dev", type: "website", - images: [ - { - url: ogImage, - width: 1200, - height: 630, - alt: title, - }, - ], + images: [{ url: ogImage, width: 1200, height: 630, alt: title }], }, twitter: { card: "summary_large_image", @@ -130,10 +43,25 @@ function StatCard({ label, value }: { label: string; value: number }) { ); } -export default function DigestPage() { - const digests = getDigests(); - const latest = digests[0] ?? null; - const archive = latest ? digests.slice(1) : digests; +async function fetchWeeklyDigest(): Promise { + const hdrs = await headers(); + const host = hdrs.get("x-forwarded-host") || hdrs.get("host"); + const protocol = hdrs.get("x-forwarded-proto") || "https"; + + const fallbackBase = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + const base = host ? `${protocol}://${host}` : fallbackBase; + + const response = await fetch(`${base}/api/digest`, { + next: { revalidate: 300 }, + }); + + if (!response.ok) return null; + + return (await response.json()) as WeeklyDigestResponse; +} + +export default async function DigestPage() { + const digest = await fetchWeeklyDigest(); return (
@@ -141,14 +69,14 @@ export default function DigestPage() {

Weekly Digest

- A weekly summary of what shipped on forAgents.dev. + Live snapshot of this week's activity on forAgents.dev.

- {!latest ? ( + {!digest ? ( - No digests published yet. + Unable to load digest data right now. ) : ( @@ -157,54 +85,57 @@ export default function DigestPage() {
-

{latest.title}

+

Week in Review

- {formatDateRange(latest.startDate, latest.endDate)} + {digest.week.label}
- {typeof latest.weekNumber === "number" ? ( - - Week {latest.weekNumber} - - ) : null} + + Weekly +
-
- - - +
+ + + +

Highlights

    - {latest.highlights.map((h) => ( -
  • + {digest.highlights.map((highlight) => ( +
  • - {h} + {highlight}
  • ))}
-

Top skills

-
- {latest.topSkills.map((slug) => ( - - - {slug} - - - ))} -
+

Top trending skills

+ {digest.trending.length === 0 ? ( +

No trending skills available yet.

+ ) : ( +
+ {digest.trending.map((skill) => ( + + + {skill.name} + + + ))} +
+ )}
Looking for more? Browse all{" "} @@ -220,65 +151,6 @@ export default function DigestPage() { )} - - - -
-
-

Archive

-
- {digests.length} {digests.length === 1 ? "digest" : "digests"} -
-
- - {archive.length === 0 ? ( - - - No older digests yet. - - - ) : ( -
- {archive.map((d) => ( - - -
-
-
- {formatDateRange(d.startDate, d.endDate)} -
-
{d.title}
-
- {typeof d.weekNumber === "number" ? ( - - Week {d.weekNumber} - - ) : null} -
- -
-
-
{d.stats.prsShipped}
-
PRs
-
-
-
{d.stats.newPages}
-
Pages
-
-
-
{d.stats.newFeatures}
-
Features
-
-
-
-
- ))} -
- )} -
); } diff --git a/src/lib/weeklyDigest.ts b/src/lib/weeklyDigest.ts new file mode 100644 index 00000000..635737c0 --- /dev/null +++ b/src/lib/weeklyDigest.ts @@ -0,0 +1,160 @@ +import { getSkills } from "@/lib/data"; +import { getAllReviews } from "@/lib/reviews"; +import { getComputedTrendingSkills } from "@/lib/server/trendingCatalog"; +import { getBounties } from "@/lib/bounties"; +import skillVersionsData from "../../data/skill-versions.json"; + +export type WeeklyDigestResponse = { + week: { + start: string; // YYYY-MM-DD + end: string; // YYYY-MM-DD + label: string; + }; + newSkills: number; + newReviews: number; + trending: Array<{ + slug: string; + name: string; + trendingScore: number; + trendingBadge: string | null; + }>; + bounties: { + new: number; + claimed: number; + }; + highlights: string[]; +}; + +type SkillVersionHistory = { + slug: string; + versions?: Array<{ + date?: string; + }>; +}; + +function toYmd(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function parseDate(value: string | undefined | null): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isBetweenInclusive(target: Date, start: Date, end: Date): boolean { + const time = target.getTime(); + return time >= start.getTime() && time <= end.getTime(); +} + +function startOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)); +} + +function endOfUtcDay(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999)); +} + +function buildWeekWindow(now: Date): { start: Date; end: Date } { + const end = endOfUtcDay(now); + const start = startOfUtcDay(new Date(end.getTime() - 6 * 24 * 60 * 60 * 1000)); + return { start, end }; +} + +function buildSkillCreatedAtIndex(): Map { + const map = new Map(); + const histories = Array.isArray(skillVersionsData) + ? (skillVersionsData as SkillVersionHistory[]) + : []; + + for (const history of histories) { + if (!history?.slug || !Array.isArray(history.versions)) continue; + + let oldest: Date | null = null; + + for (const version of history.versions) { + const date = parseDate(version?.date); + if (!date) continue; + if (!oldest || date.getTime() < oldest.getTime()) { + oldest = date; + } + } + + if (oldest) { + map.set(history.slug, oldest); + } + } + + return map; +} + +export async function generateWeeklyDigest(now = new Date()): Promise { + const { start, end } = buildWeekWindow(now); + + const [reviews, bounties, ranked] = await Promise.all([ + getAllReviews(), + getBounties(), + getComputedTrendingSkills(getSkills()), + ]); + + const skillCreatedAt = buildSkillCreatedAtIndex(); + const newSkills = getSkills().filter((skill) => { + const createdAt = skillCreatedAt.get(skill.slug); + if (!createdAt) return false; + return isBetweenInclusive(createdAt, start, end); + }).length; + + const newReviews = reviews.filter((review) => { + const createdAt = parseDate(review.createdAt); + return createdAt ? isBetweenInclusive(createdAt, start, end) : false; + }).length; + + const newBounties = bounties.filter((bounty) => { + const createdAt = parseDate(bounty.createdAt); + return createdAt ? isBetweenInclusive(createdAt, start, end) : false; + }).length; + + const claimedBounties = bounties.filter((bounty) => { + if (bounty.status === "open") return false; + + const claimedAt = parseDate(bounty.claim?.claimedAt); + if (claimedAt) return isBetweenInclusive(claimedAt, start, end); + + // Fallback for seed rows that only carry status without claim metadata. + const createdAt = parseDate(bounty.createdAt); + return createdAt ? isBetweenInclusive(createdAt, start, end) : false; + }).length; + + const trending = ranked.slice(0, 5).map((skill) => ({ + slug: skill.slug, + name: skill.name, + trendingScore: skill.trendingScore, + trendingBadge: skill.badge, + })); + + const highlights: string[] = [ + `${newSkills} new skills added this week`, + `${newReviews} community reviews submitted`, + `${newBounties} new bounties posted, ${claimedBounties} claimed`, + ]; + + if (trending.length > 0) { + highlights.push(`Trending now: ${trending.slice(0, 3).map((skill) => skill.name).join(", ")}`); + } + + return { + week: { + start: toYmd(start), + end: toYmd(end), + label: `${toYmd(start)} → ${toYmd(end)}`, + }, + newSkills, + newReviews, + trending, + bounties: { + new: newBounties, + claimed: claimedBounties, + }, + highlights, + }; +}