feat(analytics): add interactive analytics dashboard with SVG charts - #455
feat(analytics): add interactive analytics dashboard with SVG charts#455adityakryadav wants to merge 2 commits into
Conversation
|
@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. |
📝 WalkthroughWalkthroughAdds per-user analytics aggregation, an authenticated API endpoint with date filtering, and dashboard charts for traffic history, referrers, countries, and devices. ChangesAnalytics dashboard
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
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
lib/analytics.ts (3)
211-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated days-clamp/start-date logic between
getUserAnalyticsSummaryandgetUserAnalyticsDetails.Both functions compute
rangeDays/startwith identicalMath.max(1, Math.min(365, ...))+utcDayStart(...)logic. Extracting a sharedresolveAnalyticsRange(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 winSequential await before the parallel breakdown queries, and no exported return type.
dailyAnalytics(Line 319) is awaited before thePromise.allfor referrers/devices/countries starts, adding avoidable latency on this user-facing endpoint — all four queries are independent and could run in onePromise.all. Separately, unlikegetUserAnalyticsSummary(Line 209:export type UserAnalyticsSummary = ...), this function has no exported return type, socomponents/analytics/AnalyticsCharts.tsxhand-rolls a matchingAnalyticsDetailstype 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 winIn-memory aggregation won't scale for large click datasets.
findMany(Lines 128-141) pulls everyClickEventrow for the day into memory before aggregating in JS. This directly conflicts with the PR objective of optimizing for large click datasets — a DB-sidegroupBywith_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
📒 Files selected for processing (4)
app/api/analytics/route.tsapp/dashboard/AnalyticsOverview.tsxcomponents/analytics/AnalyticsCharts.tsxlib/analytics.ts
| 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 } | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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"> |
There was a problem hiding this comment.
🎯 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:
- 1: https://tailwindcss.com/blog/tailwindcss-v4
- 2: Replace default explicit spacing scale with multiplier system tailwindlabs/tailwindcss#14857
- 3: https://tailwindcss.com/docs/width
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.
| 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 } }, | ||
| }), | ||
| ]); | ||
| } |
There was a problem hiding this comment.
🗄️ 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.prismaRepository: 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.
|
Hi @adityakryadav , could you please resolve the merge conflicts and above coderabbit comments as well? |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
app/dashboard/AnalyticsOverview.tsx (2)
374-375: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winBound 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 winAvoid rendering a duplicate traffic-history chart.
AnalyticsOverviewalready renders “Clicks Over Time,” whileAnalyticsChartsalso 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 winReferrer breakdown groups by raw full-URL header, fragmenting real sources.
referreris stored as the rawRefererheader value (full URL incl. path/query) at line 448, then grouped as-is ingetUserAnalyticsDetails. 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
📒 Files selected for processing (2)
app/dashboard/AnalyticsOverview.tsxlib/analytics.ts
| } | ||
| 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, | ||
| }; | ||
| } |
There was a problem hiding this comment.
🎯 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.tsRepository: 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 || trueRepository: 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.
Closes #422
Summary by CodeRabbit