diff --git a/apps/web/actions/videos/get-analytics.ts b/apps/web/actions/videos/get-analytics.ts index 35e1ca743aa..5d470bbd07d 100644 --- a/apps/web/actions/videos/get-analytics.ts +++ b/apps/web/actions/videos/get-analytics.ts @@ -1,6 +1,7 @@ "use server"; import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; import { Tinybird } from "@cap/web-backend"; import { Video } from "@cap/web-domain"; @@ -94,3 +95,64 @@ export async function getVideoAnalytics( }), ); } + +export async function getVideoEngagement(videoId: string) { + if (!videoId) throw new Error("Video ID is required"); + + const user = await getCurrentUser(); + if (!user?.id) throw new Error("Unauthorized"); + + const [video] = await db() + .select({ ownerId: videos.ownerId }) + .from(videos) + .where(eq(videos.id, Video.VideoId.make(videoId))) + .limit(1); + + if (!video || video.ownerId !== user.id) throw new Error("Unauthorized"); + + if (!/^[0-9a-zA-Z_-]+$/.test(videoId)) + throw new Error("Invalid video ID format"); + const safeId = videoId; + + return runPromise( + Effect.gen(function* () { + const tinybird = yield* Tinybird; + + const result = yield* tinybird + .querySql<{ + total: number; + reached_25: number; + reached_50: number; + reached_75: number; + reached_95: number; + avg_percent: number; + }>( + `SELECT count() as total, countIf(max_percent >= 25) as reached_25, countIf(max_percent >= 50) as reached_50, countIf(max_percent >= 75) as reached_75, countIf(max_percent >= 95) as reached_95, round(avg(max_percent)) as avg_percent FROM (SELECT session_id, max(toFloat32(percent_watched)) as max_percent FROM analytics_events WHERE action = 'video_progress' AND video_id = '${safeId}' GROUP BY session_id)`, + ) + .pipe( + Effect.catchAll(() => + Effect.succeed({ + data: [] as { + total: number; + reached_25: number; + reached_50: number; + reached_75: number; + reached_95: number; + avg_percent: number; + }[], + }), + ), + ); + + const row = result.data?.[0]; + return { + total: Number(row?.total ?? 0), + reached25: Number(row?.reached_25 ?? 0), + reached50: Number(row?.reached_50 ?? 0), + reached75: Number(row?.reached_75 ?? 0), + reached95: Number(row?.reached_95 ?? 0), + avgPercent: Number(row?.avg_percent ?? 0), + }; + }), + ); +} diff --git a/apps/web/app/api/analytics/track/route.ts b/apps/web/app/api/analytics/track/route.ts index 9386d1d249a..dee56c88f85 100644 --- a/apps/web/app/api/analytics/track/route.ts +++ b/apps/web/app/api/analytics/track/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { db } from "@cap/database"; import { videos, videoUploads } from "@cap/database/schema"; import { provideOptionalAuth, Tinybird } from "@cap/web-backend"; @@ -23,6 +24,8 @@ interface TrackPayload { hostname?: string | null; userAgent?: string; occurredAt?: string; + action?: string; + percentWatched?: number | null; } const VIEW_TRACKING_DELAY_MS = 2 * 60 * 1000; @@ -85,6 +88,55 @@ export async function POST(request: NextRequest) { ""; const pathname = body.pathname ?? `/s/${body.videoId}`; + const action = body.action ?? "page_hit"; + + if (action === "video_progress") { + const sessionId = + typeof body.sessionId === "string" + ? body.sessionId.trim().slice(0, 128) || null + : null; + const percentWatched = + typeof body.percentWatched === "number" && + body.percentWatched >= 0 && + body.percentWatched <= 100 + ? Math.round(body.percentWatched) + : null; + + if (percentWatched !== null) { + await runPromise( + Effect.gen(function* () { + const maybeUser = yield* Effect.serviceOption(CurrentUser); + const userId = Option.match(maybeUser, { + onNone: () => null as string | null, + onSome: (user) => (user as { id: string }).id, + }); + + const [videoRecord] = yield* Effect.tryPromise(() => + db() + .select({ ownerId: videos.ownerId }) + .from(videos) + .where(eq(videos.id, Video.VideoId.make(body.videoId))) + .limit(1), + ).pipe(Effect.orElseSucceed(() => [] as { ownerId: string }[])); + + if (!videoRecord || userId === videoRecord.ownerId) return; + + const tinybird = yield* Tinybird; + yield* tinybird.appendEvents([ + { + timestamp: new Date().toISOString(), + action: "video_progress", + version: "1.0", + session_id: sessionId ?? randomUUID(), + video_id: body.videoId, + percent_watched: percentWatched, + }, + ]); + }).pipe(provideOptionalAuth), + ); + } + return Response.json({ success: true }); + } await runPromise( Effect.gen(function* () { diff --git a/apps/web/app/s/[videoId]/Share.tsx b/apps/web/app/s/[videoId]/Share.tsx index f3a45d62783..d10304d5ec8 100644 --- a/apps/web/app/s/[videoId]/Share.tsx +++ b/apps/web/app/s/[videoId]/Share.tsx @@ -134,6 +134,35 @@ const trackVideoView = (payload: { }); }; +const PROGRESS_MILESTONES = [25, 50, 75, 95] as const; + +const trackVideoProgress = (videoId: string, percentWatched: number) => { + if (typeof window === "undefined") return; + const sessionId = ensureAnalyticsSessionId(); + const body = JSON.stringify({ + videoId, + sessionId, + action: "video_progress", + percentWatched, + }); + if ( + typeof navigator !== "undefined" && + typeof navigator.sendBeacon === "function" + ) { + navigator.sendBeacon( + "/api/analytics/track", + new Blob([body], { type: "application/json" }), + ); + } else { + void fetch("/api/analytics/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }); + } +}; + type AiGenerationStatus = | "QUEUED" | "PROCESSING" @@ -338,6 +367,37 @@ export const Share = ({ }); }, [data.id, data.orgId, data.owner.id, viewerId]); + useEffect(() => { + if (viewerId && viewerId === data.owner.id) return; + + const fired = new Set(); + + const onTimeUpdate = (e: Event) => { + const video = e.currentTarget as HTMLVideoElement; + if (!video.duration || video.duration === 0) return; + const pct = (video.currentTime / video.duration) * 100; + for (const milestone of PROGRESS_MILESTONES) { + if (!fired.has(milestone) && pct >= milestone) { + fired.add(milestone); + trackVideoProgress(data.id, milestone); + } + } + }; + + const attach = () => { + const video = playerRef.current; + if (!video) return null; + video.addEventListener("timeupdate", onTimeUpdate); + return () => video.removeEventListener("timeupdate", onTimeUpdate); + }; + + const detach = attach(); + if (detach) return detach; + + const raf = requestAnimationFrame(() => attach()); + return () => cancelAnimationFrame(raf); + }, [data.id, data.owner.id, viewerId]); + const isDisabled = (setting: ViewerSettingKey) => videoSettings?.[setting] ?? data.orgSettings?.[setting] ?? false; diff --git a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx index d4e8c5dde63..4388913e6aa 100644 --- a/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx +++ b/apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx @@ -1,10 +1,41 @@ "use client"; import { use, useEffect, useMemo, useState } from "react"; -import { getVideoAnalytics } from "@/actions/videos/get-analytics"; +import { + getVideoAnalytics, + getVideoEngagement, +} from "@/actions/videos/get-analytics"; import { CapCardAnalytics } from "@/app/(org)/dashboard/caps/components/CapCard/CapCardAnalytics"; import type { CommentType } from "../../../Share"; +type EngagementData = Awaited>; + +const DropOffBar = ({ + label, + count, + total, +}: { + label: string; + count: number; + total: number; +}) => { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+
+ {label} + {count} +
+
+
+
+
+ ); +}; + const Analytics = (props: { videoId: string; views: MaybePromise; @@ -15,12 +46,12 @@ const Analytics = (props: { const [views, setViews] = useState( props.views instanceof Promise ? use(props.views) : props.views, ); + const [engagement, setEngagement] = useState(null); useEffect(() => { const fetchAnalytics = async () => { try { const result = await getVideoAnalytics(props.videoId); - setViews(result.count); } catch (error) { console.error("Error fetching analytics:", error); @@ -30,6 +61,13 @@ const Analytics = (props: { fetchAnalytics(); }, [props.videoId]); + useEffect(() => { + if (!props.isOwner) return; + getVideoEngagement(props.videoId) + .then(setEngagement) + .catch(() => {}); + }, [props.videoId, props.isOwner]); + const totalComments = useMemo( () => props.comments.filter((c) => c.type === "text").length, [props.comments], @@ -41,14 +79,48 @@ const Analytics = (props: { ); return ( - +
+ + {props.isOwner && engagement && engagement.total > 0 && ( +
+
+ Avg watched + + {engagement.avgPercent}% + +
+
+ + + + +
+
+ )} +
); }; diff --git a/packages/web-backend/src/Tinybird/index.ts b/packages/web-backend/src/Tinybird/index.ts index 39dd7a6ac02..0be7e8f1f8f 100644 --- a/packages/web-backend/src/Tinybird/index.ts +++ b/packages/web-backend/src/Tinybird/index.ts @@ -23,6 +23,7 @@ export interface TinybirdEventRow { browser?: string | null; device?: string | null; os?: string | null; + percent_watched?: number | null; } export class Tinybird extends Effect.Service()("Tinybird", { @@ -185,6 +186,7 @@ export class Tinybird extends Effect.Service()("Tinybird", { browser: row.browser ?? "unknown", device: row.device ?? "desktop", os: row.os ?? "unknown", + percent_watched: row.percent_watched ?? null, }), ) .join("\n");