Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/example-dashboard/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@ export async function GET(request: NextRequest) {
case "segments":
const segmentId = searchParams.get("segment") || "all";
return NextResponse.json(await query.getSegmentedMetrics(from, to, segmentId, projectId));
case "skriuw-events":
return NextResponse.json(await query.getSkriuwEventCounts(projectFilter ?? "skriuw", from, to));
case "skriuw-trend":
return NextResponse.json(await query.getSkriuwEventTrend(projectFilter ?? "skriuw", from, to));
case "skriuw-notes":
return NextResponse.json(await query.getSkriuwNotesActivity(projectFilter ?? "skriuw", from, to));
case "skriuw-journal":
return NextResponse.json(await query.getSkriuwJournalActivity(projectFilter ?? "skriuw", from, to));
case "skriuw-auth":
return NextResponse.json(await query.getSkriuwAuthMetrics(projectFilter ?? "skriuw", from, to));
case "skriuw-recent":
const skriuwLimit = parseInt(searchParams.get("limit") || "50");
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
Comment on lines +110 to +112
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

parseInt without radix or NaN guard for limit.

parseInt(searchParams.get("limit") || "50") will return NaN for non-numeric inputs (e.g. ?limit=abc), which then flows into the SQL LIMIT clause and 500s. Also no upper bound, so ?limit=1000000 is allowed. Validate and clamp at the boundary.

🛡️ Proposed fix
-		case "skriuw-recent":
-			const skriuwLimit = parseInt(searchParams.get("limit") || "50");
-			return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
+		case "skriuw-recent": {
+			const parsed = Number(searchParams.get("limit"));
+			const skriuwLimit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 500) : 50;
+			return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter, skriuwLimit, from, to));
+		}

The added braces also resolve the Biome noSwitchDeclarations warning on line 111 by scoping skriuwLimit to its own block.

📝 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
case "skriuw-recent":
const skriuwLimit = parseInt(searchParams.get("limit") || "50");
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw", skriuwLimit, from, to));
case "skriuw-recent": {
const parsed = Number(searchParams.get("limit"));
const skriuwLimit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 500) : 50;
return NextResponse.json(await query.getSkriuwRecentEvents(projectFilter, skriuwLimit, from, to));
}
🧰 Tools
🪛 Biome (2.4.12)

[error] 111-111: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/app/api/analytics/route.ts` around lines 110 - 112,
The case "skriuw-recent" uses parseInt(searchParams.get("limit") || "50")
without a radix or NaN handling, allowing NaN or excessively large values to
reach query.getSkriuwRecentEvents; change to parse with a radix (parseInt(...,
10)), validate the result (fallback to a safe default like 50 on NaN), and clamp
it to a reasonable max (e.g. 1000) before passing to
query.getSkriuwRecentEvents; also wrap the case body in braces so skriuwLimit is
block-scoped (resolving the noSwitchDeclarations warning) and then call
NextResponse.json(await query.getSkriuwRecentEvents(projectFilter ?? "skriuw",
skriuwLimit, from, to)).

case "skriuw-searches":
return NextResponse.json(await query.getSkriuwTopSearches(projectFilter ?? "skriuw", 20, from, to));
Comment on lines +100 to +114
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hardcoded "skriuw" project fallback leaks tenant-specific behavior into the shared API route.

Every new branch uses projectFilter ?? "skriuw", but elsewhere in this handler the convention is to pass projectFilter (undefined → unfiltered) or projectId (null). Consequences:

  • A request like GET /api/analytics?metric=skriuw-events with no projectId silently scopes results to a project literally identified by the string "skriuw". On any deployment whose project id isn't "skriuw" (or whose schema uses UUIDs), this returns empty data with no error.
  • It bakes a single-tenant assumption into a route that the rest of the file treats as multi-project.

The root cause is that getSkriuw* in lib/queries/skriuw.ts declares projectId: string as required. Recommend making projectId optional in those query functions (omit the AND project_id = ... predicate when not provided), and then dropping the ?? "skriuw" here so behavior matches the other case branches.

♻️ Proposed direction

In skriuw.ts:

-export async function getSkriuwEventCounts(projectId: string, from?: Date, to?: Date): Promise<EventCount[]> {
+export async function getSkriuwEventCounts(projectId?: string, from?: Date, to?: Date): Promise<EventCount[]> {
   const range = getRange(from, to);
-  const results =
-    await sql`... AND project_id = ${projectId} ...`;
+  const projectClause = projectId ? sql`AND project_id = ${projectId}` : sql``;
+  const results =
+    await sql`... ${projectClause} ...`;

In route.ts:

-  case "skriuw-events":
-    return NextResponse.json(await query.getSkriuwEventCounts(projectFilter ?? "skriuw", from, to));
+  case "skriuw-events":
+    return NextResponse.json(await query.getSkriuwEventCounts(projectFilter, from, to));

(Apply to all seven skriuw-* cases.)

🧰 Tools
🪛 Biome (2.4.12)

[error] 111-111: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/example-dashboard/app/api/analytics/route.ts` around lines 100 - 114,
The handler is incorrectly hardcoding "skriuw" as a fallback (projectFilter ??
"skriuw") for all skriuw metrics which leaks tenant-specific behavior; change
the seven cases to pass projectFilter (allowing undefined) instead of forcing
"skriuw", and update the corresponding query functions in lib/queries/skriuw.ts
(e.g., getSkriuwEventCounts, getSkriuwEventTrend, getSkriuwNotesActivity,
getSkriuwJournalActivity, getSkriuwAuthMetrics, getSkriuwRecentEvents,
getSkriuwTopSearches) to accept projectId?: string and omit the "AND project_id
= ..." predicate when projectId is not provided so the route remains
multi-tenant and unfiltered when no projectFilter is supplied.

default:
return NextResponse.json({ error: "Unknown metric" }, { status: 400 });
}
Expand Down
35 changes: 17 additions & 18 deletions apps/example-dashboard/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,30 +138,29 @@
}
}

/* Dark themed scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: #3f3f46 #18181b;
}

*::-webkit-scrollbar {
width: 8px;
height: 8px;
/* Custom tooltip styles for charts */
.recharts-tooltip-wrapper {
outline: none !important;
}

*::-webkit-scrollbar-track {
background: #18181b;
.recharts-default-tooltip {
background: var(--background) !important;
border: 1px solid var(--border) !important;
border-radius: calc(var(--radius)) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}

*::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 4px;
/* Scrollbar - light mode fallback */
.light * {
scrollbar-width: thin;
scrollbar-color: #e5e5e5 #ffffff;
}

*::-webkit-scrollbar-thumb:hover {
background: #52525b;
.light *::-webkit-scrollbar-track {
background: #ffffff;
}

*::-webkit-scrollbar-corner {
background: #18181b;
.light *::-webkit-scrollbar-thumb {
background: #e5e5e5;
border-radius: 4px;
}
12 changes: 6 additions & 6 deletions apps/example-dashboard/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ export function AppSidebar() {
className="h-8 text-xs font-medium"
>
<Link href={viewHref(item.id)}>
<item.icon className="size-3.5" />
<span>{item.label}</span>
<item.icon className="size-3.5 text-muted-foreground group-hover:text-foreground transition-colors" />
<span className="text-foreground">{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
Expand Down Expand Up @@ -235,8 +235,8 @@ function TimeRangeSwitcher({ value, onChange }: TimeRangeProps) {
variant="ghost"
className="h-9 w-full justify-start gap-2 px-2 text-left group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded border border-border bg-muted/50">
<CalendarDays className="size-3.5" />
<div className="flex size-6 shrink-0 items-center justify-center rounded border border-border bg-transparent">
<CalendarDays className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className="truncate text-xs font-medium leading-tight">{currentRange.label}</div>
Expand Down Expand Up @@ -280,8 +280,8 @@ function ProjectSwitcher({ projects, selectedProject, onProjectChange }: Project
variant="ghost"
className="h-10 w-full justify-start gap-2 px-2 text-left group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded border border-border bg-muted/50">
<Zap className="size-3.5" />
<div className="flex size-6 shrink-0 items-center justify-center rounded border border-border bg-transparent">
<Zap className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className="truncate text-xs font-semibold leading-tight">{displayName}</div>
Expand Down
14 changes: 8 additions & 6 deletions apps/example-dashboard/components/dashboard-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ export function DashboardContent({
</div>

{!databaseReady && <DatabaseNotice issue={setupIssue} />}
<DemoDataNotice />
{!databaseReady && <DemoDataNotice />}

<div className="overflow-x-auto -mx-3 px-3">
<div className="flex items-center gap-1 p-1 bg-muted/50 rounded-lg w-fit min-w-full">
Expand Down Expand Up @@ -745,10 +745,10 @@ function DatabaseNotice({ issue }: { issue?: "missing_database_url" | "query_fai
sessionStorage.setItem("db-notice-dismissed", "true");
setDismissed(true);
}}
className="group relative w-full rounded-md border border-amber-500/30 bg-amber-500/[0.07] px-3 py-2 text-left hover:bg-amber-500/[0.1] transition-colors"
className="group relative w-full rounded-md border border-border bg-muted/30 px-3 py-2 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-400" />
<AlertTriangle className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-xs text-muted-foreground">{detail}</span>
</div>
<X className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
Expand All @@ -758,14 +758,16 @@ function DatabaseNotice({ issue }: { issue?: "missing_database_url" | "query_fai

function DemoDataNotice() {
const [dismissed, setDismissed] = useState(false);
const isPersonalDashboard = typeof window !== "undefined" &&
window.location.hostname === process.env.NEXT_PUBLIC_PERSONAL_DASHBOARD_HOSTNAME;

useEffect(() => {
if (sessionStorage.getItem("demo-notice-dismissed") === "true") {
setDismissed(true);
}
}, []);

if (dismissed) return null;
if (dismissed || isPersonalDashboard) return null;

return (
<button
Expand All @@ -774,10 +776,10 @@ function DemoDataNotice() {
sessionStorage.setItem("demo-notice-dismissed", "true");
setDismissed(true);
}}
className="group relative w-full rounded-md border border-sky-500/30 bg-sky-500/[0.07] px-3 py-2 text-left hover:bg-sky-500/[0.1] transition-colors"
className="group relative w-full rounded-md border border-border bg-muted/30 px-3 py-2 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<BadgeInfo className="h-4 w-4 shrink-0 text-sky-400" />
<BadgeInfo className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
All data is illustrative! Learn{" "}
<Link href="https://docs.analytics.remcostoeten.nl" className="underline">
Expand Down
34 changes: 22 additions & 12 deletions apps/example-dashboard/components/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,25 @@ export function DataTable<T extends object>({
<tr
key={i}
className={cn(
"hover:bg-muted/50 transition-colors",
"border-b border-border/50 hover:bg-muted/30 transition-colors",
onRowClick && "cursor-pointer",
)}
onClick={() => onRowClick?.(row)}
>
{columns.map((col) => {
const value = row[col.key as keyof T];
return (
<td
key={String(col.key)}
className={cn(
"px-3 py-1.5 text-foreground",
col.align === "right"
? "text-right tabular-nums"
: col.align === "center"
? "text-center"
: "text-left",
)}
>
<td
key={String(col.key)}
className={cn(
"px-3 py-1.5 text-foreground",
col.align === "right"
? "text-right tabular-nums font-medium"
: col.align === "center"
? "text-center"
: "text-left",
)}
>
{col.render ? col.render(value, row) : String(value ?? "")}
</td>
);
Expand Down Expand Up @@ -117,6 +117,16 @@ export function TopPagesTable({ data, className }: TopPagesTableProps) {
title="Top Pages"
className={className}
columns={[
{
key: "host",
label: "Domain",
width: "120px",
render: (v) => (
<span className="text-[10px] truncate block text-muted-foreground">
{(v as string) || "—"}
</span>
),
},
{
key: "path",
label: "Path",
Expand Down
6 changes: 3 additions & 3 deletions apps/example-dashboard/components/geo-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ export function GeoMap({ data, className, onCountryClick }: GeoMapProps) {
},
hover: {
fill: hasData
? "hsl(var(--primary))"
? "hsl(var(--foreground) / 0.8)"
: "hsl(var(--muted-foreground) / 0.2)",
outline: "none",
cursor: hasData ? "pointer" : "default",
Expand All @@ -358,7 +358,7 @@ export function GeoMap({ data, className, onCountryClick }: GeoMapProps) {
{/* Tooltip */}
{tooltipContent && (
<div
className="fixed z-50 px-2 py-1.5 bg-popover border border-border rounded shadow-lg pointer-events-none"
className="fixed z-50 px-2.5 py-1.5 bg-background border border-border rounded shadow-lg pointer-events-none"
style={{
left: tooltipPos.x + 10,
top: tooltipPos.y - 40,
Expand Down Expand Up @@ -392,7 +392,7 @@ export function GeoMap({ data, className, onCountryClick }: GeoMapProps) {
className="w-16 h-3 rounded-sm"
style={{
background:
"linear-gradient(to right, hsl(var(--primary) / 0.2), hsl(var(--primary)))",
"linear-gradient(to right, hsl(var(--muted-foreground) / 0.3), hsl(var(--foreground) / 0.8))",
}}
/>
<span className="text-[10px] text-muted-foreground">Traffic</span>
Expand Down
2 changes: 1 addition & 1 deletion apps/example-dashboard/components/kpi-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function KPICard({ metric, compact = false }: KPICardProps) {
{label}
</p>
<div className="flex items-baseline gap-2 mt-0.5">
<span className="text-xl font-semibold text-foreground tabular-nums">
<span className="text-xl font-semibold text-foreground tabular-nums tracking-tight">
{formattedValue}
</span>
{trend && (
Expand Down
4 changes: 2 additions & 2 deletions apps/example-dashboard/components/trend-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ export function TrendChart({
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="bg-popover border border-border rounded px-2 py-1 shadow-sm">
<div className="bg-background border border-border rounded px-2.5 py-1.5 shadow-lg">
<p className="text-[10px] text-muted-foreground">{p.formattedTime}</p>
<p className="text-xs font-medium text-foreground">
<p className="text-sm font-semibold text-foreground">
{Number(p.value).toLocaleString()}
</p>
</div>
Expand Down
15 changes: 9 additions & 6 deletions apps/example-dashboard/lib/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,31 +82,34 @@ export const mockSignals: SignalEvent[] = [];

// Top pages
export const mockTopPages: ContentMetric[] = [
{ path: "/", views: 45123, uniqueVisitors: 12453, avgDuration: 32, bounceRate: 0.42 },
{ path: "/pricing", views: 12847, uniqueVisitors: 8234, avgDuration: 89, bounceRate: 0.28 },
{ host: "example.com", path: "/", views: 45123, uniqueVisitors: 12453, avgDuration: 32, bounceRate: 0.42 },
{ host: "example.com", path: "/pricing", views: 12847, uniqueVisitors: 8234, avgDuration: 89, bounceRate: 0.28 },
{
host: "example.com",
path: "/docs/getting-started",
views: 8934,
uniqueVisitors: 5621,
avgDuration: 145,
bounceRate: 0.18,
},
{
path: "/blog/analytics-guide",
host: "docs.example.com",
path: "/introduction",
views: 6721,
uniqueVisitors: 4532,
avgDuration: 234,
bounceRate: 0.22,
},
{ path: "/features", views: 5432, uniqueVisitors: 3876, avgDuration: 56, bounceRate: 0.35 },
{ host: "example.com", path: "/features", views: 5432, uniqueVisitors: 3876, avgDuration: 56, bounceRate: 0.35 },
{
path: "/docs/api-reference",
host: "docs.example.com",
path: "/api-reference",
views: 4123,
uniqueVisitors: 2987,
avgDuration: 312,
bounceRate: 0.12,
},
{ path: "/contact", views: 2341, uniqueVisitors: 1876, avgDuration: 45, bounceRate: 0.65 },
{ host: "example.com", path: "/contact", views: 2341, uniqueVisitors: 1876, avgDuration: 45, bounceRate: 0.65 },
{ path: "/about", views: 1923, uniqueVisitors: 1654, avgDuration: 67, bounceRate: 0.48 },
];

Expand Down
3 changes: 2 additions & 1 deletion apps/example-dashboard/lib/queries/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export async function getTopPages(
): Promise<ContentMetric[]> {
const range = getRange(from, to);
const results =
await sql`SELECT path, COUNT(*) as views, COUNT(DISTINCT visitor_id) as unique_visitors FROM events WHERE ${publicTraffic()} AND type = 'pageview' AND ts >= ${range.from} AND ts <= ${range.to} AND path IS NOT NULL ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY path ORDER BY views DESC LIMIT ${limit}`;
await sql`SELECT host, path, COUNT(*) as views, COUNT(DISTINCT visitor_id) as unique_visitors FROM events WHERE ${publicTraffic()} AND type = 'pageview' AND ts >= ${range.from} AND ts <= ${range.to} AND path IS NOT NULL ${projectId ? sql`AND project_id = ${projectId}` : sql``} GROUP BY host, path ORDER BY views DESC LIMIT ${limit}`;
return results.map((r) => ({
host: r.host as string,
path: r.path as string,
views: Number(r.views),
uniqueVisitors: Number(r.unique_visitors),
Expand Down
1 change: 1 addition & 0 deletions apps/example-dashboard/lib/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./audience";
export * from "./sessions";
export * from "./realtime";
export * from "./overview";
export * from "./skriuw";
Loading
Loading