diff --git a/AGENTS.md b/AGENTS.md index 6eba4fd..6a3b3f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,203 +2,125 @@ This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. Read it in full before making changes. ---- - ## Repository Structure -This is a monorepo with three sub-projects: - -``` +```text / ├── frontend/ # React SPA (Vite + TypeScript + Tailwind) +│ └── src/ +│ ├── api/ # Axios API client modules +│ ├── components/# Shared UI components (PascalCase .tsx) +│ ├── contexts/ # React Context providers +│ ├── hooks/ # TanStack React Query hooks (useXxx.ts) +│ ├── pages/ # Route-level page components +│ ├── types/ # TypeScript interfaces (index.ts) +│ └── utils/ # Pure utility functions ├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL) +│ └── src/ +│ ├── auth/ # OIDC + JWT logic +│ ├── errors/ # AppError subclasses +│ ├── middleware/# Express middlewares +│ ├── prisma/ # Prisma client singleton +│ ├── routes/ # Express routers (xxx.routes.ts) +│ ├── schemas/ # Zod validation schemas +│ └── services/ # Business logic classes (xxx.service.ts) ├── ios/ # Native iOS app (Swift/Xcode) ├── timetracker-chart/ # Helm chart for Kubernetes deployment -├── docker-compose.yml -└── project.md # Product requirements document +└── docker-compose.yml ``` -### Frontend layout (`frontend/src/`) -``` -api/ # Axios API client modules (one file per resource) -components/ # Shared UI components (PascalCase .tsx) -contexts/ # React Context providers: AuthContext, TimerContext -hooks/ # TanStack React Query custom hooks (useXxx.ts) -pages/ # Route-level page components (XxxPage.tsx) -types/ # All TypeScript interfaces (index.ts) -utils/ # Pure utility functions (dateUtils.ts) -``` - -### Backend layout (`backend/src/`) -``` -auth/ # OIDC + JWT authentication logic -config/ # Environment variable configuration -errors/ # Custom AppError subclasses -middleware/ # auth, errorHandler, validation middleware -prisma/ # Prisma client singleton -routes/ # Express routers (xxx.routes.ts) -schemas/ # Zod validation schemas (index.ts) -services/ # Business logic classes (xxx.service.ts) -types/ # TypeScript interfaces + Express augmentation -utils/ # timeUtils.ts -``` - ---- - ## Build, Lint, and Dev Commands ### Frontend (`frontend/`) -```bash -npm run dev # Start Vite dev server (port 5173) -npm run build # Type-check (tsc) then bundle (vite build) -npm run preview # Preview production build locally -npm run lint # ESLint over .ts/.tsx, zero warnings allowed -``` +- **Dev Server:** `npm run dev` (port 5173) +- **Build:** `npm run build` (tsc & vite build) +- **Lint:** `npm run lint` (ESLint, zero warnings allowed) +- **Preview:** `npm run preview` ### Backend (`backend/`) -```bash -npm run dev # Hot-reload dev server via tsx watch -npm run build # Compile TypeScript to dist/ -npm run start # Run compiled output (node dist/index.js) -npm run db:migrate # Run Prisma migrations -npm run db:generate # Regenerate Prisma client -npm run db:seed # Seed the database -``` +- **Dev Server:** `npm run dev` (tsx watch) +- **Build:** `npm run build` (tsc to dist/) +- **Start:** `npm run start` (node dist/index.js) +- **Database:** + - `npm run db:migrate` (Run migrations) + - `npm run db:generate` (Regenerate client) + - `npm run db:seed` (Seed database) -### Full stack (repo root) -```bash -docker-compose up # Start all services (frontend, backend, postgres) -``` +### Full Stack (Root) +- **Run all:** `docker-compose up` ### Testing -**There is no test framework configured.** No test runner (`jest`, `vitest`, etc.) is installed and no `.spec.ts` / `.test.ts` files exist. When adding tests, set up Vitest (already aligned with Vite) and add a `test` script to `package.json`. To run a single test file with Vitest once installed: -```bash -npx vitest run src/path/to/file.test.ts -``` - ---- - -## TypeScript Configuration - -### Frontend (`frontend/tsconfig.json`) -- `strict: true`, `noUnusedLocals: true`, `noUnusedParameters: true` -- `noEmit: true` — Vite handles all output -- Path alias `@/*` → `src/*` (use `@/` for all internal imports) -- `target: ES2020`, `module: ESNext`, `moduleResolution: bundler` -- `isolatedModules: true`, `resolveJsonModule: true` - -### Backend (`backend/tsconfig.json`) -- `strict: true`, `esModuleInterop: true` -- `target: ES2022`, `module: Node16`, `moduleResolution: Node16` -- `outDir: ./dist`, `rootDir: ./src` -- `declaration: true` (emits `.d.ts` files) - ---- +**No test framework is currently configured.** No test runner (`jest`, `vitest`) is installed and no `.spec.ts` or `.test.ts` files exist. +- When adding tests, set up **Vitest** (aligned with Vite). +- Add a `test` script to `package.json`. +- **To run a single test file with Vitest once installed:** + ```bash + npx vitest run src/path/to/file.test.ts + ``` ## Code Style Guidelines -### Imports -- Use the `@/` alias for all internal frontend imports: `import { useAuth } from "@/contexts/AuthContext"` -- Use `import type { ... }` for type-only imports: `import type { User } from "@/types"` -- Order: external libraries first, then internal `@/` imports -- Named exports are the standard; avoid default exports (only `App.tsx` uses one) +### Imports & Exports +- Use `@/` for all internal frontend imports: `import { useAuth } from "@/contexts/AuthContext"` +- Use `import type { ... }` for type-only imports. Order external libraries first. +- Named exports are standard. Avoid default exports (except in `App.tsx`). ### Formatting -- 2-space indentation throughout -- No Prettier config exists — maintain consistency with surrounding code -- Trailing commas in multi-line objects and arrays -- Quote style is mixed across the codebase (no enforcer); prefer double quotes to match the majority of files +- 2-space indentation. No Prettier config exists; maintain consistency with surrounding code. +- Prefer double quotes. Trailing commas in multi-line objects/arrays. -### Types and Interfaces -- Define all shared types as `interface` (not `type` aliases) in the relevant `types/index.ts` -- Suffix input/mutation types: `CreateClientInput`, `UpdateProjectInput` -- Use `?` for optional fields, not `field: T | undefined` -- Use `string | null` for nullable fields (not `undefined`) -- Backend Zod schemas live in `backend/src/schemas/index.ts`, named `Schema` (e.g., `CreateClientSchema`) -- Backend custom errors extend `AppError`: `NotFoundError`, `BadRequestError`, `ConflictError`, `UnauthorizedError` - -### Naming Conventions - -| Category | Convention | Example | -|---|---|---| -| React components | `PascalCase.tsx` | `TimerWidget.tsx`, `Modal.tsx` | -| Page components | `PascalCasePage.tsx` | `DashboardPage.tsx`, `LoginPage.tsx` | -| Context files | `PascalCaseContext.tsx` | `AuthContext.tsx`, `TimerContext.tsx` | -| Custom hooks | `useXxx.ts` | `useTimeEntries.ts`, `useClients.ts` | -| API modules | `camelCase.ts` | `timeEntries.ts`, `clients.ts` | -| Utility files | `camelCaseUtils.ts` | `dateUtils.ts`, `timeUtils.ts` | -| Backend routes | `camelCase.routes.ts` | `timeEntry.routes.ts` | -| Backend services | `camelCase.service.ts` | `timeEntry.service.ts` | -| Types / schemas | `index.ts` (aggregated) | `src/types/index.ts` | -| Directories | `camelCase` | `api/`, `hooks/`, `routes/`, `services/` | +### Types & Naming Conventions +- Define shared types as `interface` in `types/index.ts`. +- Suffix input types: `CreateClientInput`. +- Use `?` for optional fields, `string | null` for nullable fields (not `undefined`). +- **Components:** `PascalCase.tsx` (`DashboardPage.tsx`) +- **Hooks/Utils/API:** `camelCase.ts` (`useTimeEntries.ts`, `dateUtils.ts`) +- **Backend Routes/Services:** `camelCase.routes.ts`, `camelCase.service.ts` +- **Backend Schemas:** Zod schemas in `backend/src/schemas/index.ts` (e.g., `CreateClientSchema`). ### React Components -- Use named function declarations, not arrow functions assigned to `const`: - ```ts - // correct - export function DashboardPage() { ... } - - // avoid - export const DashboardPage = () => { ... } - ``` -- Context hooks (`useAuth`, `useTimer`) throw an error if called outside their provider — maintain this pattern for all new contexts +- Use named function declarations: `export function DashboardPage() { ... }` +- Context hooks throw an error if called outside their provider. ### State Management -- **Server state**: TanStack React Query (all remote data). Never use `useState` for server data. - - Custom hooks encapsulate `useQuery` + `useMutation` + cache invalidation - - Query keys are arrays: `["timeEntries", filters]`, `["projects", clientId]` - - Use `mutateAsync` (not `mutate`) so callers can `await` and handle errors - - Invalidate related queries after mutations via `queryClient.invalidateQueries` -- **Shared client state**: React Context (`AuthContext`, `TimerContext`) -- **Local UI state**: `useState` per component (modals, form data, error messages) -- No Redux or Zustand — do not introduce them +- **Server state:** TanStack React Query. Never use `useState` for server data. + - Use `mutateAsync` so callers can await and handle errors. + - Invalidate related queries after mutations: `queryClient.invalidateQueries`. +- **Shared client state:** React Context. +- **Local UI state:** `useState`. +- **NO Redux or Zustand.** ### Error Handling - -**Frontend:** -```ts -try { - await someAsyncOperation() -} catch (err) { - setError(err instanceof Error ? err.message : "An error occurred") -} -``` -- Store errors in local `useState` and render inline as red text -- No global error boundary exists; handle errors close to where they occur - -**Backend:** -```ts -router.get("/resource/:id", async (req, res, next) => { +- **Frontend:** + ```typescript try { - const result = await service.getById(req.params.id) - res.json(result) - } catch (error) { - next(error) // always forward to errorHandler middleware + await someAsyncOperation() + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred") } -}) -``` -- Throw `AppError` subclasses from services; never send raw error responses from route handlers -- The global `errorHandler` middleware handles Prisma error codes (P2002, P2025, P2003) and `AppError` subclasses + ``` + Store errors in local state and render inline as red text. No global error boundary exists. +- **Backend:** Throw `AppError` subclasses from services. + ```typescript + router.get("/:id", async (req, res, next) => { + try { + res.json(await service.getById(req.params.id)) + } catch (error) { + next(error) // Always forward to errorHandler middleware + } + }) + ``` ### Styling -- **Tailwind CSS v3** for all styling — no CSS modules, no styled-components -- Use `clsx` + `tailwind-merge` for conditional class merging when needed -- Icons from `lucide-react` only +- **Tailwind CSS v3** only. No CSS modules or styled-components. +- Use `clsx` + `tailwind-merge` for class merging. Icons from `lucide-react` only. -### Backend Validation -- All incoming request data validated with Zod schemas before reaching service layer -- Schemas defined in `backend/src/schemas/index.ts` -- Validation middleware applied per-route; never trust `req.body` without parsing through a schema - -### Database -- Prisma v6 with PostgreSQL -- Database column names are `snake_case`, mapped to `camelCase` TypeScript via `@map` in the Prisma schema -- Always use the Prisma client singleton from `backend/src/prisma/` - ---- +### Backend Validation & Database +- Validate all incoming request data with Zod schemas in middleware. +- Prisma v6 with PostgreSQL. Use the Prisma client singleton from `backend/src/prisma/`. +- DB columns are `snake_case`, mapped to `camelCase` TypeScript via `@map`. ## Key Architectural Decisions -- The frontend communicates with the backend exclusively through the typed Axios modules in `frontend/src/api/` -- Authentication supports two flows: OIDC (web, via `express-session`) and JWT (iOS client, via `jsonwebtoken`) -- The iOS app lives in `ios/` and shares no code with the web frontend — do not couple them -- All business logic belongs in service classes; routes only handle HTTP concerns (parsing, validation, response formatting) +- Frontend communicates with Backend exclusively via typed Axios modules in `frontend/src/api/`. +- iOS app shares no code with the web frontend. +- Backend routes only handle HTTP concerns (parsing, validation, formatting); business logic belongs purely in services. diff --git a/backend/package-lock.json b/backend/package-lock.json index fdf4b21..f27e794 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@prisma/client": "^6.19.2", + "@quixo3/prisma-session-store": "^3.1.19", "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.18.2", @@ -470,6 +471,27 @@ "node": ">=18" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@prisma/client": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", @@ -555,6 +577,24 @@ "@prisma/debug": "6.19.2" } }, + "node_modules/@quixo3/prisma-session-store": { + "version": "3.1.19", + "resolved": "https://registry.npmjs.org/@quixo3/prisma-session-store/-/prisma-session-store-3.1.19.tgz", + "integrity": "sha512-fCG7dzmd8dyqoj4XSi5IHETqrbzN+roz4+4pPS1uMo0kVQu8CT9HRbULuIaOxWCAODT7yGyNGNvVywEeGI80lw==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.0", + "ts-dedent": "^2.2.0", + "type-fest": "^5.3.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.16.1", + "express-session": ">=1.17.1" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2133,6 +2173,18 @@ "node": ">= 0.8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -2152,6 +2204,15 @@ "node": ">=0.6" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2172,6 +2233,21 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/backend/package.json b/backend/package.json index eea2416..41bd041 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@prisma/client": "^6.19.2", + "@quixo3/prisma-session-store": "^3.1.19", "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.18.2", diff --git a/backend/prisma/migrations/20260223103350_add_session_model/migration.sql b/backend/prisma/migrations/20260223103350_add_session_model/migration.sql new file mode 100644 index 0000000..617bdde --- /dev/null +++ b/backend/prisma/migrations/20260223103350_add_session_model/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "sid" TEXT NOT NULL, + "data" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_sid_key" ON "sessions"("sid"); diff --git a/backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql b/backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql new file mode 100644 index 0000000..438090c --- /dev/null +++ b/backend/prisma/migrations/20260223123527_add_break_minutes/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fb41269..399cb79 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -10,7 +10,7 @@ datasource db { model User { id String @id @db.VarChar(255) username String @db.VarChar(255) - fullName String? @db.VarChar(255) @map("full_name") + fullName String? @map("full_name") @db.VarChar(255) email String @db.VarChar(255) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -31,9 +31,9 @@ model Client { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - userId String @map("user_id") @db.VarChar(255) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - projects Project[] + userId String @map("user_id") @db.VarChar(255) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + projects Project[] clientTargets ClientTarget[] @@index([userId]) @@ -41,10 +41,10 @@ model Client { } model Project { - id String @id @default(uuid()) - name String @db.VarChar(255) - description String? @db.Text - color String? @db.VarChar(7) // Hex color code + id String @id @default(uuid()) + name String @db.VarChar(255) + description String? @db.Text + color String? @db.VarChar(7) // Hex color code createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -53,7 +53,7 @@ model Project { clientId String @map("client_id") client Client @relation(fields: [clientId], references: [id], onDelete: Cascade) - timeEntries TimeEntry[] + timeEntries TimeEntry[] ongoingTimers OngoingTimer[] @@index([userId]) @@ -62,16 +62,17 @@ model Project { } model TimeEntry { - id String @id @default(uuid()) - startTime DateTime @map("start_time") @db.Timestamptz() - endTime DateTime @map("end_time") @db.Timestamptz() - description String? @db.Text - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + startTime DateTime @map("start_time") @db.Timestamptz() + endTime DateTime @map("end_time") @db.Timestamptz() + breakMinutes Int @default(0) @map("break_minutes") + description String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - userId String @map("user_id") @db.VarChar(255) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - projectId String @map("project_id") + userId String @map("user_id") @db.VarChar(255) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + projectId String @map("project_id") project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) @@index([userId]) @@ -86,9 +87,9 @@ model OngoingTimer { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - userId String @map("user_id") @db.VarChar(255) @unique - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - projectId String? @map("project_id") + userId String @unique @map("user_id") @db.VarChar(255) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + projectId String? @map("project_id") project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) @@index([userId]) @@ -96,11 +97,11 @@ model OngoingTimer { } model ClientTarget { - id String @id @default(uuid()) - weeklyHours Float @map("weekly_hours") - startDate DateTime @map("start_date") @db.Date // Always a Monday - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(uuid()) + weeklyHours Float @map("weekly_hours") + startDate DateTime @map("start_date") @db.Date // Always a Monday + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") userId String @map("user_id") @db.VarChar(255) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -128,4 +129,13 @@ model BalanceCorrection { @@index([clientTargetId]) @@map("balance_corrections") -} \ No newline at end of file +} + +model Session { + id String @id + sid String @unique + data String @db.Text + expiresAt DateTime @map("expires_at") + + @@map("sessions") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 56772ce..e41bc5b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,8 +1,9 @@ import express from "express"; import cors from "cors"; import session from "express-session"; +import { PrismaSessionStore } from "@quixo3/prisma-session-store"; import { config, validateConfig } from "./config"; -import { connectDatabase } from "./prisma/client"; +import { connectDatabase, prisma } from "./prisma/client"; import { errorHandler, notFoundHandler } from "./middleware/errorHandler"; // Import routes @@ -43,6 +44,11 @@ async function main() { resave: false, saveUninitialized: false, name: "sessionId", + store: new PrismaSessionStore(prisma, { + checkPeriod: 2 * 60 * 1000, // ms + dbRecordIdIsSessionId: true, + dbRecordIdFunction: undefined, + }), cookie: { secure: config.nodeEnv === "production", httpOnly: true, diff --git a/backend/src/routes/timer.routes.ts b/backend/src/routes/timer.routes.ts index 8b7a8a4..474b0c6 100644 --- a/backend/src/routes/timer.routes.ts +++ b/backend/src/routes/timer.routes.ts @@ -48,6 +48,16 @@ router.put( } ); +// DELETE /api/timer - Cancel (discard) the ongoing timer without creating a time entry +router.delete('/', requireAuth, async (req: AuthenticatedRequest, res, next) => { + try { + await timerService.cancel(req.user!.id); + res.status(204).send(); + } catch (error) { + next(error); + } +}); + // POST /api/timer/stop - Stop timer router.post( '/stop', diff --git a/backend/src/schemas/index.ts b/backend/src/schemas/index.ts index d7118b1..a0b73e8 100644 --- a/backend/src/schemas/index.ts +++ b/backend/src/schemas/index.ts @@ -31,6 +31,7 @@ export const UpdateProjectSchema = z.object({ export const CreateTimeEntrySchema = z.object({ startTime: z.string().datetime(), endTime: z.string().datetime(), + breakMinutes: z.number().int().min(0).optional(), description: z.string().max(1000).optional(), projectId: z.string().uuid(), }); @@ -38,6 +39,7 @@ export const CreateTimeEntrySchema = z.object({ export const UpdateTimeEntrySchema = z.object({ startTime: z.string().datetime().optional(), endTime: z.string().datetime().optional(), + breakMinutes: z.number().int().min(0).optional(), description: z.string().max(1000).optional(), projectId: z.string().uuid().optional(), }); @@ -64,6 +66,7 @@ export const StartTimerSchema = z.object({ export const UpdateTimerSchema = z.object({ projectId: z.string().uuid().optional().nullable(), + startTime: z.string().datetime().optional(), }); export const StopTimerSchema = z.object({ diff --git a/backend/src/services/clientTarget.service.ts b/backend/src/services/clientTarget.service.ts index e7f7a12..c73cc40 100644 --- a/backend/src/services/clientTarget.service.ts +++ b/backend/src/services/clientTarget.service.ts @@ -222,7 +222,7 @@ export class ClientTargetService { const rows = await prisma.$queryRaw(Prisma.sql` SELECT DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, - COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS tracked_seconds + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds FROM time_entries te JOIN projects p ON p.id = te.project_id WHERE te.user_id = ${target.userId} diff --git a/backend/src/services/timeEntry.service.ts b/backend/src/services/timeEntry.service.ts index affd05b..898693f 100644 --- a/backend/src/services/timeEntry.service.ts +++ b/backend/src/services/timeEntry.service.ts @@ -42,7 +42,7 @@ export class TimeEntryService { p.id AS project_id, p.name AS project_name, p.color AS project_color, - COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS total_seconds, COUNT(te.id)::bigint AS entry_count FROM time_entries te JOIN projects p ON p.id = te.project_id @@ -63,7 +63,7 @@ export class TimeEntryService { SELECT c.id AS client_id, c.name AS client_name, - COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS total_seconds, COUNT(te.id)::bigint AS entry_count FROM time_entries te JOIN projects p ON p.id = te.project_id @@ -77,7 +77,7 @@ export class TimeEntryService { prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>( Prisma.sql` SELECT - COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, + COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS total_seconds, COUNT(te.id)::bigint AS entry_count FROM time_entries te JOIN projects p ON p.id = te.project_id @@ -204,12 +204,19 @@ export class TimeEntryService { async create(userId: string, data: CreateTimeEntryInput) { const startTime = new Date(data.startTime); const endTime = new Date(data.endTime); + const breakMinutes = data.breakMinutes ?? 0; // Validate end time is after start time if (endTime <= startTime) { throw new BadRequestError("End time must be after start time"); } + // Validate break time doesn't exceed duration + const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000; + if (breakMinutes > durationMinutes) { + throw new BadRequestError("Break time cannot exceed total duration"); + } + // Verify the project belongs to the user const project = await prisma.project.findFirst({ where: { id: data.projectId, userId }, @@ -235,6 +242,7 @@ export class TimeEntryService { data: { startTime, endTime, + breakMinutes, description: data.description, userId, projectId: data.projectId, @@ -267,12 +275,19 @@ export class TimeEntryService { ? new Date(data.startTime) : entry.startTime; const endTime = data.endTime ? new Date(data.endTime) : entry.endTime; + const breakMinutes = data.breakMinutes ?? entry.breakMinutes; // Validate end time is after start time if (endTime <= startTime) { throw new BadRequestError("End time must be after start time"); } + // Validate break time doesn't exceed duration + const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000; + if (breakMinutes > durationMinutes) { + throw new BadRequestError("Break time cannot exceed total duration"); + } + // If project changed, verify it belongs to the user if (data.projectId && data.projectId !== entry.projectId) { const project = await prisma.project.findFirst({ @@ -302,6 +317,7 @@ export class TimeEntryService { data: { startTime, endTime, + breakMinutes, description: data.description, projectId: data.projectId, }, diff --git a/backend/src/services/timer.service.ts b/backend/src/services/timer.service.ts index a35b8f5..792eed9 100644 --- a/backend/src/services/timer.service.ts +++ b/backend/src/services/timer.service.ts @@ -102,9 +102,24 @@ export class TimerService { projectId = data.projectId; } + // Validate startTime if provided + let startTime: Date | undefined = undefined; + if (data.startTime) { + const parsed = new Date(data.startTime); + const now = new Date(); + if (parsed >= now) { + throw new BadRequestError("Start time must be in the past"); + } + startTime = parsed; + } + + const updateData: Record = {}; + if (projectId !== undefined) updateData.projectId = projectId; + if (startTime !== undefined) updateData.startTime = startTime; + return prisma.ongoingTimer.update({ where: { userId }, - data: projectId !== undefined ? { projectId } : {}, + data: updateData, include: { project: { select: { @@ -123,6 +138,15 @@ export class TimerService { }); } + async cancel(userId: string) { + const timer = await this.getOngoingTimer(userId); + if (!timer) { + throw new NotFoundError("No timer is running"); + } + + await prisma.ongoingTimer.delete({ where: { userId } }); + } + async stop(userId: string, data?: StopTimerInput) { const timer = await this.getOngoingTimer(userId); if (!timer) { diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 9559edb..47aa4cb 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -38,6 +38,7 @@ export interface UpdateProjectInput { export interface CreateTimeEntryInput { startTime: string; endTime: string; + breakMinutes?: number; description?: string; projectId: string; } @@ -45,6 +46,7 @@ export interface CreateTimeEntryInput { export interface UpdateTimeEntryInput { startTime?: string; endTime?: string; + breakMinutes?: number; description?: string; projectId?: string; } @@ -71,6 +73,7 @@ export interface StartTimerInput { export interface UpdateTimerInput { projectId?: string | null; + startTime?: string; } export interface StopTimerInput { diff --git a/frontend/index.html b/frontend/index.html index 0692acb..4c819ff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + TimeTracker diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..3ea957c --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..2a4bf1b --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/api/timer.ts b/frontend/src/api/timer.ts index 06e2601..7580651 100644 --- a/frontend/src/api/timer.ts +++ b/frontend/src/api/timer.ts @@ -1,6 +1,11 @@ import apiClient from './client'; import type { OngoingTimer, TimeEntry } from '@/types'; +export interface UpdateTimerPayload { + projectId?: string | null; + startTime?: string; +} + export const timerApi = { getOngoing: async (): Promise => { const { data } = await apiClient.get('/timer'); @@ -14,10 +19,8 @@ export const timerApi = { return data; }, - update: async (projectId?: string | null): Promise => { - const { data } = await apiClient.put('/timer', { - projectId, - }); + update: async (payload: UpdateTimerPayload): Promise => { + const { data } = await apiClient.put('/timer', payload); return data; }, @@ -27,4 +30,8 @@ export const timerApi = { }); return data; }, + + cancel: async (): Promise => { + await apiClient.delete('/timer'); + }, }; \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index a86fbda..1e7ae0d 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -48,7 +48,7 @@ export function Navbar() {
- + TimeTracker Logo TimeTracker diff --git a/frontend/src/components/TimeEntryFormModal.tsx b/frontend/src/components/TimeEntryFormModal.tsx index ad949ee..5ce7693 100644 --- a/frontend/src/components/TimeEntryFormModal.tsx +++ b/frontend/src/components/TimeEntryFormModal.tsx @@ -20,6 +20,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime return { startTime: getLocalISOString(new Date(entry.startTime)), endTime: getLocalISOString(new Date(entry.endTime)), + breakMinutes: entry.breakMinutes, description: entry.description || '', projectId: entry.projectId, }; @@ -29,6 +30,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime return { startTime: getLocalISOString(oneHourAgo), endTime: getLocalISOString(now), + breakMinutes: 0, description: '', projectId: projects?.[0]?.id || '', }; @@ -97,6 +99,16 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime />
+
+ + setFormData({ ...formData, breakMinutes: parseInt(e.target.value) || 0 })} + className="input" + /> +