From bc76c59e4c44836921c6d341dfc8f409c6182ed6 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:19:42 -0700 Subject: [PATCH 01/11] Add Freebuff live usage dashboard --- freebuff/web/src/app/api/live/route.ts | 15 + freebuff/web/src/app/live/live-client.tsx | 425 ++++++++++++++++++++++ freebuff/web/src/app/live/page.tsx | 33 ++ freebuff/web/src/server/live-stats.ts | 93 +++++ 4 files changed, 566 insertions(+) create mode 100644 freebuff/web/src/app/api/live/route.ts create mode 100644 freebuff/web/src/app/live/live-client.tsx create mode 100644 freebuff/web/src/app/live/page.tsx create mode 100644 freebuff/web/src/server/live-stats.ts diff --git a/freebuff/web/src/app/api/live/route.ts b/freebuff/web/src/app/api/live/route.ts new file mode 100644 index 0000000000..dd39d7c632 --- /dev/null +++ b/freebuff/web/src/app/api/live/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' + +import { getFreebuffLiveStats } from '@/server/live-stats' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function GET() { + const stats = await getFreebuffLiveStats() + return NextResponse.json(stats, { + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + }) +} diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx new file mode 100644 index 0000000000..8e81d50579 --- /dev/null +++ b/freebuff/web/src/app/live/live-client.tsx @@ -0,0 +1,425 @@ +'use client' + +import { motion } from 'framer-motion' +import { Activity, Clock3, Cpu, Globe2, Radio, ShieldCheck } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import { cn } from '@/lib/utils' + +import type { FreebuffLiveStats } from '@/server/live-stats' +import type { LucideIcon } from 'lucide-react' + +const POLL_MS = 15_000 +const MAP_SIZE = { width: 1000, height: 520 } +const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }) + +const COUNTRY_POINTS: Record = { + AT: [47.5, 14.5], + AU: [-25.3, 133.8], + BE: [50.5, 4.5], + CA: [56.1, -106.3], + CH: [46.8, 8.2], + DE: [51.2, 10.4], + DK: [56, 10], + ES: [40.4, -3.7], + FI: [64, 26], + FR: [46.2, 2.2], + GB: [55, -3], + IE: [53.4, -8.2], + IL: [31, 35], + IS: [65, -18], + IT: [42.8, 12.8], + LI: [47.1, 9.6], + LU: [49.8, 6.1], + MT: [35.9, 14.4], + NL: [52.1, 5.3], + NO: [61, 8], + NZ: [-41, 174], + PT: [39.4, -8.2], + SE: [62, 15], + SG: [1.4, 103.8], + US: [39.8, -98.6], +} + +const LAND_PATHS = [ + 'M93 151 C137 94 226 78 303 114 C376 149 362 217 288 237 C229 254 229 323 171 303 C104 280 61 197 93 151Z', + 'M276 291 C320 311 350 354 330 414 C313 468 269 500 247 466 C223 428 232 365 205 332 C185 307 229 277 276 291Z', + 'M444 118 C523 79 655 87 727 124 C799 160 890 160 923 214 C955 265 879 295 823 270 C744 235 725 292 638 283 C551 274 502 240 438 259 C386 274 338 225 357 176 C371 142 403 138 444 118Z', + 'M690 310 C731 277 796 297 825 333 C852 366 831 426 779 436 C728 447 671 390 690 310Z', + 'M766 439 C805 423 863 442 889 478 C837 492 792 489 746 470 C748 455 755 446 766 439Z', + 'M421 96 C448 80 495 83 516 105 C486 118 454 121 421 96Z', +] + +function countryName(code: string): string { + return code === 'UNKNOWN' ? 'Unknown' : (REGION_NAMES.of(code) ?? code) +} + +function formattedTime(iso: string): string { + return new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }).format(new Date(iso)) +} + +function projectPoint(lat: number, lon: number) { + return { + x: ((lon + 180) / 360) * MAP_SIZE.width, + y: ((90 - lat) / 180) * MAP_SIZE.height, + } +} + +function useLiveStats(initialStats: FreebuffLiveStats) { + const [stats, setStats] = useState(initialStats) + const [isRefreshing, setIsRefreshing] = useState(false) + + useEffect(() => { + let isMounted = true + + async function refresh() { + setIsRefreshing(true) + try { + const response = await fetch('/api/live', { cache: 'no-store' }) + if (response.ok && isMounted) { + setStats((await response.json()) as FreebuffLiveStats) + } + } finally { + if (isMounted) setIsRefreshing(false) + } + } + + const interval = window.setInterval(refresh, POLL_MS) + return () => { + isMounted = false + window.clearInterval(interval) + } + }, []) + + return { stats, isRefreshing } +} + +function StatTile({ + icon: Icon, + label, + value, + detail, +}: { + icon: LucideIcon + label: string + value: string + detail: string +}) { + return ( +
+
+ + {label} + + +
+
+ {value} +
+
{detail}
+
+ ) +} + +function Panel({ + icon: Icon, + title, + children, +}: { + icon: LucideIcon + title: string + children: React.ReactNode +}) { + return ( +
+
+

{title}

+ +
+ {children} +
+ ) +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function WorldMap({ stats }: { stats: FreebuffLiveStats }) { + const maxCount = Math.max(1, ...stats.countries.map((row) => row.count)) + const plottedCountries = stats.countries + .map((country) => { + const point = COUNTRY_POINTS[country.countryCode] + return point ? { ...country, point } : null + }) + .filter((country) => country !== null) + + return ( +
+ + + + + + + + + + + + + + + + + + {LAND_PATHS.map((path) => ( + + ))} + + {plottedCountries.map(({ countryCode, count, point }) => { + const [lat, lon] = point + const { x, y } = projectPoint(lat, lon) + const radius = 7 + Math.sqrt(count / maxCount) * 20 + + return ( + + + + + {count} + + + {countryName(countryCode)}: {count} + + + ) + })} + + + {plottedCountries.length === 0 && ( +
+
Standing by
+
+ Live sessions will appear here as users start Freebuff. +
+
+ )} + +
+ + Updated {formattedTime(stats.generatedAt)} +
+
+ ) +} + +function ModelBars({ stats }: { stats: FreebuffLiveStats }) { + const maxCount = Math.max(1, ...stats.models.map((model) => model.count)) + + if (stats.models.length === 0) { + return No models are active right now. + } + + return ( +
+ {stats.models.map((model) => ( +
+
+ {model.displayName} + {model.count} +
+
+ +
+
+ ))} +
+ ) +} + +function CountryList({ stats }: { stats: FreebuffLiveStats }) { + if (stats.countries.length === 0) { + return No active countries yet. + } + + return ( +
+ {stats.countries.map((country) => ( +
+
+
+ {countryName(country.countryCode)} +
+
+ {country.countryCode} +
+
+
+ {country.count} +
+
+ ))} +
+ ) +} + +export default function LiveClient({ + initialStats, +}: { + initialStats: FreebuffLiveStats +}) { + const { stats, isRefreshing } = useLiveStats(initialStats) + const topCountry = useMemo( + () => + stats.countries[0] + ? countryName(stats.countries[0].countryCode) + : 'None yet', + [stats.countries], + ) + + return ( +
+
+
+
+
+
+
+ + Live +
+

+ Freebuff live usage +

+
+
+ + + {isRefreshing + ? 'Refreshing' + : `Updated ${formattedTime(stats.generatedAt)}`} + +
+
+ +
+ + + +
+
+
+ +
+
+ + +
+ + + + + + + +
+
+ +
+ + Aggregate country and model counts only. +
+
+
+ ) +} diff --git a/freebuff/web/src/app/live/page.tsx b/freebuff/web/src/app/live/page.tsx new file mode 100644 index 0000000000..8a548a3d18 --- /dev/null +++ b/freebuff/web/src/app/live/page.tsx @@ -0,0 +1,33 @@ +import { env } from '@codebuff/common/env' + +import { getFreebuffLiveStats } from '@/server/live-stats' + +import LiveClient from './live-client' + +import type { Metadata } from 'next' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function generateMetadata(): Promise { + const canonical = `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/live` + return { + title: 'Live Freebuff Users', + description: 'Live aggregate Freebuff usage by country and model.', + alternates: { + canonical, + }, + openGraph: { + title: 'Live Freebuff Users', + description: 'Live aggregate Freebuff usage by country and model.', + url: canonical, + type: 'website', + siteName: 'Freebuff', + }, + } +} + +export default async function LivePage() { + const initialStats = await getFreebuffLiveStats() + return +} diff --git a/freebuff/web/src/server/live-stats.ts b/freebuff/web/src/server/live-stats.ts new file mode 100644 index 0000000000..359a85ff29 --- /dev/null +++ b/freebuff/web/src/server/live-stats.ts @@ -0,0 +1,93 @@ +import { SUPPORTED_FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models' +import { db } from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { and, count, eq, gt, sql } from 'drizzle-orm' + +export interface FreebuffLiveCountryCount { + countryCode: string + count: number +} + +export interface FreebuffLiveModelCount { + modelId: string + displayName: string + count: number +} + +export interface FreebuffLiveStats { + totalLiveUsers: number + countries: FreebuffLiveCountryCount[] + models: FreebuffLiveModelCount[] + generatedAt: string +} + +const MODEL_LABELS = Object.fromEntries( + SUPPORTED_FREEBUFF_MODELS.map( + (model) => [model.id, model.displayName] as const, + ), +) + +function modelDisplayName(modelId: string): string { + return MODEL_LABELS[modelId] ?? modelId.split('/').at(-1) ?? modelId +} + +function liveSessionWhere(now: Date) { + return and( + eq(schema.freeSession.status, 'active'), + gt(schema.freeSession.expires_at, now), + sql`NOT EXISTS ( + SELECT 1 FROM ${schema.user} + WHERE ${schema.user.id} = ${schema.freeSession.user_id} + AND ${schema.user.banned} = true + )`, + ) +} + +function sortCounts(rows: T[]): T[] { + return [...rows].sort((a, b) => b.count - a.count) +} + +export async function getFreebuffLiveStats( + now = new Date(), +): Promise { + const [countryRows, modelRows] = await Promise.all([ + db + .select({ + countryCode: schema.freeSession.country_code, + count: count(), + }) + .from(schema.freeSession) + .where(liveSessionWhere(now)) + .groupBy(schema.freeSession.country_code), + db + .select({ + modelId: schema.freeSession.model, + count: count(), + }) + .from(schema.freeSession) + .where(liveSessionWhere(now)) + .groupBy(schema.freeSession.model), + ]) + + const countries = sortCounts( + countryRows.map((row) => ({ + countryCode: row.countryCode ?? 'UNKNOWN', + count: Number(row.count), + })), + ) + + const models = sortCounts( + modelRows.map((row) => ({ + modelId: row.modelId, + displayName: modelDisplayName(row.modelId), + count: Number(row.count), + })), + ) + + return { + totalLiveUsers: models.reduce((sum, row) => sum + row.count, 0), + countries, + models, + generatedAt: now.toISOString(), + } +} From 8f524e0ce2ac32c976912b41e6ff3633f70374ed Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:21:43 -0700 Subject: [PATCH 02/11] Refine Freebuff live dashboard copy --- freebuff/web/src/app/live/live-client.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index 8e81d50579..81a9dea124 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -1,7 +1,7 @@ 'use client' import { motion } from 'framer-motion' -import { Activity, Clock3, Cpu, Globe2, Radio, ShieldCheck } from 'lucide-react' +import { Activity, Clock3, Cpu, Globe2, Radio } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { cn } from '@/lib/utils' @@ -358,7 +358,7 @@ export default function LiveClient({ Live

- Freebuff live usage + Freebuff live

-
+
-
- -
- - Aggregate country and model counts only. -
) From 8394b7d289a5dd4e54bdfc942bb0886fd1f58594 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:22:20 -0700 Subject: [PATCH 03/11] Simplify Freebuff live stat cards --- freebuff/web/src/app/live/live-client.tsx | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index 81a9dea124..1a5ddbf23d 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -102,12 +102,10 @@ function StatTile({ icon: Icon, label, value, - detail, }: { icon: LucideIcon label: string value: string - detail: string }) { return (
@@ -120,7 +118,6 @@ function StatTile({
{value}
-
{detail}
) } @@ -382,14 +379,8 @@ export default function LiveClient({ icon={Globe2} label="Live users" value={stats.totalLiveUsers.toLocaleString()} - detail="Active sessions now" - /> - + From 14a9fdaf89972e91ed677be7d8a4f23adce35b30 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:23:34 -0700 Subject: [PATCH 04/11] Declutter Freebuff live header --- freebuff/web/src/app/live/live-client.tsx | 48 ++++------------------- 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index 1a5ddbf23d..646bc0c95b 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -1,11 +1,9 @@ 'use client' import { motion } from 'framer-motion' -import { Activity, Clock3, Cpu, Globe2, Radio } from 'lucide-react' +import { Activity, Cpu, Globe2, Radio } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' -import { cn } from '@/lib/utils' - import type { FreebuffLiveStats } from '@/server/live-stats' import type { LucideIcon } from 'lucide-react' @@ -71,20 +69,14 @@ function projectPoint(lat: number, lon: number) { function useLiveStats(initialStats: FreebuffLiveStats) { const [stats, setStats] = useState(initialStats) - const [isRefreshing, setIsRefreshing] = useState(false) useEffect(() => { let isMounted = true async function refresh() { - setIsRefreshing(true) - try { - const response = await fetch('/api/live', { cache: 'no-store' }) - if (response.ok && isMounted) { - setStats((await response.json()) as FreebuffLiveStats) - } - } finally { - if (isMounted) setIsRefreshing(false) + const response = await fetch('/api/live', { cache: 'no-store' }) + if (response.ok && isMounted) { + setStats((await response.json()) as FreebuffLiveStats) } } @@ -95,25 +87,16 @@ function useLiveStats(initialStats: FreebuffLiveStats) { } }, []) - return { stats, isRefreshing } + return stats } -function StatTile({ - icon: Icon, - label, - value, -}: { - icon: LucideIcon - label: string - value: string -}) { +function StatTile({ label, value }: { label: string; value: string }) { return (
{label} -
{value} @@ -334,7 +317,7 @@ export default function LiveClient({ }: { initialStats: FreebuffLiveStats }) { - const { stats, isRefreshing } = useLiveStats(initialStats) + const stats = useLiveStats(initialStats) const topCountry = useMemo( () => stats.countries[0] @@ -358,29 +341,14 @@ export default function LiveClient({ Freebuff live
-
- - - {isRefreshing - ? 'Refreshing' - : `Updated ${formattedTime(stats.generatedAt)}`} - -
- +
From 2ef32057a20c7f63a3796df7051c2fc692d79e8e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:24:23 -0700 Subject: [PATCH 05/11] Tighten Freebuff live layout spacing --- freebuff/web/src/app/live/live-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index 646bc0c95b..b7f0fe4dce 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -330,7 +330,7 @@ export default function LiveClient({
-
+
@@ -353,7 +353,7 @@ export default function LiveClient({
-
+
From 3db8126742c0cb223babdac03b3c219ef195b5ad Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:27:43 -0700 Subject: [PATCH 06/11] Move live indicator into heading --- freebuff/web/src/app/live/live-client.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index b7f0fe4dce..852bfd298a 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -1,7 +1,7 @@ 'use client' import { motion } from 'framer-motion' -import { Activity, Cpu, Globe2, Radio } from 'lucide-react' +import { Cpu, Globe2, Radio } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import type { FreebuffLiveStats } from '@/server/live-stats' @@ -333,11 +333,20 @@ export default function LiveClient({
-
- - Live -
-

+

+ Freebuff live

From e61457ee2c15c9e2b4e7b44b492fa93674a7668d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:29:38 -0700 Subject: [PATCH 07/11] Place live timestamp beside heading --- freebuff/web/src/app/live/live-client.tsx | 44 +++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index 852bfd298a..e54f1b460c 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -1,7 +1,7 @@ 'use client' import { motion } from 'framer-motion' -import { Cpu, Globe2, Radio } from 'lucide-react' +import { Cpu, Globe2 } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import type { FreebuffLiveStats } from '@/server/live-stats' @@ -245,11 +245,6 @@ function WorldMap({ stats }: { stats: FreebuffLiveStats }) {
)} - -
- - Updated {formattedTime(stats.generatedAt)} -
) } @@ -333,22 +328,27 @@ export default function LiveClient({
-

- - Freebuff live -

+
+

+ + Freebuff live +

+ + Updated {formattedTime(stats.generatedAt)} + +
From dc9b88549c31de6740abe8ca4f6e2dbe7f43e478 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 11 May 2026 17:33:22 -0700 Subject: [PATCH 08/11] Add Freebuff install prompt to live page --- freebuff/web/src/app/live/live-client.tsx | 87 ++++++++++++++++++++++- freebuff/web/src/components/footer.tsx | 7 ++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/freebuff/web/src/app/live/live-client.tsx b/freebuff/web/src/app/live/live-client.tsx index e54f1b460c..e3ae10bafe 100644 --- a/freebuff/web/src/app/live/live-client.tsx +++ b/freebuff/web/src/app/live/live-client.tsx @@ -1,12 +1,17 @@ 'use client' import { motion } from 'framer-motion' -import { Cpu, Globe2 } from 'lucide-react' +import { ChevronDown, Cpu, Globe2 } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' +import { CopyButton } from '@/components/copy-button' + import type { FreebuffLiveStats } from '@/server/live-stats' import type { LucideIcon } from 'lucide-react' +const INSTALL_COMMAND = 'npm install -g freebuff' const POLL_MS = 15_000 const MAP_SIZE = { width: 1000, height: 520 } const REGION_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }) @@ -48,6 +53,13 @@ const LAND_PATHS = [ 'M421 96 C448 80 495 83 516 105 C486 118 454 121 421 96Z', ] +const SETUP_STEPS = [ + 'Open your terminal', + 'Navigate to your project', + INSTALL_COMMAND, + 'freebuff', +] + function countryName(code: string): string { return code === 'UNKNOWN' ? 'Unknown' : (REGION_NAMES.of(code) ?? code) } @@ -307,6 +319,73 @@ function CountryList({ stats }: { stats: FreebuffLiveStats }) { ) } +function InstallCallout() { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+
+ + Freebuff +
+
+ freebuff +
+
The free coding agent
+
+ + +
+
+ $ + + {INSTALL_COMMAND} + + +
+ + + + {isOpen && ( +
    + {SETUP_STEPS.map((step, index) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+ )} +
+
+
+ ) +} + export default function LiveClient({ initialStats, }: { @@ -329,10 +408,10 @@ export default function LiveClient({
-

+

+ +
) } diff --git a/freebuff/web/src/components/footer.tsx b/freebuff/web/src/components/footer.tsx index 97cd24896e..858f00079a 100644 --- a/freebuff/web/src/components/footer.tsx +++ b/freebuff/web/src/components/footer.tsx @@ -1,7 +1,14 @@ +'use client' + import Image from 'next/image' import Link from 'next/link' +import { usePathname } from 'next/navigation' export function Footer() { + const pathname = usePathname() + + if (pathname === '/live') return null + return (