Skip to content

feat(analytics): add interactive analytics dashboard with SVG charts - #455

Open
adityakryadav wants to merge 2 commits into
vishnukothakapu:mainfrom
adityakryadav:feat/dashboard-analytics
Open

feat(analytics): add interactive analytics dashboard with SVG charts#455
adityakryadav wants to merge 2 commits into
vishnukothakapu:mainfrom
adityakryadav:feat/dashboard-analytics

Conversation

@adityakryadav

@adityakryadav adityakryadav commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #422

Summary by CodeRabbit

  • New Features
    • Added an Analytics section to the dashboard with traffic history and interactive tooltips.
    • Added selectable reporting periods (7, 30, 90 days, or all-time).
    • Added top breakdowns for referrers, countries, and devices.
  • Bug Fixes
    • Improved analytics request handling with clearer responses for unauthorized access and invalid parameters.
    • Added loading and empty states when analytics data isn’t available.

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

@adityakryadav is attempting to deploy a commit to the vishnukothakapu's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-user analytics aggregation, an authenticated API endpoint with date filtering, and dashboard charts for traffic history, referrers, countries, and devices.

Changes

Analytics dashboard

Layer / File(s) Summary
Analytics event pipeline
lib/analytics.ts
Click metadata, bot classification, uniqueness checks, daily aggregation, and transactional persistence remain connected.
Per-user analytics aggregation
lib/analytics.ts
Simplifies the summary response and adds daily totals plus top non-bot breakdowns for referrers, devices, and countries.
Authenticated analytics API
app/api/analytics/route.ts
Adds authenticated GET handling with user lookup, days validation from 1–365 or all, JSON responses, and error statuses.
Dashboard analytics visualization
app/dashboard/AnalyticsOverview.tsx, components/analytics/AnalyticsCharts.tsx
Connects the selected range to chart data and renders traffic, referrer, country, and device visualizations with loading and empty states.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AnalyticsCharts
  participant GET
  participant Session
  participant Prisma
  participant Analytics
  AnalyticsCharts->>GET: Request analytics for selected days
  GET->>Session: Retrieve server session
  GET->>Prisma: Find user by email
  GET->>Analytics: Aggregate user analytics
  Analytics-->>GET: Return daily data and breakdowns
  GET-->>AnalyticsCharts: Return JSON response
Loading

Possibly related issues

  • vishnukothakapu/linkid issue 199 — Covers the analytics API, date filtering, click trends, referrers, countries, and device breakdowns implemented here.
  • vishnukothakapu/linkid issue 542 — Relates to the referrer, device, and country breakdowns added to the analytics details flow.

Possibly related PRs

Suggested labels: level:advanced, gssoc:approved, type:feature

Suggested reviewers: anshika-guleria

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The dashboard, charts, secure API, and date filters are covered, but the required query indexing for large click datasets is not shown. Add database indexing for the analytics queries, or document the existing indexes used to keep large click-dataset queries fast.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding an interactive analytics dashboard with charts.
Out of Scope Changes check ✅ Passed The changes all appear focused on analytics dashboard UI and supporting APIs, with no clear unrelated additions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
lib/analytics.ts (3)

211-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated days-clamp/start-date logic between getUserAnalyticsSummary and getUserAnalyticsDetails.

Both functions compute rangeDays/start with identical Math.max(1, Math.min(365, ...)) + utcDayStart(...) logic. Extracting a shared resolveAnalyticsRange(days) helper avoids drift if the clamp bounds or windowing semantics ever change.

Also applies to: 310-316

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/analytics.ts` around lines 211 - 217, Extract the duplicated day-range
clamping and UTC start-date calculation from getUserAnalyticsSummary and
getUserAnalyticsDetails into a shared resolveAnalyticsRange(days) helper. Update
both functions to use this helper while preserving the existing null behavior,
1–365 day bounds, and utcDayStart windowing semantics.

306-414: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sequential await before the parallel breakdown queries, and no exported return type.

dailyAnalytics (Line 319) is awaited before the Promise.all for referrers/devices/countries starts, adding avoidable latency on this user-facing endpoint — all four queries are independent and could run in one Promise.all. Separately, unlike getUserAnalyticsSummary (Line 209: export type UserAnalyticsSummary = ...), this function has no exported return type, so components/analytics/AnalyticsCharts.tsx hand-rolls a matching AnalyticsDetails type that can silently drift from the actual shape.

♻️ Combine into one Promise.all and export the type
+export type UserAnalyticsDetails = Awaited<ReturnType<typeof getUserAnalyticsDetails>>;
+
 export async function getUserAnalyticsDetails(input: { userId: string; days: number | null }) {
   ...
-    const dailyAnalytics = await prisma.dailyLinkAnalytics.groupBy({ ... });
-    const dailyData = dailyAnalytics.map(...);
-    const [referrers, devices, countries] = await Promise.all([...]);
+    const [dailyAnalytics, referrers, devices, countries] = await Promise.all([
+        prisma.dailyLinkAnalytics.groupBy({ ... }),
+        prisma.clickEvent.groupBy({ ... /* referrers */ }),
+        prisma.clickEvent.groupBy({ ... /* devices */ }),
+        prisma.clickEvent.groupBy({ ... /* countries */ }),
+    ]);
+    const dailyData = dailyAnalytics.map(...);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/analytics.ts` around lines 306 - 414, Update getUserAnalyticsDetails to
execute the dailyLinkAnalytics query together with the referrers, devices, and
countries queries in a single Promise.all, preserving each query’s filters and
result mapping. Export a UserAnalyticsDetails return type for this function,
matching the returned dailyData, referrers, devices, and countries shapes, so
consumers can reuse it instead of duplicating the structure.

124-207: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

In-memory aggregation won't scale for large click datasets.

findMany (Lines 128-141) pulls every ClickEvent row for the day into memory before aggregating in JS. This directly conflicts with the PR objective of optimizing for large click datasets — a DB-side groupBy with _count/filters would avoid materializing potentially huge result sets in the app process.

♻️ Suggested direction: aggregate in the database instead
-    const events = await prisma.clickEvent.findMany({
-        where: { createdAt: { gte: start, lt: endExclusive } },
-        select: { linkId: true, userId: true, isBot: true, isUniqueVisitor: true },
-    });
-
-    const counters = new Map<...>();
-    for (const event of events) { ... }
+    const [totalsByLink, uniqueByLink, botsByLink] = await Promise.all([
+        prisma.clickEvent.groupBy({
+            by: ["linkId"],
+            where: { createdAt: { gte: start, lt: endExclusive }, isBot: false },
+            _count: { id: true },
+        }),
+        prisma.clickEvent.groupBy({
+            by: ["linkId"],
+            where: { createdAt: { gte: start, lt: endExclusive }, isUniqueVisitor: true },
+            _count: { id: true },
+        }),
+        prisma.clickEvent.groupBy({
+            by: ["linkId"],
+            where: { createdAt: { gte: start, lt: endExclusive }, isBot: true },
+            _count: { id: true },
+        }),
+    ]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/analytics.ts` around lines 124 - 207, Replace the in-memory events fetch
and Map aggregation in recomputeDailyAnalyticsForDate with database-side grouped
aggregation for the date range, using groupBy and filtered _count values for
total, unique, and bot clicks. Build the existing dailyLinkAnalytics upserts
from the grouped results while preserving linkId/date, userId, and returned rows
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/api/analytics/route.ts`:
- Around line 24-35: Update the validation condition in the analytics route
around isAllTime and days so every non-"all" value is validated, including 0 and
NaN; remove the truthiness gate and retain the existing 1–365 range and 400
response for invalid values. Ensure non-numeric days such as "abc" returns the
documented 400 instead of reaching getUserAnalyticsDetails.

In `@components/analytics/AnalyticsCharts.tsx`:
- Line 115: Update the loading and empty placeholder containers in
AnalyticsCharts to use the responsive w-full width utility instead of w-100,
keeping their existing layout classes and ensuring both states fill the chart
card like the rendered chart.

In `@lib/analytics.ts`:
- Around line 52-122: The unique-visitor check in processAnalyticsJob is
performed outside the transaction and can overcount under concurrent requests.
Move the deduplication decision into an atomic database operation or add a
database-enforced guard for the linkId/visitorKey window, ensuring concurrent
jobs cannot both mark themselves as unique while preserving bot handling and the
existing analytics increments.

---

Nitpick comments:
In `@lib/analytics.ts`:
- Around line 211-217: Extract the duplicated day-range clamping and UTC
start-date calculation from getUserAnalyticsSummary and getUserAnalyticsDetails
into a shared resolveAnalyticsRange(days) helper. Update both functions to use
this helper while preserving the existing null behavior, 1–365 day bounds, and
utcDayStart windowing semantics.
- Around line 306-414: Update getUserAnalyticsDetails to execute the
dailyLinkAnalytics query together with the referrers, devices, and countries
queries in a single Promise.all, preserving each query’s filters and result
mapping. Export a UserAnalyticsDetails return type for this function, matching
the returned dailyData, referrers, devices, and countries shapes, so consumers
can reuse it instead of duplicating the structure.
- Around line 124-207: Replace the in-memory events fetch and Map aggregation in
recomputeDailyAnalyticsForDate with database-side grouped aggregation for the
date range, using groupBy and filtered _count values for total, unique, and bot
clicks. Build the existing dailyLinkAnalytics upserts from the grouped results
while preserving linkId/date, userId, and returned rows behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 58fafa9e-c724-49c1-af49-85c594a4a042

📥 Commits

Reviewing files that changed from the base of the PR and between 138b212 and a7e4df6.

📒 Files selected for processing (4)
  • app/api/analytics/route.ts
  • app/dashboard/AnalyticsOverview.tsx
  • components/analytics/AnalyticsCharts.tsx
  • lib/analytics.ts

Comment on lines +24 to +35
const daysQuery = request.nextUrl.searchParams.get("days");
const isAllTime = daysQuery === "all";
const days = isAllTime ? null : (daysQuery ? Number.parseInt(daysQuery, 10) : 30);

if (!isAllTime && days) {
if (Number.isNaN(days) || days < 1 || days > 365) {
return NextResponse.json(
{ error: "days must be between 1 and 365, or 'all'" },
{ status: 400 }
);
}
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validation is skipped for days=0 and non-numeric days.

if (!isAllTime && days) treats days as a boolean gate, but 0 and NaN are both falsy in JS. ?days=0 silently bypasses validation (gets clamped to 1 downstream instead of returning the documented 400), and ?days=abc bypasses validation entirely, producing NaN/Invalid Date inside getUserAnalyticsDetails that surfaces as an opaque 500 instead of a clear 400.

🐛 Proposed fix
     const daysQuery = request.nextUrl.searchParams.get("days");
     const isAllTime = daysQuery === "all";
     const days = isAllTime ? null : (daysQuery ? Number.parseInt(daysQuery, 10) : 30);

-    if (!isAllTime && days) {
-      if (Number.isNaN(days) || days < 1 || days > 365) {
-        return NextResponse.json(
-          { error: "days must be between 1 and 365, or 'all'" },
-          { status: 400 }
-        );
-      }
-    }
+    if (!isAllTime && (days === null || Number.isNaN(days) || days < 1 || days > 365)) {
+      return NextResponse.json(
+        { error: "days must be between 1 and 365, or 'all'" },
+        { status: 400 }
+      );
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const daysQuery = request.nextUrl.searchParams.get("days");
const isAllTime = daysQuery === "all";
const days = isAllTime ? null : (daysQuery ? Number.parseInt(daysQuery, 10) : 30);
if (!isAllTime && days) {
if (Number.isNaN(days) || days < 1 || days > 365) {
return NextResponse.json(
{ error: "days must be between 1 and 365, or 'all'" },
{ status: 400 }
);
}
}
const daysQuery = request.nextUrl.searchParams.get("days");
const isAllTime = daysQuery === "all";
const days = isAllTime ? null : (daysQuery ? Number.parseInt(daysQuery, 10) : 30);
if (!isAllTime && (days === null || Number.isNaN(days) || days < 1 || days > 365)) {
return NextResponse.json(
{ error: "days must be between 1 and 365, or 'all'" },
{ status: 400 }
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/analytics/route.ts` around lines 24 - 35, Update the validation
condition in the analytics route around isAllTime and days so every non-"all"
value is validated, including 0 and NaN; remove the truthiness gate and retain
the existing 1–365 range and 400 response for invalid values. Ensure non-numeric
days such as "abc" returns the documented 400 instead of reaching
getUserAnalyticsDetails.


if (loading) {
return (
<div className="flex h-64 w-100 items-center justify-center">

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Tailwind CSS v4 does w-100 resolve automatically as a spacing-scale width utility?

💡 Result:

Yes, in Tailwind CSS v4, w-100 resolves automatically as a spacing-scale width utility [1]. In Tailwind CSS v4, the architecture for spacing utilities has shifted from a static, pre-defined scale to a dynamic, generative system [2][1]. The framework now derives spacing-based utilities—such as w-*, h-*, mt-*, and px-*—directly from a single CSS variable, --spacing (which defaults to 0.25rem or 4px) [2][1][3]. Because of this change, any integer or supported decimal multiplier can be used with these utilities [2]. For example, w-100 will generate a width of calc(var(--spacing) * 100), which computes to 25rem (400px) by default [1]. This eliminates the need for manual configuration or arbitrary value syntax (e.g., w-[400px]) for values that were previously outside the standard spacing scale [2][1].

Citations:


Use w-full for the loading and empty placeholders.
w-100 renders as a fixed ~400px width in Tailwind v4, so these states won’t fill the chart card like the actual chart does. Switching both placeholders to w-full keeps the responsive layout consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/analytics/AnalyticsCharts.tsx` at line 115, Update the loading and
empty placeholder containers in AnalyticsCharts to use the responsive w-full
width utility instead of w-100, keeping their existing layout classes and
ensuring both states fill the chart card like the rendered chart.

Comment thread lib/analytics.ts
Comment on lines +52 to +122
export async function processAnalyticsJob(payload: AnalyticsJobPayload): Promise<void> {
const { linkId, userId, userAgent, referrer, country, acceptLanguage, ip } = payload;

const isBot = isLikelyBot(userAgent);
const deviceType = detectDeviceType(userAgent);
const visitorKey = isBot
? null
: getVisitorKey({
ip,
userAgent,
acceptLanguage,
});

const uniqueWindowStart = new Date(Date.now() - UNIQUE_VISITOR_WINDOW_HOURS * 60 * 60 * 1000);

const existingRecentClick = !isBot && visitorKey
? await prisma.clickEvent.findFirst({
where: {
linkId,
visitorKey,
isBot: false,
createdAt: { gte: uniqueWindowStart },
},
select: { id: true },
})
: null;

const isUniqueVisitor = Boolean(!isBot && visitorKey && !existingRecentClick);
const today = utcDayStart(new Date());

await prisma.$transaction([
prisma.clickEvent.create({
data: {
linkId,
userId,
visitorKey,
userAgent,
referrer,
country,
deviceType,
isBot,
isUniqueVisitor,
},
}),
prisma.dailyLinkAnalytics.upsert({
where: {
linkId_date: {
linkId,
date: today,
},
},
update: {
totalClicks: { increment: isBot ? 0 : 1 },
uniqueClicks: { increment: isUniqueVisitor ? 1 : 0 },
botClicks: { increment: isBot ? 1 : 0 },
},
create: {
linkId,
userId,
date: today,
totalClicks: isBot ? 0 : 1,
uniqueClicks: isUniqueVisitor ? 1 : 0,
botClicks: isBot ? 1 : 0,
},
}),
prisma.link.update({
where: { id: linkId },
data: { clicks: { increment: isBot ? 0 : 1 } },
}),
]);
}

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether job claiming/processing is guaranteed single-flight
ast-grep outline lib/jobs.ts --items all
rg -n -A10 'function claimNextJob' lib/jobs.ts
rg -n 'CRON_SECRET|vercel.json|schedule' -g '*.json' -g '*.ts'

Repository: vishnukothakapu/linkid

Length of output: 1561


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' app/api/jobs/analytics/route.ts
printf '\n---\n'
sed -n '1,220p' lib/jobs.ts
printf '\n---\n'
rg -n 'clickEvent|dailyLinkAnalytics|uniqueClicks|visitorKey' prisma lib app -g '*.ts' -g '*.prisma'

Repository: vishnukothakapu/linkid

Length of output: 8728


🏁 Script executed:

sed -n '60,100p' prisma/schema.prisma

Repository: vishnukothakapu/linkid

Length of output: 1387


Make unique-visitor counting atomic

existingRecentClick is checked before the transaction, so two concurrent processAnalyticsJob runs for the same linkId + visitorKey can both miss the prior click and increment uniqueClicks. There’s no DB-level guard on ClickEvent to prevent that, so the dedupe window can overcount under overlap from multiple requests/workers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/analytics.ts` around lines 52 - 122, The unique-visitor check in
processAnalyticsJob is performed outside the transaction and can overcount under
concurrent requests. Move the deduplication decision into an atomic database
operation or add a database-enforced guard for the linkId/visitorKey window,
ensuring concurrent jobs cannot both mark themselves as unique while preserving
bot handling and the existing analytics increments.

@vishnukothakapu

Copy link
Copy Markdown
Owner

Hi @adityakryadav , could you please resolve the merge conflicts and above coderabbit comments as well?

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
linkid Error Error Jul 27, 2026 4:05pm

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (3)
app/dashboard/AnalyticsOverview.tsx (2)

374-375: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the per-link chart size.

The height grows by 40px for every link, so users with many links can trigger an excessively tall chart and expensive layout. Cap the displayed list or use a fixed-height scrollable/paginated container.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/dashboard/AnalyticsOverview.tsx` around lines 374 - 375, Bound the
per-link chart sizing around the height calculation using clicksPerLinkData so
it cannot grow indefinitely with the number of links. Preserve the minimum
height while adding a maximum display height and an appropriate scrollable,
paginated, or otherwise bounded container for the full list.

358-358: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid rendering a duplicate traffic-history chart.

AnalyticsOverview already renders “Clicks Over Time,” while AnalyticsCharts also renders “Traffic History” and performs another analytics fetch. Keep one traffic visualization, or make the new component own only the missing breakdowns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/dashboard/AnalyticsOverview.tsx` at line 358, Update the
AnalyticsOverview rendering around AnalyticsCharts to avoid showing two
traffic-history visualizations and triggering redundant analytics fetching. Keep
the existing “Clicks Over Time” chart and either remove AnalyticsCharts’
overlapping traffic chart or restrict AnalyticsCharts to only the missing
breakdowns while preserving its unique analytics content.
lib/analytics.ts (1)

342-361: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Referrer breakdown groups by raw full-URL header, fragmenting real sources.

referrer is stored as the raw Referer header value (full URL incl. path/query) at line 448, then grouped as-is in getUserAnalyticsDetails. Two clicks from the same site (e.g. different tweet/path/query) land in separate groups, defeating a meaningful "referrer breakdown" (a PR objective). Normalize to the origin/hostname before persisting.

♻️ Suggested normalization at capture time
-    const referrer = headers.get("referer") ?? headers.get("referrer");
+    const rawReferrer = headers.get("referer") ?? headers.get("referrer");
+    const referrer = rawReferrer
+        ? (() => {
+              try {
+                  return new URL(rawReferrer).hostname;
+              } catch {
+                  return rawReferrer;
+              }
+          })()
+        : null;

Also applies to: 448-448

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/analytics.ts` around lines 342 - 361, Normalize the raw Referer value to
its origin or hostname before persisting it in the click-event capture logic
near the referrer assignment at line 448, so path/query differences group under
the same source. Keep getUserAnalyticsDetails and its referrer groupBy
unchanged, ensuring missing or invalid referrers retain the existing safe
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/analytics.ts`:
- Around line 414-801: Remove the duplicate analytics declarations and trailing
duplicate imports, types, and function implementations in lib/analytics.ts,
retaining one canonical block for each symbol including processAnalyticsJob,
recomputeDailyAnalyticsForDate, and getUserAnalyticsSummary. Ensure
getUserAnalyticsSummary returns only the reduced summary shape, while breakdown
fields remain implemented by getUserAnalyticsDetails.

---

Nitpick comments:
In `@app/dashboard/AnalyticsOverview.tsx`:
- Around line 374-375: Bound the per-link chart sizing around the height
calculation using clicksPerLinkData so it cannot grow indefinitely with the
number of links. Preserve the minimum height while adding a maximum display
height and an appropriate scrollable, paginated, or otherwise bounded container
for the full list.
- Line 358: Update the AnalyticsOverview rendering around AnalyticsCharts to
avoid showing two traffic-history visualizations and triggering redundant
analytics fetching. Keep the existing “Clicks Over Time” chart and either remove
AnalyticsCharts’ overlapping traffic chart or restrict AnalyticsCharts to only
the missing breakdowns while preserving its unique analytics content.

In `@lib/analytics.ts`:
- Around line 342-361: Normalize the raw Referer value to its origin or hostname
before persisting it in the click-event capture logic near the referrer
assignment at line 448, so path/query differences group under the same source.
Keep getUserAnalyticsDetails and its referrer groupBy unchanged, ensuring
missing or invalid referrers retain the existing safe behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 555d616d-68ce-4e62-8169-1f83c846640a

📥 Commits

Reviewing files that changed from the base of the PR and between a7e4df6 and b8fa937.

📒 Files selected for processing (2)
  • app/dashboard/AnalyticsOverview.tsx
  • lib/analytics.ts

Comment thread lib/analytics.ts
Comment on lines +414 to +801
}
import {
detectDeviceType,
getForwardedIp,
getVisitorKey,
isLikelyBot,
utcDayEndExclusive,
utcDayStart,
} from "@/lib/analyticsUtils";
import { enqueueJob } from "@/lib/jobs";
import prisma from "@/lib/prisma";

const UNIQUE_VISITOR_WINDOW_HOURS = 24;

type TrackClickInput = {
linkId: string;
userId: string;
headers: Headers;
};

export type AnalyticsJobPayload = {
linkId: string;
userId: string;
userAgent: string | null;
referrer: string | null;
country: string | null;
acceptLanguage: string | null;
ip: string | null;
};

export async function trackLinkClick(input: TrackClickInput): Promise<void> {
const { linkId, userId, headers } = input;

const userAgent = headers.get("user-agent");
const referrer = headers.get("referer") ?? headers.get("referrer");
const country = headers.get("x-vercel-ip-country") ?? headers.get("cf-ipcountry");
const acceptLanguage = headers.get("accept-language");
const ip = getForwardedIp(headers);

const jobPayload: AnalyticsJobPayload = {
linkId,
userId,
userAgent,
referrer,
country,
acceptLanguage,
ip,
};

await enqueueJob("analytics-click", jobPayload);
}

export async function processAnalyticsJob(payload: AnalyticsJobPayload): Promise<void> {
const { linkId, userId, userAgent, referrer, country, acceptLanguage, ip } = payload;

const isBot = isLikelyBot(userAgent);
const deviceType = detectDeviceType(userAgent);
const visitorKey = isBot
? null
: getVisitorKey({
ip,
userAgent,
acceptLanguage,
});

const uniqueWindowStart = new Date(Date.now() - UNIQUE_VISITOR_WINDOW_HOURS * 60 * 60 * 1000);

const existingRecentClick = !isBot && visitorKey
? await prisma.clickEvent.findFirst({
where: {
linkId,
visitorKey,
isBot: false,
createdAt: { gte: uniqueWindowStart },
},
select: { id: true },
})
: null;

const isUniqueVisitor = Boolean(!isBot && visitorKey && !existingRecentClick);
const today = utcDayStart(new Date());

await prisma.$transaction([
prisma.clickEvent.create({
data: {
linkId,
userId,
visitorKey,
userAgent,
referrer,
country,
deviceType,
isBot,
isUniqueVisitor,
},
}),
prisma.dailyLinkAnalytics.upsert({
where: {
linkId_date: {
linkId,
date: today,
},
},
update: {
totalClicks: { increment: isBot ? 0 : 1 },
uniqueClicks: { increment: isUniqueVisitor ? 1 : 0 },
botClicks: { increment: isBot ? 1 : 0 },
},
create: {
linkId,
userId,
date: today,
totalClicks: isBot ? 0 : 1,
uniqueClicks: isUniqueVisitor ? 1 : 0,
botClicks: isBot ? 1 : 0,
},
}),
prisma.link.update({
where: { id: linkId },
data: { clicks: { increment: isBot ? 0 : 1 } },
}),
]);
}

export async function recomputeDailyAnalyticsForDate(date: Date): Promise<{ rows: number }> {
const start = utcDayStart(date);
const endExclusive = utcDayEndExclusive(date);

const events = await prisma.clickEvent.findMany({
where: {
createdAt: {
gte: start,
lt: endExclusive,
},
},
select: {
linkId: true,
userId: true,
isBot: true,
isUniqueVisitor: true,
},
});

const counters = new Map<
string,
{
linkId: string;
userId: string;
totalClicks: number;
uniqueClicks: number;
botClicks: number;
}
>();

for (const event of events) {
const key = event.linkId;
const current = counters.get(key) ?? {
linkId: event.linkId,
userId: event.userId,
totalClicks: 0,
uniqueClicks: 0,
botClicks: 0,
};

if (event.isBot) {
current.botClicks += 1;
} else {
current.totalClicks += 1;
}

if (event.isUniqueVisitor) {
current.uniqueClicks += 1;
}

counters.set(key, current);
}

const upserts = Array.from(counters.values()).map((entry) =>
prisma.dailyLinkAnalytics.upsert({
where: {
linkId_date: {
linkId: entry.linkId,
date: start,
},
},
update: {
userId: entry.userId,
totalClicks: entry.totalClicks,
uniqueClicks: entry.uniqueClicks,
botClicks: entry.botClicks,
},
create: {
linkId: entry.linkId,
userId: entry.userId,
date: start,
totalClicks: entry.totalClicks,
uniqueClicks: entry.uniqueClicks,
botClicks: entry.botClicks,
},
})
);

if (upserts.length > 0) {
await prisma.$transaction(upserts);
}

return { rows: upserts.length };
}

export type UserAnalyticsSummary = Awaited<ReturnType<typeof getUserAnalyticsSummary>>;

export async function getUserAnalyticsSummary(input: {
userId: string;
days: number | null;
}) {
const rangeDays = input.days === null
? null
: Math.max(1, Math.min(365, input.days));

const start = rangeDays === null
? null
: utcDayStart(new Date(Date.now() - (rangeDays - 1) * 24 * 60 * 60 * 1000));

const [totals, perLink, clicksOverTimeRaw, recentActivityEvent] = await Promise.all([
prisma.dailyLinkAnalytics.aggregate({
where: {
userId: input.userId,
...(start !== null && { date: { gte: start } }),
},
_sum: {
totalClicks: true,
uniqueClicks: true,
botClicks: true,
},
}),
prisma.dailyLinkAnalytics.groupBy({
by: ["linkId"],
where: {
userId: input.userId,
...(start !== null && { date: { gte: start } }),
},
_sum: {
totalClicks: true,
uniqueClicks: true,
botClicks: true,
},
}),
prisma.dailyLinkAnalytics.groupBy({
by: ["date"],
where: {
userId: input.userId,
...(start !== null && { date: { gte: start } }),
},
_sum: {
totalClicks: true,
uniqueClicks: true,
botClicks: true,
},
orderBy: { date: "asc" },
}),
prisma.clickEvent.findFirst({
where: {
userId: input.userId,
...(start !== null && { createdAt: { gte: start } }),
},
orderBy: { createdAt: "desc" },
select: {
id: true,
linkId: true,
country: true,
deviceType: true,
isBot: true,
createdAt: true,
link: {
select: { platform: true, label: true },
},
},
}),
]);

const clicksOverTime = clicksOverTimeRaw.map((entry) => ({
date: entry.date,
totalClicks: entry._sum.totalClicks ?? 0,
uniqueClicks: entry._sum.uniqueClicks ?? 0,
botClicks: entry._sum.botClicks ?? 0,
}));

const recentActivity = recentActivityEvent
? {
id: recentActivityEvent.id,
linkId: recentActivityEvent.linkId,
platform: recentActivityEvent.link.platform,
label: recentActivityEvent.link.label,
country: recentActivityEvent.country,
deviceType: recentActivityEvent.deviceType,
isBot: recentActivityEvent.isBot,
createdAt: recentActivityEvent.createdAt,
}
: null;

if (perLink.length === 0) {
return {
rangeDays,
totals: {
totalClicks: 0,
uniqueClicks: 0,
botClicks: 0,
},
links: [],
clicksOverTime: [],
platformPerformance: [],
recentActivity: null,
};
}

const links = await prisma.link.findMany({
where: {
id: {
in: perLink.map((entry) => entry.linkId),
},
},
select: {
id: true,
platform: true,
label: true,
url: true,
isPublic: true,
},
});

const linksById = new Map(links.map((link) => [link.id, link]));

const resolvedLinks = perLink
.map((entry) => {
const link = linksById.get(entry.linkId);
if (!link) return null;

return {
id: link.id,
platform: link.platform,
label: link.label,
url: link.url,
isPublic: link.isPublic,
totalClicks: entry._sum.totalClicks ?? 0,
uniqueClicks: entry._sum.uniqueClicks ?? 0,
botClicks: entry._sum.botClicks ?? 0,
};
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.sort((a, b) => b.totalClicks - a.totalClicks);

const platformPerformanceMap = new Map<
string,
{ platform: string; totalClicks: number; uniqueClicks: number; linkCount: number }
>();

for (const link of resolvedLinks) {
const current = platformPerformanceMap.get(link.platform) ?? {
platform: link.platform,
totalClicks: 0,
uniqueClicks: 0,
linkCount: 0,
};

current.totalClicks += link.totalClicks;
current.uniqueClicks += link.uniqueClicks;
current.linkCount += 1;

platformPerformanceMap.set(link.platform, current);
}

const platformPerformance = Array.from(platformPerformanceMap.values()).sort(
(a, b) => b.totalClicks - a.totalClicks
);

return {
rangeDays,
totals: {
totalClicks: totals._sum.totalClicks ?? 0,
uniqueClicks: totals._sum.uniqueClicks ?? 0,
botClicks: totals._sum.botClicks ?? 0,
},
links: resolvedLinks,
clicksOverTime,
platformPerformance,
recentActivity,
};
}

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.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm duplicate top-level declarations in the final file
rg -n 'export (async )?function (trackLinkClick|processAnalyticsJob|recomputeDailyAnalyticsForDate|getUserAnalyticsSummary|getUserAnalyticsDetails)\b|export type (AnalyticsJobPayload|UserAnalyticsSummary)\b|^import ' lib/analytics.ts

Repository: vishnukothakapu/linkid

Length of output: 1351


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file size =="
wc -l lib/analytics.ts

echo "== declarations and duplicate context markers =="
rg -n 'export type (TrackClickInput|AnalyticsJobPayload|UserAnalyticsSummary)\b|export async function (trackLinkClick|processAnalyticsJob|recomputeDailyAnalyticsForDate|getUserAnalyticsSummary|getUserAnalyticsDetails)\b|^import ' lib/analytics.ts

echo "== line 410-440 =="
sed -n '410,440p' lib/analytics.ts

echo "== getUserAnalyticsSummary first block (205-305) =="
sed -n '205,305p' lib/analytics.ts

echo "== getUserAnalyticsSummary second block (620-810) =="
sed -n '620,810p' lib/analytics.ts

echo "== git diff stat/name-status (if available) =="
git diff --stat || true
git diff --name-status || true

Repository: vishnukothakapu/linkid

Length of output: 10939


Resolve the unresolved conflict in lib/analytics.ts

The module contains duplicate top-level declarations from line 20 onward through getUserAnalyticsSummary, plus trailing duplicate imports/type/function blocks from line 415 onward. This will fail TypeScript compilation with duplicate-identifier / duplicate-function-implementation errors. Keep only one block and ensure getUserAnalyticsSummary returns the reduced summary shape, with breakdown data remaining in getUserAnalyticsDetails.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/analytics.ts` around lines 414 - 801, Remove the duplicate analytics
declarations and trailing duplicate imports, types, and function implementations
in lib/analytics.ts, retaining one canonical block for each symbol including
processAnalyticsJob, recomputeDailyAnalyticsForDate, and
getUserAnalyticsSummary. Ensure getUserAnalyticsSummary returns only the reduced
summary shape, while breakdown fields remain implemented by
getUserAnalyticsDetails.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(analytics): Interactive dashboard analytics with visual charts

2 participants