Skip to content

Commit fe38ff4

Browse files
Merge pull request #1383 from CapSoftware/analytics-project
feat: Implement Cap Analytics
2 parents fdec142 + 006a668 commit fe38ff4

File tree

86 files changed

+12763
-537
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+12763
-537
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ pnpm-lock.yaml
3535
.vinxi
3636
native-deps*
3737
apps/storybook/storybook-static
38+
.tinyb
39+
40+
**/.tinyb
3841

3942
*.tsbuildinfo
4043
.cargo/config.toml

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,12 @@ Portions of this software are licensed as follows:
6060
# Contributing
6161

6262
See [CONTRIBUTING.md](CONTRIBUTING.md) for more information. This guide is a work in progress, and is updated regularly as the app matures.
63+
64+
## Analytics (Tinybird)
65+
66+
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:
67+
68+
- 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.**
69+
- Validate that the schema and materialized views match what the app expects via `pnpm analytics:check`.
70+
71+
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`.
Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,96 @@
11
"use server";
22

3-
import { dub } from "@cap/utils";
4-
5-
export async function getVideoAnalytics(videoId: string) {
6-
if (!videoId) {
7-
throw new Error("Video ID is required");
8-
}
9-
10-
try {
11-
const response = await dub().analytics.retrieve({
12-
domain: "cap.link",
13-
key: videoId,
14-
});
15-
const { clicks } = response as { clicks: number };
16-
17-
if (typeof clicks !== "number" || clicks === null) {
18-
return { count: 0 };
19-
}
20-
21-
return { count: clicks };
22-
} catch (error: any) {
23-
if (error.code === "not_found") {
24-
return { count: 0 };
25-
}
26-
return { count: 0 };
27-
}
3+
import { db } from "@cap/database";
4+
import { videos } from "@cap/database/schema";
5+
import { Tinybird } from "@cap/web-backend";
6+
import { Video } from "@cap/web-domain";
7+
import { eq } from "drizzle-orm";
8+
import { Effect } from "effect";
9+
import { runPromise } from "@/lib/server";
10+
11+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
12+
const MIN_RANGE_DAYS = 1;
13+
const MAX_RANGE_DAYS = 90;
14+
const DEFAULT_RANGE_DAYS = MAX_RANGE_DAYS;
15+
16+
const escapeLiteral = (value: string) => value.replace(/'/g, "''");
17+
const formatDate = (date: Date) => date.toISOString().slice(0, 10);
18+
const formatDateTime = (date: Date) =>
19+
date.toISOString().slice(0, 19).replace("T", " ");
20+
const buildConditions = (clauses: Array<string | undefined>) =>
21+
clauses.filter((clause): clause is string => Boolean(clause)).join(" AND ");
22+
23+
const normalizeRangeDays = (rangeDays?: number) => {
24+
if (!Number.isFinite(rangeDays)) return DEFAULT_RANGE_DAYS;
25+
const normalized = Math.floor(rangeDays as number);
26+
if (normalized <= 0) return DEFAULT_RANGE_DAYS;
27+
return Math.max(MIN_RANGE_DAYS, Math.min(normalized, MAX_RANGE_DAYS));
28+
};
29+
30+
interface GetVideoAnalyticsOptions {
31+
rangeDays?: number;
32+
}
33+
34+
export async function getVideoAnalytics(
35+
videoId: string,
36+
options?: GetVideoAnalyticsOptions,
37+
) {
38+
if (!videoId) throw new Error("Video ID is required");
39+
40+
const [{ orgId } = { orgId: null }] = await db()
41+
.select({ orgId: videos.orgId })
42+
.from(videos)
43+
.where(eq(videos.id, Video.VideoId.make(videoId)))
44+
.limit(1);
45+
46+
return runPromise(
47+
Effect.gen(function* () {
48+
const tinybird = yield* Tinybird;
49+
50+
const rangeDays = normalizeRangeDays(options?.rangeDays);
51+
const now = new Date();
52+
const from = new Date(now.getTime() - rangeDays * DAY_IN_MS);
53+
const pathname = `/s/${videoId}`;
54+
const aggregateConditions = [
55+
orgId ? `tenant_id = '${escapeLiteral(orgId)}'` : undefined,
56+
`pathname = '${escapeLiteral(pathname)}'`,
57+
`date BETWEEN toDate('${formatDate(from)}') AND toDate('${formatDate(now)}')`,
58+
];
59+
const aggregateSql = `SELECT coalesce(uniqMerge(visits), 0) AS views FROM analytics_pages_mv WHERE ${buildConditions(aggregateConditions)}`;
60+
61+
const rawConditions = [
62+
"action = 'page_hit'",
63+
orgId ? `tenant_id = '${escapeLiteral(orgId)}'` : undefined,
64+
`pathname = '${escapeLiteral(pathname)}'`,
65+
`timestamp BETWEEN toDateTime('${formatDateTime(from)}') AND toDateTime('${formatDateTime(now)}')`,
66+
];
67+
const rawSql = `SELECT coalesce(uniq(session_id), 0) AS views FROM analytics_events WHERE ${buildConditions(rawConditions)}`;
68+
69+
const querySql = (sql: string) =>
70+
tinybird.querySql<{ views: number }>(sql).pipe(
71+
Effect.catchAll((e) => {
72+
console.error("tinybird sql error", e);
73+
return Effect.succeed({ data: [] });
74+
}),
75+
);
76+
77+
const aggregateResult = yield* querySql(aggregateSql);
78+
79+
const fallbackResult = aggregateResult.data?.length
80+
? aggregateResult
81+
: yield* querySql(rawSql);
82+
83+
const data = fallbackResult?.data ?? [];
84+
const firstItem = data[0];
85+
const count =
86+
typeof firstItem === "number"
87+
? firstItem
88+
: typeof firstItem === "object" &&
89+
firstItem !== null &&
90+
"views" in firstItem
91+
? Number(firstItem.views ?? 0)
92+
: 0;
93+
return { count: Number.isFinite(count) ? count : 0 };
94+
}),
95+
);
2896
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"use client";
2+
3+
import type { Variants } from "motion/react";
4+
import { motion, useAnimation } from "motion/react";
5+
import type { HTMLAttributes } from "react";
6+
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
7+
import { cn } from "@/lib/utils";
8+
9+
export interface ChartLineIconHandle {
10+
startAnimation: () => void;
11+
stopAnimation: () => void;
12+
}
13+
14+
interface ChartLineIconProps extends HTMLAttributes<HTMLDivElement> {
15+
size?: number;
16+
}
17+
18+
const variants: Variants = {
19+
normal: {
20+
pathLength: 1,
21+
opacity: 1,
22+
},
23+
animate: {
24+
pathLength: [0, 1],
25+
opacity: [0, 1],
26+
transition: {
27+
delay: 0.15,
28+
duration: 0.3,
29+
opacity: { delay: 0.1 },
30+
},
31+
},
32+
};
33+
34+
const ChartLineIcon = forwardRef<ChartLineIconHandle, ChartLineIconProps>(
35+
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
36+
const controls = useAnimation();
37+
const isControlledRef = useRef(false);
38+
39+
useImperativeHandle(ref, () => {
40+
isControlledRef.current = true;
41+
42+
return {
43+
startAnimation: () => controls.start("animate"),
44+
stopAnimation: () => controls.start("normal"),
45+
};
46+
});
47+
48+
const handleMouseEnter = useCallback(
49+
(e: React.MouseEvent<HTMLDivElement>) => {
50+
if (!isControlledRef.current) {
51+
controls.start("animate");
52+
} else {
53+
onMouseEnter?.(e);
54+
}
55+
},
56+
[controls, onMouseEnter],
57+
);
58+
59+
const handleMouseLeave = useCallback(
60+
(e: React.MouseEvent<HTMLDivElement>) => {
61+
if (!isControlledRef.current) {
62+
controls.start("normal");
63+
} else {
64+
onMouseLeave?.(e);
65+
}
66+
},
67+
[controls, onMouseLeave],
68+
);
69+
70+
return (
71+
<div
72+
className={cn(className)}
73+
onMouseEnter={handleMouseEnter}
74+
onMouseLeave={handleMouseLeave}
75+
{...props}
76+
>
77+
<svg
78+
xmlns="http://www.w3.org/2000/svg"
79+
width={size}
80+
height={size}
81+
viewBox="0 0 24 24"
82+
fill="none"
83+
stroke="currentColor"
84+
strokeWidth="2"
85+
strokeLinecap="round"
86+
strokeLinejoin="round"
87+
>
88+
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
89+
<motion.path
90+
d="m7 13 3-3 4 4 5-5"
91+
variants={variants}
92+
animate={controls}
93+
/>
94+
</svg>
95+
</div>
96+
);
97+
},
98+
);
99+
100+
ChartLineIcon.displayName = "ChartLineIcon";
101+
102+
export default ChartLineIcon;
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"use client";
2+
3+
import type { Variants } from "motion/react";
4+
import { motion, useAnimation } from "motion/react";
5+
import type { HTMLAttributes } from "react";
6+
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
7+
import { cn } from "@/lib/utils";
8+
9+
export interface ClapIconHandle {
10+
startAnimation: () => void;
11+
stopAnimation: () => void;
12+
}
13+
14+
interface ClapIconProps extends HTMLAttributes<HTMLDivElement> {
15+
size?: number;
16+
}
17+
18+
const variants: Variants = {
19+
normal: {
20+
rotate: 0,
21+
originX: "4px",
22+
originY: "20px",
23+
},
24+
animate: {
25+
rotate: [-10, -10, 0],
26+
transition: {
27+
duration: 0.8,
28+
times: [0, 0.5, 1],
29+
ease: "easeInOut",
30+
},
31+
},
32+
};
33+
34+
const clapVariants: Variants = {
35+
normal: {
36+
rotate: 0,
37+
originX: "3px",
38+
originY: "11px",
39+
},
40+
animate: {
41+
rotate: [0, -10, 16, 0],
42+
transition: {
43+
duration: 0.4,
44+
times: [0, 0.3, 0.6, 1],
45+
ease: "easeInOut",
46+
},
47+
},
48+
};
49+
50+
const ClapIcon = forwardRef<ClapIconHandle, ClapIconProps>(
51+
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
52+
const controls = useAnimation();
53+
const isControlledRef = useRef(false);
54+
55+
useImperativeHandle(ref, () => {
56+
isControlledRef.current = true;
57+
58+
return {
59+
startAnimation: () => controls.start("animate"),
60+
stopAnimation: () => controls.start("normal"),
61+
};
62+
});
63+
64+
const handleMouseEnter = useCallback(
65+
(e: React.MouseEvent<HTMLDivElement>) => {
66+
if (!isControlledRef.current) {
67+
controls.start("animate");
68+
} else {
69+
onMouseEnter?.(e);
70+
}
71+
},
72+
[controls, onMouseEnter],
73+
);
74+
75+
const handleMouseLeave = useCallback(
76+
(e: React.MouseEvent<HTMLDivElement>) => {
77+
if (!isControlledRef.current) {
78+
controls.start("normal");
79+
} else {
80+
onMouseLeave?.(e);
81+
}
82+
},
83+
[controls, onMouseLeave],
84+
);
85+
return (
86+
<div
87+
className={cn(className)}
88+
onMouseEnter={handleMouseEnter}
89+
onMouseLeave={handleMouseLeave}
90+
{...props}
91+
>
92+
<svg
93+
xmlns="http://www.w3.org/2000/svg"
94+
width={size}
95+
height={size}
96+
viewBox="0 0 24 24"
97+
fill="none"
98+
stroke="currentColor"
99+
strokeWidth="2"
100+
strokeLinecap="round"
101+
strokeLinejoin="round"
102+
style={{ overflow: "visible" }}
103+
>
104+
<motion.g animate={controls} variants={variants}>
105+
<motion.g animate={controls} variants={clapVariants}>
106+
<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" />
107+
<path d="m6.2 5.3 3.1 3.9" />
108+
<path d="m12.4 3.4 3.1 4" />
109+
</motion.g>
110+
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
111+
</motion.g>
112+
</svg>
113+
</div>
114+
);
115+
},
116+
);
117+
118+
ClapIcon.displayName = "ClapIcon";
119+
120+
export default ClapIcon;

0 commit comments

Comments
 (0)