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
64 changes: 64 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,66 @@ export async function getVideoAnalytics(
}),
);
}

export async function getVideoEngagement(videoId: string) {
if (!videoId) throw new Error("Video ID is required");

if (!/^[0-9a-zA-Z_-]+$/.test(videoId))
throw new Error("Invalid video ID format");

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");

const safeId = escapeLiteral(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((e) => {
console.error("tinybird engagement query error", e);
return 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),
};
}),
);
}
49 changes: 49 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,52 @@ export async function POST(request: NextRequest) {
"";

const pathname = body.pathname ?? `/s/${body.videoId}`;
const action = body.action ?? "page_hit";

if (action === "video_progress") {
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: timestamp.toISOString(),
action: "video_progress",
version: "1.0",
session_id: sessionId ?? randomUUID(),
tenant_id: videoRecord.ownerId,
video_id: body.videoId,
percent_watched: percentWatched,
},
]);
}).pipe(provideOptionalAuth),
);
}
return Response.json({ success: true });
Comment on lines +93 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

video_progress branch re-parses sessionId and shadows the already-sanitized sessionId above (including allowing the literal "anonymous" back in). That can collapse engagement sessions for clients where storage fails. Also seems better to use the already-computed timestamp.

Suggested change
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 });
if (action === "video_progress") {
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: timestamp.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 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Resolved in df58819 — the video_progress branch no longer re-parses sessionId; it reuses the sanitized sessionId (with randomUUID() fallback instead of letting "anonymous" back in) and the already-computed timestamp. tenant_id was re-added in 6f7c8b7.

}

await runPromise(
Effect.gen(function* () {
Expand Down
65 changes: 65 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,42 @@ 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);
}
}
};

let cleanup: (() => void) | null = null;

const attach = () => {
const video = playerRef.current;
if (!video) return false;
video.addEventListener("timeupdate", onTimeUpdate);
cleanup = () => video.removeEventListener("timeupdate", onTimeUpdate);
return true;
};

let raf = 0;
if (!attach()) raf = requestAnimationFrame(() => attach());

return () => {
cancelAnimationFrame(raf);
cleanup?.();
};
}, [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
Loading
Loading