diff --git a/.gitignore b/.gitignore index de893d0f..377e4be6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ pm2.yml *.log .vscode .tool-versions +.claude +tmpclaude* +nul # debug npm-debug.log* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..883ee7f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Umami is a privacy-focused web analytics platform built with Next.js 15, React 19, and TypeScript. It serves as an alternative to Google Analytics, storing data in PostgreSQL (primary) with optional ClickHouse for time-series analytics. + +## Common Commands + +```bash +# Development +npm run dev # Start dev server on port 3001 with Turbopack +npm run build # Full production build (includes db setup, tracker, geo data) +npm run start # Start production server + +# Database +npm run build-db # Generate Prisma client +npm run update-db # Run Prisma migrations +npm run check-db # Verify database connection +npm run seed-data # Seed test data + +# Code Quality +npm run lint # Lint with Biome +npm run format # Format with Biome +npm run check # Format and lint with Biome +npm run test # Run Jest tests + +# Building specific parts +npm run build-tracker # Build client-side tracking script (Rollup) +npm run build-geo # Build geolocation database +``` + +## Architecture + +### Directory Structure +- `src/app/` - Next.js App Router (routes and API endpoints) + - `(main)/` - Authenticated app routes (dashboard, websites, teams, boards, etc.) + - `(collect)/` - Data collection routes + - `api/` - REST API endpoints +- `src/components/` - React components (charts, forms, common UI, hooks) +- `src/lib/` - Core utilities (auth, crypto, date, prisma helpers, redis) +- `src/queries/` - Data access layer (Prisma queries and raw SQL) +- `src/store/` - Zustand state stores (app, dashboard, websites, cache) +- `src/tracker/` - Client-side tracking script (lightweight IIFE) +- `prisma/` - Database schema and migrations + +### Key Patterns + +**API Request Handling** - All API endpoints use Zod validation with `parseRequest`: +```typescript +const schema = z.object({ /* fields */ }); +const { body, error } = await parseRequest(request, schema); +if (error) return error(); +``` + +**Authentication** - JWT tokens via Bearer header, share tokens via `x-umami-share-token` header for public dashboards. Role-based access: admin, manager, user. + +**Data Fetching** - React Query with 60s stale time, no retry, no refetch on window focus. + +**State Management** - Zustand for client state, localStorage for user preferences. + +**Styling** - CSS Modules with CSS variables for theming (light/dark mode). + +### Database + +- **ORM**: Prisma 7.x with PostgreSQL adapter +- **Schema**: `prisma/schema.prisma` - 14 models (User, Team, Website, Session, WebsiteEvent, EventData, etc.) +- **Query helpers**: `src/lib/prisma.ts` has dynamic SQL generation functions (`getDateSQL`, `mapFilter`, `getSearchSQL`) +- **Raw SQL**: Complex analytics queries use `{{param}}` template placeholders for safe binding + +### Tracker Script + +The tracking script in `src/tracker/index.js` is a standalone IIFE (~3-4KB) built with Rollup. It sends events to `/api/send`. Alternative script names can be configured via `TRACKER_SCRIPT_NAME` env var. + +## Environment Variables + +Key variables in `.env`: +``` +DATABASE_URL # PostgreSQL connection string (required) +APP_SECRET # Encryption/signing secret +CLICKHOUSE_URL # Optional ClickHouse for analytics +REDIS_URL # Optional Redis for caching/sessions +BASE_PATH # App base path (e.g., /analytics) +DEBUG # Debug namespaces (e.g., umami:*) +``` + +## Requirements + +- Node.js 18.18+ +- PostgreSQL 12.14+ +- pnpm (package manager) diff --git a/prisma/migrations/15_boards/migration.sql b/prisma/migrations/16_boards/migration.sql similarity index 100% rename from prisma/migrations/15_boards/migration.sql rename to prisma/migrations/16_boards/migration.sql diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 435a406f..6e456a67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") relationMode = "prisma" } diff --git a/src/app/(main)/boards/BoardProvider.tsx b/src/app/(main)/boards/BoardProvider.tsx index 36136614..93d4e6f0 100644 --- a/src/app/(main)/boards/BoardProvider.tsx +++ b/src/app/(main)/boards/BoardProvider.tsx @@ -13,9 +13,5 @@ export function BoardProvider({ boardId, children }: { boardId: string; children return ; } - if (!board) { - return null; - } - return {children}; } diff --git a/src/app/(main)/boards/[boardId]/BoardHeader.tsx b/src/app/(main)/boards/[boardId]/BoardHeader.tsx index 86bd822c..a86ddf58 100644 --- a/src/app/(main)/boards/[boardId]/BoardHeader.tsx +++ b/src/app/(main)/boards/[boardId]/BoardHeader.tsx @@ -1,21 +1,48 @@ -import { Button, Column, Grid, Heading, Row, TextField } from '@umami/react-zen'; -import { useMessages } from '@/components/hooks'; +import { Column, Grid, Heading, LoadingButton, Row, TextField, useToast } from '@umami/react-zen'; +import { useState } from 'react'; +import { useApi, useBoard, useMessages, useModified, useNavigation } from '@/components/hooks'; export function BoardHeader() { - const { formatMessage, labels } = useMessages(); + const board = useBoard(); + const { formatMessage, labels, messages } = useMessages(); + const { post, useMutation } = useApi(); + const { touch } = useModified(); + const { router, renderUrl } = useNavigation(); + const { toast } = useToast(); const defaultName = formatMessage(labels.untitled); - const name = ''; - const description = ''; - const handleNameChange = (name: string) => { - //updateReport({ name: name || defaultName }); + const [name, setName] = useState(board?.name ?? ''); + const [description, setDescription] = useState(board?.description ?? ''); + + const { mutateAsync, isPending } = useMutation({ + mutationFn: (data: { name: string; description: string }) => { + if (board) { + return post(`/boards/${board.id}`, data); + } + return post('/boards', { ...data, type: 'dashboard', slug: '' }); + }, + }); + + const handleNameChange = (value: string) => { + setName(value); }; - const handleDescriptionChange = (description: string) => { - //updateReport({ description }); + const handleDescriptionChange = (value: string) => { + setDescription(value); }; - return

asdgfviybiyu8oaero8g9873qrgb875qh0g8

; + const handleSave = async () => { + const result = await mutateAsync({ name: name || defaultName, description }); + + toast(formatMessage(messages.saved)); + touch('boards'); + + if (board) { + touch(`board:${board.id}`); + } else if (result?.id) { + router.push(renderUrl(`/boards/${result.id}`)); + } + }; return ( - asdfasdfds - + + {formatMessage(labels.save)} + ); diff --git a/src/app/(main)/boards/[boardId]/BoardPage.tsx b/src/app/(main)/boards/[boardId]/BoardPage.tsx index 858866c1..20bdd72c 100644 --- a/src/app/(main)/boards/[boardId]/BoardPage.tsx +++ b/src/app/(main)/boards/[boardId]/BoardPage.tsx @@ -1,5 +1,6 @@ 'use client'; import { Column } from '@umami/react-zen'; +import { BoardBody } from '@/app/(main)/boards/[boardId]/BoardBody'; import { BoardHeader } from '@/app/(main)/boards/[boardId]/BoardHeader'; import { BoardProvider } from '@/app/(main)/boards/BoardProvider'; import { PageBody } from '@/components/common/PageBody'; @@ -10,6 +11,7 @@ export function BoardPage({ boardId }: { boardId: string }) { + diff --git a/src/app/(main)/boards/[boardId]/page.tsx b/src/app/(main)/boards/[boardId]/page.tsx index 82300eb6..0eebdb2f 100644 --- a/src/app/(main)/boards/[boardId]/page.tsx +++ b/src/app/(main)/boards/[boardId]/page.tsx @@ -4,7 +4,7 @@ import { BoardPage } from './BoardPage'; export default async function ({ params }: { params: Promise<{ boardId: string }> }) { const { boardId } = await params; - return ; + return ; } export const metadata: Metadata = { diff --git a/src/app/api/boards/[boardId]/route.ts b/src/app/api/boards/[boardId]/route.ts index a5dfd2aa..2f94e527 100644 --- a/src/app/api/boards/[boardId]/route.ts +++ b/src/app/api/boards/[boardId]/route.ts @@ -26,7 +26,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ boar export async function POST(request: Request, { params }: { params: Promise<{ boardId: string }> }) { const schema = z.object({ name: z.string().optional(), - domain: z.string().optional(), + description: z.string().optional(), shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), }); @@ -37,14 +37,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa } const { boardId } = await params; - const { name, domain, shareId } = body; + const { name, description, shareId } = body; if (!(await canUpdateBoard(auth, boardId))) { return unauthorized(); } try { - const board = await updateBoard(boardId, { name, domain, shareId }); + const board = await updateBoard(boardId, { name, description, shareId }); return Response.json(board); } catch (e: any) {