diff --git a/.gitignore b/.gitignore index b88fcb3f4d..40ea0b2797 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ pnpm-lock.yaml .vinxi native-deps* apps/storybook/storybook-static +.tinyb + +**/.tinyb *.tsbuildinfo .cargo/config.toml diff --git a/README.md b/README.md index f4f413018d..314deee613 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,12 @@ Portions of this software are licensed as follows: # Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for more information. This guide is a work in progress, and is updated regularly as the app matures. + +## Analytics (Tinybird) + +Cap uses [Tinybird](https://www.tinybird.co) to ingest viewer telemetry for dashboards. The Tinybird admin token (`TINYBIRD_ADMIN_TOKEN` or `TINYBIRD_TOKEN`) must be available in your environment. Once the token is present you can: + +- Provision the required data sources and materialized views via `pnpm analytics:setup`. This command installs the Tinybird CLI (if needed), runs `tb login` when a `.tinyb` credential file is missing, copies that credential into `scripts/analytics/tinybird`, and finally executes `tb deploy --allow-destructive-operations --wait` from that directory. **It synchronizes the Tinybird workspace to the resources defined in `scripts/analytics/tinybird`, removing any other datasources/pipes in that workspace.** +- Validate that the schema and materialized views match what the app expects via `pnpm analytics:check`. + +Both commands target the workspace pointed to by `TINYBIRD_HOST` (defaults to `https://api.tinybird.co`). Make sure you are comfortable with the destructive nature of the deploy step before running `analytics:setup`. diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 7792c16d46..35e1ca743a 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -1,28 +1,96 @@ "use server"; -import { dub } from "@cap/utils"; - -export async function getVideoAnalytics(videoId: string) { - if (!videoId) { - throw new Error("Video ID is required"); - } - - try { - const response = await dub().analytics.retrieve({ - domain: "cap.link", - key: videoId, - }); - const { clicks } = response as { clicks: number }; - - if (typeof clicks !== "number" || clicks === null) { - return { count: 0 }; - } - - return { count: clicks }; - } catch (error: any) { - if (error.code === "not_found") { - return { count: 0 }; - } - return { count: 0 }; - } +import { db } from "@cap/database"; +import { videos } from "@cap/database/schema"; +import { Tinybird } from "@cap/web-backend"; +import { Video } from "@cap/web-domain"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; +import { runPromise } from "@/lib/server"; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const MIN_RANGE_DAYS = 1; +const MAX_RANGE_DAYS = 90; +const DEFAULT_RANGE_DAYS = MAX_RANGE_DAYS; + +const escapeLiteral = (value: string) => value.replace(/'/g, "''"); +const formatDate = (date: Date) => date.toISOString().slice(0, 10); +const formatDateTime = (date: Date) => + date.toISOString().slice(0, 19).replace("T", " "); +const buildConditions = (clauses: Array) => + clauses.filter((clause): clause is string => Boolean(clause)).join(" AND "); + +const normalizeRangeDays = (rangeDays?: number) => { + if (!Number.isFinite(rangeDays)) return DEFAULT_RANGE_DAYS; + const normalized = Math.floor(rangeDays as number); + if (normalized <= 0) return DEFAULT_RANGE_DAYS; + return Math.max(MIN_RANGE_DAYS, Math.min(normalized, MAX_RANGE_DAYS)); +}; + +interface GetVideoAnalyticsOptions { + rangeDays?: number; +} + +export async function getVideoAnalytics( + videoId: string, + options?: GetVideoAnalyticsOptions, +) { + if (!videoId) throw new Error("Video ID is required"); + + const [{ orgId } = { orgId: null }] = await db() + .select({ orgId: videos.orgId }) + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))) + .limit(1); + + return runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + + const rangeDays = normalizeRangeDays(options?.rangeDays); + const now = new Date(); + const from = new Date(now.getTime() - rangeDays * DAY_IN_MS); + const pathname = `/s/${videoId}`; + const aggregateConditions = [ + orgId ? `tenant_id = '${escapeLiteral(orgId)}'` : undefined, + `pathname = '${escapeLiteral(pathname)}'`, + `date BETWEEN toDate('${formatDate(from)}') AND toDate('${formatDate(now)}')`, + ]; + const aggregateSql = `SELECT coalesce(uniqMerge(visits), 0) AS views FROM analytics_pages_mv WHERE ${buildConditions(aggregateConditions)}`; + + const rawConditions = [ + "action = 'page_hit'", + orgId ? `tenant_id = '${escapeLiteral(orgId)}'` : undefined, + `pathname = '${escapeLiteral(pathname)}'`, + `timestamp BETWEEN toDateTime('${formatDateTime(from)}') AND toDateTime('${formatDateTime(now)}')`, + ]; + const rawSql = `SELECT coalesce(uniq(session_id), 0) AS views FROM analytics_events WHERE ${buildConditions(rawConditions)}`; + + const querySql = (sql: string) => + tinybird.querySql<{ views: number }>(sql).pipe( + Effect.catchAll((e) => { + console.error("tinybird sql error", e); + return Effect.succeed({ data: [] }); + }), + ); + + const aggregateResult = yield* querySql(aggregateSql); + + const fallbackResult = aggregateResult.data?.length + ? aggregateResult + : yield* querySql(rawSql); + + const data = fallbackResult?.data ?? []; + const firstItem = data[0]; + const count = + typeof firstItem === "number" + ? firstItem + : typeof firstItem === "object" && + firstItem !== null && + "views" in firstItem + ? Number(firstItem.views ?? 0) + : 0; + return { count: Number.isFinite(count) ? count : 0 }; + }), + ); } diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx new file mode 100644 index 0000000000..dc6b5a80e6 --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx @@ -0,0 +1,102 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface ChartLineIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface ChartLineIconProps extends HTMLAttributes { + size?: number; +} + +const variants: Variants = { + normal: { + pathLength: 1, + opacity: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + transition: { + delay: 0.15, + duration: 0.3, + opacity: { delay: 0.1 }, + }, + }, +}; + +const ChartLineIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( +
+ + + + +
+ ); + }, +); + +ChartLineIcon.displayName = "ChartLineIcon"; + +export default ChartLineIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx new file mode 100644 index 0000000000..c112a20b8b --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx @@ -0,0 +1,120 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface ClapIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface ClapIconProps extends HTMLAttributes { + size?: number; +} + +const variants: Variants = { + normal: { + rotate: 0, + originX: "4px", + originY: "20px", + }, + animate: { + rotate: [-10, -10, 0], + transition: { + duration: 0.8, + times: [0, 0.5, 1], + ease: "easeInOut", + }, + }, +}; + +const clapVariants: Variants = { + normal: { + rotate: 0, + originX: "3px", + originY: "11px", + }, + animate: { + rotate: [0, -10, 16, 0], + transition: { + duration: 0.4, + times: [0, 0.3, 0.6, 1], + ease: "easeInOut", + }, + }, +}; + +const ClapIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + return ( +
+ + + + + + + + + + +
+ ); + }, +); + +ClapIcon.displayName = "ClapIcon"; + +export default ClapIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx new file mode 100644 index 0000000000..931221098c --- /dev/null +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/Reaction.tsx @@ -0,0 +1,170 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion, useAnimation } from "motion/react"; +import type { HTMLAttributes } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export interface PartyPopperIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface PartyPopperIconProps extends HTMLAttributes { + size?: number; +} + +const linesVariants: Variants = { + normal: { + opacity: 1, + pathLength: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + animate: { + opacity: [0, 1], + scale: [0.3, 0.8, 1, 1.1, 1], + pathLength: [0, 0.5, 1], + translateX: [-5, 0], + translateY: [5, 0], + transition: { + duration: 0.7, + velocity: 0.3, + }, + }, +}; + +const dotsVariants: Variants = { + normal: { opacity: 1, scale: 1, translateX: 0, translateY: 0 }, + animate: { + opacity: [0, 1], + translateX: [-5, 0], + translateY: [5, 0], + scale: [0.5, 0.8, 1, 1.1, 1], + transition: { + duration: 0.7, + }, + }, +}; + +const popperVariants: Variants = { + normal: { translateX: 0, translateY: 0 }, + animate: { + translateX: [-1.5, 0], + translateY: [1.5, 0], + transition: { + velocity: 0.3, + }, + }, +}; + +const PartyPopperIcon = forwardRef( + ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { + const controls = useAnimation(); + const isControlledRef = useRef(false); + + useImperativeHandle(ref, () => { + isControlledRef.current = true; + + return { + startAnimation: () => controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("animate"); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter], + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave], + ); + + return ( +
+ + + + + + + + + + + +
+ ); + }, +); + +PartyPopperIcon.displayName = "PartyPopperIcon"; + +export default PartyPopperIcon; diff --git a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts index d7b2c4307e..f41eb1e9ab 100644 --- a/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts +++ b/apps/web/app/(org)/dashboard/_components/AnimatedIcons/index.ts @@ -1,15 +1,18 @@ import ArrowUpIcon from "./ArrowUp"; import CapIcon from "./Cap"; +import ChartLineIcon from "./ChartLine"; import MessageCircleMoreIcon from "./Chat"; +import ChatIcon from "./Chat"; +import ClapIcon from "./Clap"; import CogIcon from "./Cog"; import DownloadIcon from "./Download"; import HomeIcon from "./Home"; import LayersIcon from "./Layers"; import LogoutIcon from "./Logout"; +import ReactionIcon from "./Reaction"; import RecordIcon from "./Record"; import ReferIcon from "./Refer"; import SettingsGearIcon from "./Settings"; - export { ArrowUpIcon, CapIcon, @@ -21,5 +24,9 @@ export { LogoutIcon, SettingsGearIcon, ReferIcon, + ChartLineIcon, + ClapIcon, + ChatIcon, + ReactionIcon, RecordIcon, }; diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx index 5198484eec..bbd498faea 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Items.tsx @@ -36,7 +36,7 @@ import { SignedImageUrl } from "@/components/SignedImageUrl"; import { Tooltip } from "@/components/Tooltip"; import { UsageButton } from "@/components/UsageButton"; import { useDashboardContext } from "../../Contexts"; -import { CapIcon, CogIcon, RecordIcon } from "../AnimatedIcons"; +import { CapIcon, ChartLineIcon, CogIcon, RecordIcon } from "../AnimatedIcons"; import type { CogIconHandle } from "../AnimatedIcons/Cog"; import CapAIBox from "./CapAIBox"; import SpacesList from "./SpacesList"; @@ -59,6 +59,13 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { icon: , subNav: [], }, + { + name: "Analytics", + href: `/dashboard/analytics`, + matchChildren: true, + icon: , + subNav: [], + }, { name: "Record a Cap", href: `/dashboard/caps/record`, @@ -84,12 +91,14 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { const [openAIDialog, setOpenAIDialog] = useState(false); const router = useRouter(); - const isPathActive = (path: string) => { - if (path === "/dashboard/caps") { - return pathname === "/dashboard/caps"; + const isPathActive = (path: string, matchChildren: boolean = false) => { + if (matchChildren) { + return pathname === path || pathname.startsWith(`${path}/`); } - return pathname === path || pathname.startsWith(`${path}/`); + + return pathname === path; }; + const isDomainSetupVerified = activeOrg?.organization.customDomain && activeOrg?.organization.domainVerified; @@ -278,7 +287,7 @@ const AdminNavItems = ({ toggleMobileNav }: Props) => { key={item.name} className="flex relative justify-center items-center mb-1.5 w-full" > - {isPathActive(item.href) && ( + {isPathActive(item.href, item.matchChildren ?? false) && ( { toggleMobileNav={toggleMobileNav} isPathActive={isPathActive} extraText={item.extraText} + matchChildren={item.matchChildren ?? false} /> ))} @@ -396,6 +406,7 @@ const NavItem = ({ sidebarCollapsed, toggleMobileNav, isPathActive, + matchChildren, extraText, }: { name: string; @@ -407,8 +418,9 @@ const NavItem = ({ }>; sidebarCollapsed: boolean; toggleMobileNav?: () => void; - isPathActive: (path: string) => boolean; + isPathActive: (path: string, matchChildren: boolean) => boolean; extraText: number | null | undefined; + matchChildren: boolean; }) => { const iconRef = useRef(null); return ( @@ -429,7 +441,7 @@ const NavItem = ({ sidebarCollapsed ? "flex justify-center items-center px-0 w-full size-9" : "px-3 py-2 w-full", - isPathActive(href) + isPathActive(href, matchChildren) ? "bg-transparent pointer-events-none" : "hover:bg-gray-2", "flex overflow-hidden justify-start items-center tracking-tight rounded-xl outline-none", diff --git a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx index 5af3fe662e..5bace9d365 100644 --- a/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx +++ b/apps/web/app/(org)/dashboard/_components/Navbar/Top.tsx @@ -17,7 +17,7 @@ import clsx from "clsx"; import { AnimatePresence } from "framer-motion"; import { MoreVertical } from "lucide-react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; import { signOut } from "next-auth/react"; import { cloneElement, @@ -54,6 +54,7 @@ const Top = () => { const queryClient = useQueryClient(); const pathname = usePathname(); + const params = useParams(); const titles: Record = { "/dashboard/caps": "Caps", @@ -64,13 +65,12 @@ const Top = () => { "/dashboard/settings/account": "Account Settings", "/dashboard/spaces": "Spaces", "/dashboard/spaces/browse": "Browse Spaces", + "/dashboard/analytics": "Analytics", + [`/dashboard/folder/${params.id}`]: "Caps", + [`/dashboard/analytics/s/${params.id}`]: "Analytics: Cap video title", }; - const title = activeSpace - ? activeSpace.name - : pathname.includes("/dashboard/folder") - ? "Caps" - : titles[pathname] || ""; + const title = activeSpace ? activeSpace.name : titles[pathname] || ""; const notificationsRef: MutableRefObject = useClickAway( (e) => { diff --git a/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx new file mode 100644 index 0000000000..5b8b663920 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx @@ -0,0 +1,132 @@ +"use client"; + +import type { Organisation } from "@cap/web-domain"; +import { Effect } from "effect"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useEffectQuery } from "@/lib/EffectRuntime"; +import { useDashboardContext } from "../../Contexts"; +import type { AnalyticsRange, OrgAnalyticsResponse } from "../types"; +import Header from "./Header"; +import OtherStats from "./OtherStats"; +import StatsChart from "./StatsChart"; + +const RANGE_OPTIONS: { value: AnalyticsRange; label: string }[] = [ + { value: "24h", label: "Last 24 hours" }, + { value: "7d", label: "Last 7 days" }, + { value: "30d", label: "Last 30 days" }, +]; + +const formatNumber = (value: number) => + new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); + +export function AnalyticsDashboard() { + const searchParams = useSearchParams(); + const capId = searchParams.get("capId"); + const { activeOrganization, organizationData, spacesData } = + useDashboardContext(); + const [range, setRange] = useState("7d"); + const [selectedOrgId, setSelectedOrgId] = + useState(null); + const [selectedSpaceId, setSelectedSpaceId] = useState(null); + + useEffect(() => { + if (activeOrganization?.organization.id && !selectedOrgId) { + setSelectedOrgId(activeOrganization.organization.id); + } + }, [activeOrganization, selectedOrgId]); + + const orgId = selectedOrgId || activeOrganization?.organization.id; + + const query = useEffectQuery({ + queryKey: ["dashboard-analytics", orgId, selectedSpaceId, range, capId], + queryFn: () => + Effect.gen(function* () { + if (!orgId) return null; + const url = new URL("/api/dashboard/analytics", window.location.origin); + url.searchParams.set("orgId", orgId); + url.searchParams.set("range", range); + if (selectedSpaceId) { + url.searchParams.set("spaceId", selectedSpaceId); + } + if (capId) { + url.searchParams.set("capId", capId); + } + const response = yield* Effect.tryPromise({ + try: () => fetch(url.toString(), { cache: "no-store" }), + catch: (cause: unknown) => cause as Error, + }); + if (!response.ok) { + return yield* Effect.fail(new Error("Failed to load analytics")); + } + return yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ data: OrgAnalyticsResponse }>, + catch: (cause: unknown) => cause as Error, + }); + }), + enabled: Boolean(orgId), + staleTime: 60 * 1000, + }); + + const analytics = (query.data as { data: OrgAnalyticsResponse } | undefined) + ?.data; + + if (!orgId) { + return ( +
+ Select or join an organization to view analytics. +
+ ); + } + + const otherStats = analytics + ? { + countries: analytics.breakdowns.countries, + cities: analytics.breakdowns.cities, + browsers: analytics.breakdowns.browsers, + operatingSystems: analytics.breakdowns.operatingSystems, + deviceTypes: analytics.breakdowns.devices, + topCaps: capId ? null : analytics.breakdowns.topCaps, + } + : { + countries: [], + cities: [], + browsers: [], + operatingSystems: [], + deviceTypes: [], + topCaps: [], + }; + + return ( +
+
+ + +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx new file mode 100644 index 0000000000..003a64c9da --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/ChartArea.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useId, useMemo } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +export const description = "An area chart with gradient fill"; + +const chartConfig = { + views: { + label: "Views", + color: "#3b82f6", + }, + comments: { + label: "Comments", + color: "#ec4899", + }, + reactions: { + label: "Reactions", + color: "#f97316", + }, + caps: { + label: "Caps", + color: "var(--gray-12)", + }, +} satisfies ChartConfig; + +interface ChartAreaProps { + selectedMetrics: Array<"caps" | "views" | "comments" | "reactions">; + data: Array<{ + bucket: string; + caps: number; + views: number; + comments: number; + reactions: number; + }>; + isLoading?: boolean; +} + +function ChartArea({ selectedMetrics, data, isLoading }: ChartAreaProps) { + const viewsGradientId = useId(); + const commentsGradientId = useId(); + const reactionsGradientId = useId(); + const capsGradientId = useId(); + const glowFilterId = useId(); + + const chartData = useMemo(() => { + if (!data || data.length === 0) return []; + const bucketDuration = + data.length > 1 + ? new Date(data[1]!.bucket).getTime() - + new Date(data[0]!.bucket).getTime() + : 0; + const hourly = bucketDuration > 0 && bucketDuration <= 60 * 60 * 1000; + return data.map((point) => ({ + ...point, + label: formatBucketLabel(point.bucket, hourly), + })); + }, [data]); + + const { maxValue, yAxisTicks } = useMemo(() => { + if (!chartData.length || selectedMetrics.length === 0) { + return { maxValue: 100, yAxisTicks: [0, 20, 40, 60, 80, 100] }; + } + + let max = 0; + for (const point of chartData) { + for (const metric of selectedMetrics) { + const value = point[metric] ?? 0; + if (value > max) { + max = value; + } + } + } + + if (max === 0) { + return { maxValue: 100, yAxisTicks: [0, 20, 40, 60, 80, 100] }; + } + + const roundedMax = Math.ceil(max * 1.1); + const magnitude = 10 ** Math.floor(Math.log10(roundedMax)); + const normalized = roundedMax / magnitude; + let niceMax: number; + + if (normalized <= 1) { + niceMax = magnitude; + } else if (normalized <= 2) { + niceMax = 2 * magnitude; + } else if (normalized <= 5) { + niceMax = 5 * magnitude; + } else { + niceMax = 10 * magnitude; + } + + const step = niceMax / 5; + const ticks: number[] = []; + for (let i = 0; i <= 5; i++) { + ticks.push(Math.round(i * step)); + } + + return { maxValue: niceMax, yAxisTicks: ticks }; + }, [chartData, selectedMetrics]); + + if (isLoading && chartData.length === 0) { + return ( +
+ ); + } + + if (chartData.length === 0) { + return ( +
+ No analytics data yet. +
+ ); + } + + if (selectedMetrics.length === 0) { + return ( +
+ Select at least one metric to view. +
+ ); + } + + return ( + + + + + + } /> + + + + + + + + + + + + + + + + + + + + + + + + + + {selectedMetrics.includes("views") && ( + + )} + {selectedMetrics.includes("comments") && ( + + )} + {selectedMetrics.includes("reactions") && ( + + )} + {selectedMetrics.includes("caps") && ( + + )} + + + ); +} + +export default ChartArea; + +const formatBucketLabel = (bucket: string, hourly: boolean) => { + const date = new Date(bucket); + if (Number.isNaN(date.getTime())) return bucket; + if (hourly) + return date.toLocaleTimeString([], { + hour: "numeric", + minute: undefined, + }); + return date.toLocaleDateString([], { month: "short", day: "numeric" }); +}; diff --git a/apps/web/app/(org)/dashboard/analytics/components/Header.tsx b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx new file mode 100644 index 0000000000..9605a1a5f3 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/Header.tsx @@ -0,0 +1,429 @@ +"use client"; + +import type { Organisation } from "@cap/web-domain"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import clsx from "clsx"; +import { + ArrowLeft, + ChevronDown, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useCurrentUser } from "@/app/Layout/AuthContext"; +import { SignedImageUrl } from "@/components/SignedImageUrl"; +import type { Organization, Spaces } from "../../dashboard-data"; +import type { AnalyticsRange } from "../types"; + +interface HeaderProps { + options: { value: AnalyticsRange; label: string }[]; + value: AnalyticsRange; + onChange: (value: AnalyticsRange) => void; + isLoading?: boolean; + organizations?: Organization[] | null; + activeOrganization?: Organization | null; + spacesData?: Spaces[] | null; + selectedOrganizationId?: Organisation.OrganisationId | null; + selectedSpaceId?: string | null; + onOrganizationChange?: (organizationId: Organisation.OrganisationId) => void; + onSpaceChange?: (spaceId: string | null) => void; + hideCapsSelect?: boolean; + capId?: string | null; + capName?: string | null; +} + +const DATE_RANGE_OPTIONS = [ + { value: "today", label: "Today" }, + { value: "yesterday", label: "Yesterday" }, + { value: "24h", label: "Last 24 hours" }, + { value: "7d", label: "Last 7 days" }, + { value: "30d", label: "Last 30 days" }, + { value: "wtd", label: "Week to date" }, + { value: "mtd", label: "Month to date" }, +] as const; + +const mapToBackendRange = (value: string): AnalyticsRange => { + if (value === "24h" || value === "7d" || value === "30d") { + return value as AnalyticsRange; + } + if (value === "today" || value === "yesterday") { + return "24h"; + } + if (value === "wtd") { + return "7d"; + } + if (value === "mtd") { + return "30d"; + } + return "7d"; +}; + +const getDisplayValue = ( + backendValue: AnalyticsRange, + lastUISelection?: string, +): string => { + if ( + lastUISelection && + (lastUISelection === "today" || lastUISelection === "yesterday") + ) { + if (backendValue === "24h") { + return lastUISelection; + } + } + if (lastUISelection && lastUISelection === "wtd") { + if (backendValue === "7d") { + return lastUISelection; + } + } + if (lastUISelection && lastUISelection === "mtd") { + if (backendValue === "30d") { + return lastUISelection; + } + } + if (backendValue === "24h") return "24h"; + if (backendValue === "7d") return "7d"; + if (backendValue === "30d") return "30d"; + return "7d"; +}; + +export default function Header({ + options: _options, + value, + onChange, + isLoading, + organizations, + activeOrganization, + spacesData, + selectedOrganizationId, + selectedSpaceId, + onOrganizationChange, + onSpaceChange, + hideCapsSelect = false, + capId, + capName, +}: HeaderProps) { + const user = useCurrentUser(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [orgOpen, setOrgOpen] = useState(false); + const [lastUISelection, setLastUISelection] = useState( + undefined, + ); + + useEffect(() => { + if (!lastUISelection) { + if (value === "24h") { + setLastUISelection("24h"); + } else if (value === "7d") { + setLastUISelection("7d"); + } else if (value === "30d") { + setLastUISelection("30d"); + } + } + }, [value, lastUISelection]); + + useEffect(() => { + const currentOrgId = + selectedOrganizationId || activeOrganization?.organization.id; + + if (selectedSpaceId) { + const space = spacesData?.find((s) => s.id === selectedSpaceId); + if (!space || space.organizationId !== currentOrgId) { + onSpaceChange?.(null); + } + } + }, [ + selectedOrganizationId, + selectedSpaceId, + spacesData, + activeOrganization, + onSpaceChange, + ]); + + const selectedOption = + DATE_RANGE_OPTIONS.find( + (opt) => opt.value === getDisplayValue(value, lastUISelection), + ) || DATE_RANGE_OPTIONS[3]; + + const handleValueChange = (newValue: string) => { + setLastUISelection(newValue); + const backendRange = mapToBackendRange(newValue); + onChange(backendRange); + setOpen(false); + }; + + const handlePrevious = () => { + const currentIndex = DATE_RANGE_OPTIONS.findIndex( + (opt) => opt.value === selectedOption.value, + ); + if (currentIndex > 0) { + const prevOption = DATE_RANGE_OPTIONS[currentIndex - 1]; + handleValueChange(prevOption?.value ?? ""); + } + }; + + const handleNext = () => { + const currentIndex = DATE_RANGE_OPTIONS.findIndex( + (opt) => opt.value === selectedOption.value, + ); + if (currentIndex < DATE_RANGE_OPTIONS.length - 1) { + const nextOption = DATE_RANGE_OPTIONS[currentIndex + 1]; + handleValueChange(nextOption?.value ?? ""); + } + }; + + const canGoPrevious = + DATE_RANGE_OPTIONS.findIndex((opt) => opt.value === selectedOption.value) > + 0; + const canGoNext = + DATE_RANGE_OPTIONS.findIndex((opt) => opt.value === selectedOption.value) < + DATE_RANGE_OPTIONS.length - 1; + + const selectedOrgId = + selectedOrganizationId || activeOrganization?.organization.id; + const selectedOrg = + organizations?.find((org) => org.organization.id === selectedOrgId) || + activeOrganization || + organizations?.[0]; + + const filteredSpaces = spacesData?.filter( + (space) => space.organizationId === selectedOrgId, + ); + + const handleOrgChange = (value: string) => { + if (value.startsWith("space:")) { + const spaceId = value.replace("space:", ""); + const space = filteredSpaces?.find((s) => s.id === spaceId); + if (space) { + onSpaceChange?.(spaceId); + onOrganizationChange?.( + space.organizationId as Organisation.OrganisationId, + ); + } + } else { + onSpaceChange?.(null); + onOrganizationChange?.(value as Organisation.OrganisationId); + } + setOrgOpen(false); + }; + + const selectedSpace = selectedSpaceId + ? filteredSpaces?.find((s) => s.id === selectedSpaceId) + : null; + + const isMyCapsSelected = + !selectedSpaceId && + selectedOrg?.organization.id === activeOrganization?.organization.id; + + const displayName = selectedSpace + ? selectedSpace.name + : isMyCapsSelected + ? user?.name + ? `${user.name}'s Caps` + : "My Caps" + : selectedOrg?.organization.name || "Select organization"; + + const displayIcon = selectedSpace + ? selectedSpace.iconUrl + : isMyCapsSelected + ? user?.imageUrl || selectedOrg?.organization.iconUrl || undefined + : selectedOrg?.organization.iconUrl; + + const displayIconName = selectedSpace + ? selectedSpace.name + : isMyCapsSelected + ? user?.name || selectedOrg?.organization.name || "My Caps" + : selectedOrg?.organization.name || "Select organization"; + + if (!activeOrganization) { + return null; + } + + const selectValue = selectedSpaceId + ? `space:${selectedSpaceId}` + : selectedOrgId || activeOrganization.organization.id; + + const handleBackClick = () => { + router.push("/dashboard/analytics"); + }; + + return ( +
+ {capId && ( +
+ +
+ {capName ? ( + + {capName} + + ) : ( +
+ )} +
+
+ )} + {!hideCapsSelect && ( + + + {displayIconName && ( + + )} + + {displayName} + + + + + + + + + + + + + {user?.name ? `${user.name}'s Caps` : "My Caps"} + + + {filteredSpaces && filteredSpaces.length > 0 && ( + <> +
+ Spaces +
+ {filteredSpaces.map((space) => { + return ( + + + + {space.name} + + + ); + })} + + )} +
+
+
+
+ )} + + +
+ + + + + {selectedOption.label} + + + + + + + +
+ + + + + {DATE_RANGE_OPTIONS.map((option) => { + const isSelected = selectedOption.value === option.value; + return ( + + + {option.label} + + + ); + })} + + + +
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx new file mode 100644 index 0000000000..8a5f4f37f8 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStatBox.tsx @@ -0,0 +1,32 @@ +import type { FontAwesomeIconProps } from "@fortawesome/react-fontawesome"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { clsx } from "clsx"; + +interface OtherStatBoxProps { + title: string; + icon: FontAwesomeIconProps["icon"]; + children: React.ReactNode; + className?: string; +} + +export default function OtherStatBox({ + title, + icon, + children, + className, +}: OtherStatBoxProps) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx new file mode 100644 index 0000000000..d2e630b126 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/OtherStats.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { + faDesktop, + faGlobe, + faMobileScreen, + faRecordVinyl, +} from "@fortawesome/free-solid-svg-icons"; +import type { BreakdownRow } from "../types"; +import OtherStatBox from "./OtherStatBox"; +import TableCard, { + type BrowserRowData, + type CapRowData, + type CityRowData, + type CountryRowData, + type DeviceRowData, + type OSRowData, +} from "./TableCard"; + +export interface OtherStatsData { + countries: BreakdownRow[]; + cities: BreakdownRow[]; + browsers: BreakdownRow[]; + operatingSystems: BreakdownRow[]; + deviceTypes: BreakdownRow[]; + topCaps?: Array | null; +} + +interface OtherStatsProps { + data: OtherStatsData; + isLoading?: boolean; +} + +const deviceMap: Record = { + desktop: "desktop", + mobile: "mobile", + tablet: "tablet", +}; + +const toCountryRow = (row: BreakdownRow): CountryRowData => ({ + countryCode: row.name, + name: row.name, + views: row.views, + comments: null, + reactions: null, + percentage: row.percentage, +}); + +const toCityRow = (row: BreakdownRow): CityRowData => ({ + countryCode: row.subtitle || "", + name: row.subtitle ? `${row.name}, ${row.subtitle}` : row.name, + views: row.views, + comments: null, + reactions: null, + percentage: row.percentage, +}); + +const toBrowserRow = (row: BreakdownRow): BrowserRowData => ({ + browser: browserNameToSlug(row.name), + name: row.name, + views: row.views, + comments: null, + reactions: null, + percentage: row.percentage, +}); + +const toOSRow = (row: BreakdownRow): OSRowData => ({ + os: osNameToKey(row.name), + name: row.name, + views: row.views, + comments: null, + reactions: null, + percentage: row.percentage, +}); + +const toCapRow = (row: BreakdownRow & { id?: string }): CapRowData => ({ + name: row.name, + views: row.views, + comments: null, + reactions: null, + percentage: row.percentage, + id: row.id, +}); + +const browserNameToSlug = (name: string): BrowserRowData["browser"] => { + switch (name.toLowerCase()) { + case "chrome": + return "google-chrome"; + case "firefox": + return "firefox"; + case "safari": + return "safari"; + case "edge": + case "internet explorer": + return "explorer"; + case "opera": + return "opera"; + case "brave": + return "brave"; + default: + return "google-chrome"; + } +}; + +const osNameToKey = (name: string): OSRowData["os"] => { + const normalized = name.toLowerCase().trim(); + if (normalized.includes("mac") || normalized === "ios") { + return "ios"; + } + switch (normalized) { + case "linux": + return "linux"; + case "ubuntu": + return "ubuntu"; + case "fedora": + return "fedora"; + default: + return "windows"; + } +}; + +export default function OtherStats({ data, isLoading }: OtherStatsProps) { + return ( +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ ({ + device: deviceMap[device.name.toLowerCase()] ?? "desktop", + name: device.name, + views: device.views, + comments: null, + reactions: null, + percentage: device.percentage, + }))} + type="device" + isLoading={isLoading} + /> +
+
+ {data.topCaps && data.topCaps.length > 0 && ( + +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx new file mode 100644 index 0000000000..c0dc3c17ff --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/StatsChart.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { Check } from "lucide-react"; +import { cloneElement, type SVGProps, useMemo, useRef, useState } from "react"; +import { + CapIcon, + ChatIcon, + ClapIcon, + ReactionIcon, +} from "@/app/(org)/dashboard/_components/AnimatedIcons"; +import { classNames } from "@/utils/helpers"; +import type { CapIconHandle } from "../../_components/AnimatedIcons/Cap"; +import ChartArea from "./ChartArea"; + +type boxes = "caps" | "views" | "comments" | "reactions"; +type ChartPoint = { + bucket: string; + caps: number; + views: number; + comments: number; + reactions: number; +}; + +const formatCount = (value: number) => { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return value.toLocaleString(); +}; + +interface StatsChartProps { + counts: Record; + data: ChartPoint[]; + isLoading?: boolean; + capId?: string | null; +} + +export default function StatsBox({ + counts, + data, + isLoading, + capId, +}: StatsChartProps) { + const [selectedBoxes, setSelectedBoxes] = useState>( + new Set(["views", "comments", "reactions"]), + ); + + const capsBoxRef = useRef(null); + const viewsBoxRef = useRef(null); + const chatsBoxRef = useRef(null); + const reactionsBoxRef = useRef(null); + + const toggleHandler = (box: boxes) => { + setSelectedBoxes((prev) => { + const next = new Set(prev); + if (next.has(box)) { + next.delete(box); + } else { + next.add(box); + } + return next; + }); + }; + + const formattedCounts = useMemo( + () => ({ + caps: formatCount(counts.caps), + views: formatCount(counts.views), + comments: formatCount(counts.comments), + reactions: formatCount(counts.reactions), + }), + [counts], + ); + + return ( +
+
+ {isLoading ? ( + <> + {Array.from({ length: capId ? 3 : 4 }, (_, index) => ( + + ))} + + ) : ( + <> + toggleHandler("views")} + isSelected={selectedBoxes.has("views")} + title="Views" + value={formattedCounts.views} + metric="views" + onMouseEnter={() => viewsBoxRef.current?.startAnimation()} + onMouseLeave={() => viewsBoxRef.current?.stopAnimation()} + icon={} + /> + toggleHandler("comments")} + isSelected={selectedBoxes.has("comments")} + title="Comments" + value={formattedCounts.comments} + metric="comments" + onMouseEnter={() => chatsBoxRef.current?.startAnimation()} + onMouseLeave={() => chatsBoxRef.current?.stopAnimation()} + icon={} + /> + toggleHandler("reactions")} + isSelected={selectedBoxes.has("reactions")} + title="Reactions" + value={formattedCounts.reactions} + metric="reactions" + onMouseEnter={() => reactionsBoxRef.current?.startAnimation()} + onMouseLeave={() => reactionsBoxRef.current?.stopAnimation()} + icon={} + /> + {!capId && ( + toggleHandler("caps")} + isSelected={selectedBoxes.has("caps")} + title="Caps" + value={formattedCounts.caps} + metric="caps" + onMouseEnter={() => capsBoxRef.current?.startAnimation()} + onMouseLeave={() => capsBoxRef.current?.stopAnimation()} + icon={} + /> + )} + + )} +
+ !capId || metric !== "caps", + )} + data={data} + isLoading={isLoading} + /> +
+ ); +} + +const metricColors = { + views: { + bg: "rgba(59, 130, 246, 0.03)", + bgHover: "rgba(59, 130, 246, 0.05)", + bgSelected: "rgba(59, 130, 246, 0.08)", + border: "rgba(59, 130, 246, 0.15)", + borderSelected: "rgba(59, 130, 246, 0.25)", + }, + comments: { + bg: "rgba(236, 72, 153, 0.03)", + bgHover: "rgba(236, 72, 153, 0.05)", + bgSelected: "rgba(236, 72, 153, 0.08)", + border: "rgba(236, 72, 153, 0.15)", + borderSelected: "rgba(236, 72, 153, 0.25)", + }, + reactions: { + bg: "rgba(249, 115, 22, 0.03)", + bgHover: "rgba(249, 115, 22, 0.05)", + bgSelected: "rgba(249, 115, 22, 0.08)", + border: "rgba(249, 115, 22, 0.15)", + borderSelected: "rgba(249, 115, 22, 0.25)", + }, + caps: { + bg: "rgba(0, 0, 0, 0.02)", + bgHover: "rgba(0, 0, 0, 0.03)", + bgSelected: "rgba(0, 0, 0, 0.05)", + border: "rgba(0, 0, 0, 0.12)", + borderSelected: "rgba(0, 0, 0, 0.2)", + }, +} as const; + +interface StatBoxProps extends React.ButtonHTMLAttributes { + title: string; + value: string; + icon: React.ReactElement>; + isSelected?: boolean; + metric: boxes; +} +function StatBox({ + title, + value, + icon, + isSelected = false, + metric, + ...props +}: StatBoxProps) { + const colors = metricColors[metric]; + + return ( + + ); +} + +function StatBoxSkeleton() { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx new file mode 100644 index 0000000000..8a3034ec87 --- /dev/null +++ b/apps/web/app/(org)/dashboard/analytics/components/TableCard.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { + LogoBadge, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@cap/ui"; +import { + faAppleWhole, + faDesktop, + faMobileScreen, + faTablet, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import clsx from "clsx"; +import getUnicodeFlagIcon from "country-flag-icons/unicode"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; + +const countryCodeToIcon = (countryCode: string | undefined | null) => { + if (!countryCode || countryCode.trim() === "") { + return null; + } + return getUnicodeFlagIcon(countryCode.toUpperCase()); +}; + +const formatNumber = (value?: number | null) => + value == null ? "—" : value.toLocaleString(); +const formatPercentage = (value?: number | null) => + value == null ? "—" : `${Math.round(value * 100)}%`; +const skeletonBar = (width = 48) => ( +
+); + +type BrowserType = + | "google-chrome" + | "firefox" + | "safari" + | "explorer" + | "opera" + | "brave" + | "vivaldi" + | "yandex" + | "duckduckgo" + | "internet-explorer" + | "samsung-internet" + | "uc-browser" + | "qq-browser" + | "maxthon" + | "arora" + | "lunascape"; + +type OperatingSystemType = "windows" | "ios" | "linux" | "fedora" | "ubuntu"; + +type DeviceType = "desktop" | "tablet" | "mobile"; + +export interface CountryRowData { + countryCode: string; + name: string; + views: number; + comments?: number | null; + reactions?: number | null; + percentage: number; +} + +export interface CityRowData { + countryCode: string; + name: string; + views: number; + comments?: number | null; + reactions?: number | null; + percentage: number; +} + +export interface BrowserRowData { + browser: BrowserType; + name: string; + views: number; + comments?: number | null; + reactions?: number | null; + percentage: number; +} + +export interface OSRowData { + os: OperatingSystemType; + name: string; + views: number; + comments?: number | null; + reactions?: number | null; + percentage: number; +} + +export interface DeviceRowData { + device: DeviceType; + name: string; + views: number; + comments?: number | null; + reactions?: number | null; + percentage: number; +} + +export interface CapRowData { + name: string; + views: number; + comments?: number | null; + reactions?: number | null; + percentage: number; + id?: string; +} + +export interface TableCardProps { + title: string; + columns: string[]; + tableClassname?: string; + type: "country" | "city" | "browser" | "os" | "device" | "cap"; + rows: + | CountryRowData[] + | CityRowData[] + | BrowserRowData[] + | OSRowData[] + | DeviceRowData[] + | CapRowData[]; + isLoading?: boolean; +} + +const TableCard = ({ + title: _title, + columns, + rows, + type, + tableClassname, + isLoading, +}: TableCardProps) => { + const router = useRouter(); + const hasRows = rows.length > 0; + const placeholders = Array.from({ length: 4 }, (_, index) => ({ + name: `placeholder-${index}`, + views: 0, + comments: null, + reactions: null, + percentage: 0, + })) as TableCardProps["rows"]; + + const displayRows = isLoading || !hasRows ? placeholders : rows; + const showSkeletons = isLoading || !hasRows; + + const handleCapNameClick = (capId: string | undefined) => { + if (capId && type === "cap") { + router.push(`/dashboard/analytics?capId=${capId}`); + } + }; + + return ( +
+ {/*
+

{title}

+