Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
48c3237
wip
ameer2468 Oct 22, 2025
62d0be9
Merge branch 'main' into analytics-project
ameer2468 Oct 23, 2025
d268fa0
wip
ameer2468 Oct 23, 2025
0fc70ac
Merge branch 'main' into analytics-project
ameer2468 Oct 24, 2025
f6a4350
ui
ameer2468 Oct 27, 2025
5b3e006
Merge branch 'main' into analytics-project
ameer2468 Oct 28, 2025
37cfa82
add caps table, improve chart look, and more
ameer2468 Oct 28, 2025
88c662d
some refactoring and more ui updates
ameer2468 Oct 28, 2025
53d1543
Merge branch 'main' into analytics-project
ameer2468 Oct 29, 2025
d4631f2
some more ui stuff
ameer2468 Oct 29, 2025
d77c181
comment out metric select
ameer2468 Oct 29, 2025
cfeb9e4
Merge branch 'main' into analytics-project
ameer2468 Oct 31, 2025
c760b8d
make select look better
ameer2468 Oct 31, 2025
510e69e
gap
ameer2468 Oct 31, 2025
c9a78d5
Merge branch 'main' into analytics-project
ameer2468 Nov 5, 2025
3d68b30
Update pnpm-lock.yaml
ameer2468 Nov 5, 2025
a4afa9d
Merge branch 'main' into analytics-project
richiemcilroy Nov 11, 2025
2d86c89
Update pnpm-lock.yaml
richiemcilroy Nov 12, 2025
acda2fc
Integrate Tinybird analytics and update dashboard
richiemcilroy Nov 12, 2025
a94a008
Revamp analytics dashboard UI and filtering
richiemcilroy Nov 13, 2025
b54dd8c
Add aspect ratio and width to CapCard image container
richiemcilroy Nov 13, 2025
4838494
Improve analytics migration and UI interactions
richiemcilroy Nov 13, 2025
4e4646e
Refactor cap deletion logic and analytics tracking
richiemcilroy Nov 13, 2025
2b48e50
format
richiemcilroy Nov 13, 2025
cf1787b
Refactor ShareVideoPage error handling and code style
richiemcilroy Nov 13, 2025
4d081bd
Refactor nav item matching to use exactMatch flag
richiemcilroy Nov 13, 2025
c321959
Refactor nav item path matching logic
richiemcilroy Nov 13, 2025
06d0060
Refactor analytics and sharing components
richiemcilroy Nov 13, 2025
7090526
Remove individual analytics page and fix Tinybird usage
richiemcilroy Nov 13, 2025
006a668
Remove FiltersList component from analytics dashboard
richiemcilroy Nov 13, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pnpm-lock.yaml
.vinxi
native-deps*
apps/storybook/storybook-static
.tinyb

**/.tinyb

*.tsbuildinfo
.cargo/config.toml
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
118 changes: 93 additions & 25 deletions apps/web/actions/videos/get-analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>) =>
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 };
}),
);
}
102 changes: 102 additions & 0 deletions apps/web/app/(org)/dashboard/_components/AnimatedIcons/ChartLine.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
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<ChartLineIconHandle, ChartLineIconProps>(
({ 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<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start("animate");
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter],
);

const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start("normal");
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave],
);

return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
<motion.path
d="m7 13 3-3 4 4 5-5"
variants={variants}
animate={controls}
/>
</svg>
</div>
);
},
);

ChartLineIcon.displayName = "ChartLineIcon";

export default ChartLineIcon;
120 changes: 120 additions & 0 deletions apps/web/app/(org)/dashboard/_components/AnimatedIcons/Clap.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
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<ClapIconHandle, ClapIconProps>(
({ 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<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start("animate");
} else {
onMouseEnter?.(e);
}
},
[controls, onMouseEnter],
);

const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start("normal");
} else {
onMouseLeave?.(e);
}
},
[controls, onMouseLeave],
);
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ overflow: "visible" }}
>
<motion.g animate={controls} variants={variants}>
<motion.g animate={controls} variants={clapVariants}>
<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" />
<path d="m6.2 5.3 3.1 3.9" />
<path d="m12.4 3.4 3.1 4" />
</motion.g>
<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</motion.g>
</svg>
</div>
);
},
);

ClapIcon.displayName = "ClapIcon";

export default ClapIcon;
Loading
Loading