Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/web/actions/videos/get-analytics.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
};
}),
);
}
52 changes: 52 additions & 0 deletions apps/web/app/api/analytics/track/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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* () {
Expand Down
60 changes: 60 additions & 0 deletions apps/web/app/s/[videoId]/Share.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<number>();

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);
Comment on lines +397 to +398

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 RAF cleanup discards event-listener detachment

When playerRef.current is null at effect mount time, a requestAnimationFrame is scheduled and the effect returns () => cancelAnimationFrame(raf) as its cleanup. When the RAF fires and attach() successfully adds the timeupdate listener, the returned cleanup closure is thrown away — React's effect cleanup only cancels the RAF (a no-op if it already ran), so the listener is never removed on unmount. This leaves a dangling timeupdate callback that continues firing and sending beacons after the component is gone.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/Share.tsx
Line: 397-398

Comment:
**RAF cleanup discards event-listener detachment**

When `playerRef.current` is `null` at effect mount time, a `requestAnimationFrame` is scheduled and the effect returns `() => cancelAnimationFrame(raf)` as its cleanup. When the RAF fires and `attach()` successfully adds the `timeupdate` listener, the returned cleanup closure is thrown away — React's effect cleanup only cancels the RAF (a no-op if it already ran), so the listener is never removed on unmount. This leaves a dangling `timeupdate` callback that continues firing and sending beacons after the component is gone.

How can I resolve this? If you propose a fix, please make it concise.

}, [data.id, data.owner.id, viewerId]);

const isDisabled = (setting: ViewerSettingKey) =>
videoSettings?.[setting] ?? data.orgSettings?.[setting] ?? false;

Expand Down
92 changes: 82 additions & 10 deletions apps/web/app/s/[videoId]/_components/tabs/Activity/Analytics.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getVideoEngagement>>;

const DropOffBar = ({
label,
count,
total,
}: {
label: string;
count: number;
total: number;
}) => {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex justify-between items-center">
<span className="text-[10px] text-gray-500">{label}</span>
<span className="text-[10px] font-medium text-gray-700">{count}</span>
</div>
<div className="h-1 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
};

const Analytics = (props: {
videoId: string;
views: MaybePromise<number>;
Expand All @@ -15,12 +46,12 @@ const Analytics = (props: {
const [views, setViews] = useState(
props.views instanceof Promise ? use(props.views) : props.views,
);
const [engagement, setEngagement] = useState<EngagementData | null>(null);

useEffect(() => {
const fetchAnalytics = async () => {
try {
const result = await getVideoAnalytics(props.videoId);

setViews(result.count);
} catch (error) {
console.error("Error fetching analytics:", error);
Expand All @@ -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],
Expand All @@ -41,14 +79,48 @@ const Analytics = (props: {
);

return (
<CapCardAnalytics
isLoadingAnalytics={props.isLoadingAnalytics}
capId={props.videoId}
displayCount={views}
totalComments={totalComments}
totalReactions={totalReactions}
isOwner={props.isOwner}
/>
<div className="flex flex-col gap-3 w-full">
<CapCardAnalytics
isLoadingAnalytics={props.isLoadingAnalytics}
capId={props.videoId}
displayCount={views}
totalComments={totalComments}
totalReactions={totalReactions}
isOwner={props.isOwner}
/>
{props.isOwner && engagement && engagement.total > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-gray-100">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Avg watched</span>
<span className="text-xs font-semibold text-gray-800">
{engagement.avgPercent}%
</span>
</div>
<div className="grid grid-cols-4 gap-2">
<DropOffBar
label="25%"
count={engagement.reached25}
total={engagement.total}
/>
<DropOffBar
label="50%"
count={engagement.reached50}
total={engagement.total}
/>
<DropOffBar
label="75%"
count={engagement.reached75}
total={engagement.total}
/>
<DropOffBar
label="95%"
count={engagement.reached95}
total={engagement.total}
/>
</div>
</div>
)}
</div>
);
};

Expand Down
2 changes: 2 additions & 0 deletions packages/web-backend/src/Tinybird/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()("Tinybird", {
Expand Down Expand Up @@ -185,6 +186,7 @@ export class Tinybird extends Effect.Service<Tinybird>()("Tinybird", {
browser: row.browser ?? "unknown",
device: row.device ?? "desktop",
os: row.os ?? "unknown",
percent_watched: row.percent_watched ?? null,
}),
)
.join("\n");
Expand Down
Loading