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
20 changes: 20 additions & 0 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "caveman-repo",
"interface": {
"displayName": "Caveman Repo"
},
"plugins": [
{
"name": "caveman",
"source": {
"source": "local",
"path": "./plugins/caveman"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
2 changes: 2 additions & 0 deletions .codex/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[features]
codex_hooks = true
17 changes: 17 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "echo 'CAVEMAN MODE ACTIVE. Rules: Drop articles/filler/pleasantries/hedging. Fragments OK. Short synonyms. Pattern: [thing] [action] [reason]. [next step]. Not: Sure! I would be happy to help you with that. Yes: Bug in auth middleware. Fix: Code/commits/security: write normal. User says stop caveman or normal mode to deactivate.'",
"timeout": 5,
"statusMessage": "Loading caveman mode"
}
]
}
]
}
}
38 changes: 18 additions & 20 deletions apps/example-dashboard/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,22 @@ export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const timeRange = searchParams.get("timeRange") || "24h";
const rawTimeRange = searchParams.get("timeRange") || "30d";
const metric = searchParams.get("metric") || "overview";
const projectId = searchParams.get("projectId") || null;
const projectFilter = projectId || undefined;

const VALID_RANGES = new Set(["30d", "60d", "90d", "180d", "all"]);
if (!VALID_RANGES.has(rawTimeRange)) {
return NextResponse.json({ error: `Invalid timeRange: ${rawTimeRange}` }, { status: 400 });
}
const timeRange = rawTimeRange;

const hours =
timeRange === "1h"
? 1
: timeRange === "6h"
? 6
: timeRange === "24h"
? 24
: timeRange === "7d"
? 168
: timeRange === "30d"
? 720
: 24;
timeRange === "60d" ? 1440 : timeRange === "90d" ? 2160 : timeRange === "180d" ? 4320 : 720;

const from = new Date(Date.now() - hours * 60 * 60 * 1000);
const to = new Date();
const from = timeRange === "all" ? new Date(0) : new Date(to.getTime() - hours * 60 * 60 * 1000);

try {
if (!process.env.DATABASE_URL) {
Expand All @@ -46,19 +42,21 @@ export async function GET(request: NextRequest) {
case "overview-extended":
return NextResponse.json(await query.getOverviewExtended(from, to, projectId));
case "pages":
return NextResponse.json(await query.getTopPages(projectFilter));
return NextResponse.json(await query.getTopPages(projectFilter, 10, from, to));
case "referrers":
return NextResponse.json(await query.getTopReferrers(projectFilter));
return NextResponse.json(await query.getTopReferrers(projectFilter, 10, from, to));
case "geo":
return NextResponse.json(await query.getGeoDistribution(projectFilter));
return NextResponse.json(await query.getGeoDistribution(projectFilter, 100, from, to));
case "geo-detail":
return NextResponse.json(await query.getGeoDetail(from, to, projectId));
case "devices":
return NextResponse.json(await query.getDeviceBreakdown(projectFilter));
return NextResponse.json(await query.getDeviceBreakdown(projectFilter, from, to));
case "trend":
return NextResponse.json(await query.getPageviewsTrend(projectFilter));
return NextResponse.json(await query.getPageviewsTrend(projectFilter, hours, from, to));
case "events":
return NextResponse.json(await query.getRecentEvents(projectFilter));
return NextResponse.json(await query.getRecentEvents(projectFilter, 20, from, to));
case "visitors":
return NextResponse.json(await query.getRecentVisitors(projectId));
return NextResponse.json(await query.getRecentVisitors(projectId, 50, from, to));
case "geo-cities":
const country = searchParams.get("country");
return NextResponse.json(await query.getGeoCities(from, to, country, projectId));
Expand Down
4 changes: 3 additions & 1 deletion apps/example-dashboard/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const metadata: Metadata = {
},
};

const analyticsUrl = process.env.NEXT_PUBLIC_ANALYTICS_URL || "https://ingestion.remcostoeten.nl";

export default function RootLayout({
children,
}: Readonly<{
Expand All @@ -48,7 +50,7 @@ export default function RootLayout({
</ThemeProvider>
<Analytics
projectId="analytics-dashboard"
ingestUrl={process.env.NEXT_PUBLIC_INGEST_URL || ""}
ingestUrl={analyticsUrl}
debug={process.env.NODE_ENV === "development"}
/>
</body>
Expand Down
207 changes: 195 additions & 12 deletions apps/example-dashboard/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import useSWR from "swr";
import {
LayoutDashboard,
Activity,
Expand All @@ -12,6 +13,8 @@ import {
Server,
CalendarDays,
Settings2,
Search,
ChevronDown,
} from "lucide-react";

import {
Expand All @@ -26,12 +29,40 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";

type ProjectOption = {
id: string;
eventCount: number;
};

async function fetchProjects(url: string): Promise<ProjectOption[]> {
const response = await fetch(url);
if (!response.ok) return [];
return response.json();
}

export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const view = searchParams.get("view") || "overview";
const selectedProject = searchParams.get("projectId");
const timeRange = searchParams.get("timeRange") || "30d";
const { data: projects = [] } = useSWR("/api/analytics?metric=projects", fetchProjects, {
fallbackData: [],
refreshInterval: 60000,
});

const dashboardItems = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
Expand All @@ -42,21 +73,62 @@ export function AppSidebar() {
{ id: "technology", label: "Technology", icon: Settings2 },
];

function setSelectedProject(projectId: string | null) {
const params = new URLSearchParams(searchParams.toString());
if (projectId) {
params.set("projectId", projectId);
} else {
params.delete("projectId");
}
router.push(`/?${params.toString()}`);
}

function setTimeRange(range: string) {
const params = new URLSearchParams(searchParams.toString());
if (range === "30d") {
params.delete("timeRange");
} else {
params.set("timeRange", range);
}
router.push(`/?${params.toString()}`);
}

function viewHref(id: string) {
const params = new URLSearchParams(searchParams.toString());
params.set("view", id);
return `/?${params.toString()}`;
}

function openSearch() {
window.dispatchEvent(new Event("open-command-palette"));
}

return (
<Sidebar collapsible="icon" className="border-r border-border">
<SidebarHeader className="border-b border-border">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" className="h-10" asChild>
<Link href="/">
<div className="flex size-6 items-center justify-center rounded bg-foreground">
<Zap className="size-3.5 text-background" />
</div>
<div className="flex flex-col leading-none">
<span className="text-sm font-semibold">Analytics</span>
<span className="text-[10px] text-muted-foreground">Premium Insights</span>
</div>
</Link>
<ProjectSwitcher
projects={projects}
selectedProject={selectedProject}
onProjectChange={setSelectedProject}
/>
</SidebarMenuItem>
<SidebarMenuItem>
<TimeRangeSwitcher value={timeRange} onChange={setTimeRange} />
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
size="lg"
className="h-9 text-xs font-medium"
tooltip="Search"
onClick={openSearch}
>
<Search className="size-3.5" />
<span className="flex-1 text-left">Search</span>
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[9px] text-muted-foreground group-data-[collapsible=icon]:hidden">
⌘K
</kbd>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
Expand All @@ -77,7 +149,7 @@ export function AppSidebar() {
tooltip={item.label}
className="h-8 text-xs font-medium"
>
<Link href={`/?view=${item.id}`}>
<Link href={viewHref(item.id)}>
<item.icon className="size-3.5" />
<span>{item.label}</span>
</Link>
Expand Down Expand Up @@ -140,3 +212,114 @@ export function AppSidebar() {
</Sidebar>
);
}

type TimeRangeProps = {
value: string;
onChange: (range: string) => void;
};

function TimeRangeSwitcher({ value, onChange }: TimeRangeProps) {
const ranges = [
{ value: "all", label: "All time" },
{ value: "30d", label: "Last 30 days" },
{ value: "60d", label: "Last 60 days" },
{ value: "90d", label: "Last 90 days" },
{ value: "180d", label: "Last 180 days" },
];
const currentRange = ranges.find((range) => range.value === value) || ranges[1];

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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>
<div className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className="truncate text-xs font-medium leading-tight">{currentRange.label}</div>
<div className="text-[10px] text-muted-foreground leading-tight">Date range</div>
</div>
<ChevronDown className="size-3 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="w-44">
<DropdownMenuLabel className="text-[10px]">Date range</DropdownMenuLabel>
<DropdownMenuSeparator />
{ranges.map((range) => (
<DropdownMenuItem
key={range.value}
onClick={() => onChange(range.value)}
className={cn("text-[11px]", currentRange.value === range.value && "bg-muted")}
>
{range.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

type ProjectSwitcherProps = {
projects: ProjectOption[];
selectedProject: string | null;
onProjectChange: (projectId: string | null) => void;
};

function ProjectSwitcher({ projects, selectedProject, onProjectChange }: ProjectSwitcherProps) {
const displayName = selectedProject
? projects.find((project) => project.id === selectedProject)?.id || selectedProject
: "All Projects";

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
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>
<div className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<div className="truncate text-xs font-semibold leading-tight">{displayName}</div>
<div className="text-[10px] text-muted-foreground leading-tight">Project scope</div>
</div>
<ChevronDown className="size-3 text-muted-foreground group-data-[collapsible=icon]:hidden" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="w-56">
<DropdownMenuLabel className="text-[10px]">Project scope</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onProjectChange(null)}
className={cn("text-[11px]", !selectedProject && "bg-muted")}
>
<span className="flex-1">All Projects</span>
</DropdownMenuItem>
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => onProjectChange(project.id)}
className={cn(
"text-[11px] flex justify-between",
selectedProject === project.id && "bg-muted",
)}
>
<span className="truncate flex-1">{project.id}</span>
<span className="text-muted-foreground text-[10px] ml-2">
{project.eventCount.toLocaleString()}
</span>
</DropdownMenuItem>
))}
{projects.length === 0 && (
<DropdownMenuItem disabled className="text-[11px] text-muted-foreground">
No projects found
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
8 changes: 4 additions & 4 deletions apps/example-dashboard/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ export function CommandPalette({
];

const timeRanges = [
{ value: "1h", label: "Last 1 hour" },
{ value: "6h", label: "Last 6 hours" },
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
{ value: "all", label: "All time" },
{ value: "30d", label: "Last 30 days" },
{ value: "60d", label: "Last 60 days" },
{ value: "90d", label: "Last 90 days" },
{ value: "180d", label: "Last 180 days" },
];

return (
Expand Down
Loading
Loading