Compare commits
38 Commits
15abfe0511
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1049410fee | ||
| c9bd0abf18 | |||
| 7ec76e3e8e | |||
| 784e71e187 | |||
| 7677fdd73d | |||
| 924b83eb4d | |||
| 91d13b19db | |||
| 2a5e6d4a22 | |||
| b7bd875462 | |||
| a58dfcfa4a | |||
| 7101f38bc8 | |||
| 3850e2db06 | |||
| 5b7b8e47cb | |||
| 7dd3873148 | |||
| 850f12e09d | |||
| 74999ce265 | |||
| 0c0fbf42ef | |||
| 0d116c8c26 | |||
| 25b7371d08 | |||
|
|
ddb0926dba | ||
|
|
1b0f5866a1 | ||
|
|
159022ef38 | ||
|
|
1a7d13d5b9 | ||
|
|
685a311001 | ||
| d09247d2a5 | |||
|
|
078dc8c304 | ||
|
|
59eda58ee6 | ||
| d56eed8dde | |||
|
|
3fa13e1428 | ||
|
|
2e629d8017 | ||
|
|
6e0567d021 | ||
|
|
3ab39643dd | ||
|
|
e01e5e59df | ||
|
|
06596dcee9 | ||
|
|
7358fa6256 | ||
|
|
c99bdf56e6 | ||
|
|
544b86c948 | ||
|
|
b971569983 |
250
AGENTS.md
250
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 `<Entity>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<string | null>` 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.
|
||||
|
||||
76
backend/package-lock.json
generated
76
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable: add deleted_at column to clients
|
||||
ALTER TABLE "clients" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: add deleted_at column to projects
|
||||
ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: add deleted_at column to time_entries
|
||||
ALTER TABLE "time_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: add deleted_at column to client_targets
|
||||
ALTER TABLE "client_targets" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable: add deleted_at column to balance_corrections
|
||||
ALTER TABLE "balance_corrections" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PeriodType" AS ENUM ('WEEKLY', 'MONTHLY');
|
||||
|
||||
-- AlterTable: rename weekly_hours -> target_hours, add period_type, add working_days
|
||||
ALTER TABLE "client_targets"
|
||||
RENAME COLUMN "weekly_hours" TO "target_hours";
|
||||
|
||||
ALTER TABLE "client_targets"
|
||||
ADD COLUMN "period_type" "PeriodType" NOT NULL DEFAULT 'WEEKLY',
|
||||
ADD COLUMN "working_days" TEXT[] NOT NULL DEFAULT ARRAY['MON','TUE','WED','THU','FRI']::TEXT[];
|
||||
@@ -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")
|
||||
@@ -25,15 +25,16 @@ model User {
|
||||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid())
|
||||
name String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_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,19 +42,20 @@ 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
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
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")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
clientId String @map("client_id")
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
timeEntries TimeEntry[]
|
||||
timeEntries TimeEntry[]
|
||||
ongoingTimers OngoingTimer[]
|
||||
|
||||
@@index([userId])
|
||||
@@ -62,16 +64,18 @@ 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")
|
||||
deletedAt DateTime? @map("deleted_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,21 +90,29 @@ 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])
|
||||
@@map("ongoing_timers")
|
||||
}
|
||||
|
||||
enum PeriodType {
|
||||
WEEKLY
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
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())
|
||||
targetHours Float @map("target_hours")
|
||||
periodType PeriodType @default(WEEKLY) @map("period_type")
|
||||
workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"]
|
||||
startDate DateTime @map("start_date") @db.Date
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
userId String @map("user_id") @db.VarChar(255)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -116,16 +128,26 @@ model ClientTarget {
|
||||
}
|
||||
|
||||
model BalanceCorrection {
|
||||
id String @id @default(uuid())
|
||||
date DateTime @map("date") @db.Date
|
||||
id String @id @default(uuid())
|
||||
date DateTime @map("date") @db.Date
|
||||
hours Float
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
clientTargetId String @map("client_target_id")
|
||||
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([clientTargetId])
|
||||
@@map("balance_corrections")
|
||||
}
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
sid String @unique
|
||||
data String @db.Text
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,20 +66,27 @@ 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({
|
||||
projectId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const WorkingDayEnum = z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']);
|
||||
|
||||
export const CreateClientTargetSchema = z.object({
|
||||
clientId: z.string().uuid(),
|
||||
weeklyHours: z.number().positive().max(168),
|
||||
targetHours: z.number().positive().max(168),
|
||||
periodType: z.enum(['weekly', 'monthly']),
|
||||
workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required'),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format'),
|
||||
});
|
||||
|
||||
export const UpdateClientTargetSchema = z.object({
|
||||
weeklyHours: z.number().positive().max(168).optional(),
|
||||
targetHours: z.number().positive().max(168).optional(),
|
||||
periodType: z.enum(['weekly', 'monthly']).optional(),
|
||||
workingDays: z.array(WorkingDayEnum).min(1, 'At least one working day is required').optional(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import type { CreateClientInput, UpdateClientInput } from "../types";
|
||||
export class ClientService {
|
||||
async findAll(userId: string) {
|
||||
return prisma.client.findMany({
|
||||
where: { userId },
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.client.findFirst({
|
||||
where: { id, userId },
|
||||
where: { id, userId, deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ export class ClientService {
|
||||
throw new NotFoundError("Client not found");
|
||||
}
|
||||
|
||||
await prisma.client.delete({
|
||||
await prisma.client.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
|
||||
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
// Returns the Monday of the week containing the given date
|
||||
function getMondayOfWeek(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day-of-week helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const;
|
||||
|
||||
/** Returns the UTC day index (0=Sun … 6=Sat) for a YYYY-MM-DD string. */
|
||||
function dayIndex(dateStr: string): number {
|
||||
return new Date(dateStr + 'T00:00:00Z').getUTCDay();
|
||||
}
|
||||
|
||||
/** Checks whether a day-name string (e.g. "MON") is in the working-days array. */
|
||||
function isWorkingDay(dateStr: string, workingDays: string[]): boolean {
|
||||
return workingDays.includes(DAY_NAMES[dayIndex(dateStr)]);
|
||||
}
|
||||
|
||||
/** Adds `n` calendar days to a YYYY-MM-DD string and returns a new YYYY-MM-DD. */
|
||||
function addDays(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** Returns the Monday of the ISO week that contains the given date string. */
|
||||
function getMondayOfWeek(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
const day = d.getUTCDay(); // 0=Sun
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
d.setUTCDate(d.getUTCDate() + diff);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Returns the Sunday (end of week) for a given Monday
|
||||
function getSundayOfWeek(monday: Date): Date {
|
||||
const d = new Date(monday);
|
||||
d.setUTCDate(d.getUTCDate() + 6);
|
||||
d.setUTCHours(23, 59, 59, 999);
|
||||
return d;
|
||||
/** Returns the Sunday of the ISO week given its Monday date string. */
|
||||
function getSundayOfWeek(monday: string): string {
|
||||
return addDays(monday, 6);
|
||||
}
|
||||
|
||||
// Returns all Mondays from startDate up to and including the current week's Monday
|
||||
function getWeekMondays(startDate: Date): Date[] {
|
||||
const mondays: Date[] = [];
|
||||
const currentMonday = getMondayOfWeek(new Date());
|
||||
let cursor = new Date(startDate);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= currentMonday) {
|
||||
mondays.push(new Date(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 7);
|
||||
/** Returns the first day of the month for a given date string. */
|
||||
function getMonthStart(dateStr: string): string {
|
||||
return dateStr.slice(0, 7) + '-01';
|
||||
}
|
||||
|
||||
/** Returns the last day of the month for a given date string. */
|
||||
function getMonthEnd(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
// Set to first day of next month then subtract 1 day
|
||||
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
|
||||
return last.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** Total calendar days in the month containing dateStr. */
|
||||
function daysInMonth(dateStr: string): number {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
/** Compare two YYYY-MM-DD strings. Returns negative, 0, or positive. */
|
||||
function cmpDate(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Period enumeration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Period {
|
||||
start: string; // YYYY-MM-DD
|
||||
end: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the period (start + end) that contains the given date.
|
||||
* For weekly: Mon–Sun.
|
||||
* For monthly: 1st–last day of month.
|
||||
*/
|
||||
function getPeriodForDate(dateStr: string, periodType: 'weekly' | 'monthly'): Period {
|
||||
if (periodType === 'weekly') {
|
||||
const monday = getMondayOfWeek(dateStr);
|
||||
return { start: monday, end: getSundayOfWeek(monday) };
|
||||
} else {
|
||||
return { start: getMonthStart(dateStr), end: getMonthEnd(dateStr) };
|
||||
}
|
||||
return mondays;
|
||||
}
|
||||
|
||||
interface WeekBalance {
|
||||
weekStart: string; // ISO date string (Monday)
|
||||
weekEnd: string; // ISO date string (Sunday)
|
||||
/**
|
||||
* Returns the start of the NEXT period after `currentPeriodEnd`.
|
||||
*/
|
||||
function nextPeriodStart(currentPeriodEnd: string, periodType: 'weekly' | 'monthly'): string {
|
||||
if (periodType === 'weekly') {
|
||||
return addDays(currentPeriodEnd, 1); // Monday of next week
|
||||
} else {
|
||||
// First day of next month
|
||||
const d = new Date(currentPeriodEnd + 'T00:00:00Z');
|
||||
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1));
|
||||
return next.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerates all periods from startDate's period through today's period (inclusive).
|
||||
*/
|
||||
function enumeratePeriods(startDate: string, periodType: 'weekly' | 'monthly'): Period[] {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const periods: Period[] = [];
|
||||
|
||||
const firstPeriod = getPeriodForDate(startDate, periodType);
|
||||
let cursor = firstPeriod;
|
||||
|
||||
while (cmpDate(cursor.start, today) <= 0) {
|
||||
periods.push(cursor);
|
||||
const ns = nextPeriodStart(cursor.end, periodType);
|
||||
cursor = getPeriodForDate(ns, periodType);
|
||||
}
|
||||
|
||||
return periods;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Working-day counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Counts working days in [from, to] (both inclusive) matching the given pattern.
|
||||
*/
|
||||
function countWorkingDays(from: string, to: string, workingDays: string[]): number {
|
||||
if (cmpDate(from, to) > 0) return 0;
|
||||
let count = 0;
|
||||
let cur = from;
|
||||
while (cmpDate(cur, to) <= 0) {
|
||||
if (isWorkingDay(cur, workingDays)) count++;
|
||||
cur = addDays(cur, 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pro-ration helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the pro-rated target hours for the first period, applying §5 of the spec.
|
||||
* If startDate falls on the natural first day of the period, no pro-ration occurs.
|
||||
*/
|
||||
function computePeriodTargetHours(
|
||||
period: Period,
|
||||
startDate: string,
|
||||
targetHours: number,
|
||||
periodType: 'weekly' | 'monthly',
|
||||
): number {
|
||||
const naturalStart = period.start;
|
||||
if (cmpDate(startDate, naturalStart) <= 0) {
|
||||
// startDate is at or before the natural period start — no pro-ration needed
|
||||
return targetHours;
|
||||
}
|
||||
|
||||
// startDate is inside the period → pro-rate by calendar days
|
||||
const fullDays = periodType === 'weekly' ? 7 : daysInMonth(period.start);
|
||||
const remainingDays = daysBetween(startDate, period.end); // inclusive both ends
|
||||
return (remainingDays / fullDays) * targetHours;
|
||||
}
|
||||
|
||||
/** Calendar days between two dates (both inclusive). */
|
||||
function daysBetween(from: string, to: string): number {
|
||||
const a = new Date(from + 'T00:00:00Z').getTime();
|
||||
const b = new Date(to + 'T00:00:00Z').getTime();
|
||||
return Math.round((b - a) / 86400000) + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PeriodBalance {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
targetHours: number;
|
||||
trackedSeconds: number;
|
||||
targetSeconds: number;
|
||||
correctionHours: number;
|
||||
balanceSeconds: number; // positive = overtime, negative = undertime
|
||||
balanceSeconds: number;
|
||||
isOngoing: boolean;
|
||||
// only when isOngoing = true
|
||||
dailyRateHours?: number;
|
||||
workingDaysInPeriod?: number;
|
||||
elapsedWorkingDays?: number;
|
||||
expectedHours?: number;
|
||||
}
|
||||
|
||||
export interface ClientTargetWithBalance {
|
||||
@@ -48,7 +195,9 @@ export interface ClientTargetWithBalance {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
userId: string;
|
||||
weeklyHours: number;
|
||||
periodType: 'weekly' | 'monthly';
|
||||
targetHours: number;
|
||||
workingDays: string[];
|
||||
startDate: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -59,53 +208,91 @@ export interface ClientTargetWithBalance {
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
totalBalanceSeconds: number; // running total across all weeks
|
||||
currentWeekTrackedSeconds: number;
|
||||
currentWeekTargetSeconds: number;
|
||||
weeks: WeekBalance[];
|
||||
totalBalanceSeconds: number;
|
||||
currentPeriodTrackedSeconds: number;
|
||||
currentPeriodTargetSeconds: number;
|
||||
periods: PeriodBalance[];
|
||||
/** True when an active timer is running for a project belonging to this client. */
|
||||
hasOngoingTimer: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prisma record shape accepted by computeBalance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TargetRecord = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
targetHours: number;
|
||||
periodType: 'WEEKLY' | 'MONTHLY';
|
||||
workingDays: string[];
|
||||
startDate: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
client: { id: string; name: string };
|
||||
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ClientTargetService {
|
||||
async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
|
||||
const targets = await prisma.clientTarget.findMany({
|
||||
where: { userId },
|
||||
where: { userId, deletedAt: null, client: { deletedAt: null } },
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||
},
|
||||
orderBy: { client: { name: 'asc' } },
|
||||
});
|
||||
|
||||
return Promise.all(targets.map(t => this.computeBalance(t)));
|
||||
return Promise.all(targets.map(t => this.computeBalance(t as unknown as TargetRecord)));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.clientTarget.findFirst({
|
||||
where: { id, userId },
|
||||
where: { id, userId, deletedAt: null, client: { deletedAt: null } },
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
|
||||
// Validate startDate is a Monday
|
||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||
const dayOfWeek = startDate.getUTCDay();
|
||||
if (dayOfWeek !== 1) {
|
||||
throw new BadRequestError('startDate must be a Monday');
|
||||
}
|
||||
|
||||
// Ensure the client belongs to this user
|
||||
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId } });
|
||||
// Ensure the client belongs to this user and is not soft-deleted
|
||||
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
|
||||
if (!client) {
|
||||
throw new NotFoundError('Client not found');
|
||||
}
|
||||
|
||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||
const periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY';
|
||||
|
||||
// Check for existing target (unique per user+client)
|
||||
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
|
||||
if (existing) {
|
||||
if (existing.deletedAt !== null) {
|
||||
// Reactivate the soft-deleted target with the new settings
|
||||
const reactivated = await prisma.clientTarget.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
deletedAt: null,
|
||||
targetHours: data.targetHours,
|
||||
periodType,
|
||||
workingDays: data.workingDays,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
return this.computeBalance(reactivated as unknown as TargetRecord);
|
||||
}
|
||||
throw new BadRequestError('A target already exists for this client. Delete the existing one first or update it.');
|
||||
}
|
||||
|
||||
@@ -113,52 +300,55 @@ export class ClientTargetService {
|
||||
data: {
|
||||
userId,
|
||||
clientId: data.clientId,
|
||||
weeklyHours: data.weeklyHours,
|
||||
targetHours: data.targetHours,
|
||||
periodType,
|
||||
workingDays: data.workingDays,
|
||||
startDate,
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.computeBalance(target);
|
||||
return this.computeBalance(target as unknown as TargetRecord);
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) throw new NotFoundError('Client target not found');
|
||||
|
||||
const updateData: { weeklyHours?: number; startDate?: Date } = {};
|
||||
const updateData: {
|
||||
targetHours?: number;
|
||||
periodType?: 'WEEKLY' | 'MONTHLY';
|
||||
workingDays?: string[];
|
||||
startDate?: Date;
|
||||
} = {};
|
||||
|
||||
if (data.weeklyHours !== undefined) {
|
||||
updateData.weeklyHours = data.weeklyHours;
|
||||
}
|
||||
|
||||
if (data.startDate !== undefined) {
|
||||
const startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||
if (startDate.getUTCDay() !== 1) {
|
||||
throw new BadRequestError('startDate must be a Monday');
|
||||
}
|
||||
updateData.startDate = startDate;
|
||||
}
|
||||
if (data.targetHours !== undefined) updateData.targetHours = data.targetHours;
|
||||
if (data.periodType !== undefined) updateData.periodType = data.periodType.toUpperCase() as 'WEEKLY' | 'MONTHLY';
|
||||
if (data.workingDays !== undefined) updateData.workingDays = data.workingDays;
|
||||
if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate + 'T00:00:00Z');
|
||||
|
||||
const updated = await prisma.clientTarget.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
client: { select: { id: true, name: true } },
|
||||
corrections: { orderBy: { date: 'asc' } },
|
||||
corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return this.computeBalance(updated);
|
||||
return this.computeBalance(updated as unknown as TargetRecord);
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const existing = await this.findById(id, userId);
|
||||
if (!existing) throw new NotFoundError('Client target not found');
|
||||
await prisma.clientTarget.delete({ where: { id } });
|
||||
await prisma.clientTarget.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
|
||||
@@ -188,99 +378,221 @@ export class ClientTargetService {
|
||||
if (!target) throw new NotFoundError('Client target not found');
|
||||
|
||||
const correction = await prisma.balanceCorrection.findFirst({
|
||||
where: { id: correctionId, clientTargetId: targetId },
|
||||
where: { id: correctionId, clientTargetId: targetId, deletedAt: null },
|
||||
});
|
||||
if (!correction) throw new NotFoundError('Correction not found');
|
||||
|
||||
await prisma.balanceCorrection.delete({ where: { id: correctionId } });
|
||||
await prisma.balanceCorrection.update({
|
||||
where: { id: correctionId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async computeBalance(target: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
weeklyHours: number;
|
||||
startDate: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
client: { id: string; name: string };
|
||||
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
||||
}): Promise<ClientTargetWithBalance> {
|
||||
const mondays = getWeekMondays(target.startDate);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Balance computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (mondays.length === 0) {
|
||||
return this.emptyBalance(target);
|
||||
private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
|
||||
const startDateStr = target.startDate.toISOString().split('T')[0];
|
||||
const periodType = target.periodType.toLowerCase() as 'weekly' | 'monthly';
|
||||
const workingDays = target.workingDays;
|
||||
|
||||
const periods = enumeratePeriods(startDateStr, periodType);
|
||||
|
||||
if (periods.length === 0) {
|
||||
return this.emptyBalance(target, periodType);
|
||||
}
|
||||
|
||||
// Fetch all tracked time for this user on this client's projects in one query
|
||||
// covering startDate to end of current week
|
||||
const periodStart = mondays[0];
|
||||
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
|
||||
const overallStart = periods[0].start;
|
||||
const overallEnd = periods[periods.length - 1].end;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
type TrackedRow = { week_start: Date; tracked_seconds: bigint };
|
||||
// Fetch active timer for this user (if any) and check if it belongs to this client
|
||||
const ongoingTimer = await prisma.ongoingTimer.findUnique({
|
||||
where: { userId: target.userId },
|
||||
include: { project: { select: { clientId: true } } },
|
||||
});
|
||||
|
||||
const rows = await prisma.$queryRaw<TrackedRow[]>(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
|
||||
FROM time_entries te
|
||||
JOIN projects p ON p.id = te.project_id
|
||||
WHERE te.user_id = ${target.userId}
|
||||
AND p.client_id = ${target.clientId}
|
||||
AND te.start_time >= ${periodStart}
|
||||
AND te.start_time <= ${periodEnd}
|
||||
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
||||
`);
|
||||
// Elapsed seconds from the active timer attributed to this client target.
|
||||
// We only count it if the timer has a project assigned and that project
|
||||
// belongs to the same client as this target.
|
||||
let ongoingTimerSeconds = 0;
|
||||
let ongoingTimerPeriodStart: string | null = null;
|
||||
|
||||
// Index tracked seconds by week start (ISO Monday string)
|
||||
const trackedByWeek = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week)
|
||||
const monday = getMondayOfWeek(new Date(row.week_start));
|
||||
const key = monday.toISOString().split('T')[0];
|
||||
trackedByWeek.set(key, Number(row.tracked_seconds));
|
||||
if (
|
||||
ongoingTimer &&
|
||||
ongoingTimer.projectId !== null &&
|
||||
ongoingTimer.project?.clientId === target.clientId
|
||||
) {
|
||||
ongoingTimerSeconds = Math.floor(
|
||||
(Date.now() - ongoingTimer.startTime.getTime()) / 1000,
|
||||
);
|
||||
// Determine which period the timer's start time falls into
|
||||
const timerDateStr = ongoingTimer.startTime.toISOString().split('T')[0];
|
||||
const timerPeriod = getPeriodForDate(timerDateStr, periodType);
|
||||
ongoingTimerPeriodStart = timerPeriod.start;
|
||||
}
|
||||
|
||||
// Index corrections by week
|
||||
const correctionsByWeek = new Map<string, number>();
|
||||
// Fetch all time tracked for this client across the full range in one query
|
||||
type TrackedRow = { period_start: string; tracked_seconds: bigint };
|
||||
|
||||
let trackedRows: TrackedRow[];
|
||||
if (periodType === 'weekly') {
|
||||
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
TO_CHAR(
|
||||
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'),
|
||||
'YYYY-MM-DD'
|
||||
) AS period_start,
|
||||
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}
|
||||
AND p.client_id = ${target.clientId}
|
||||
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
|
||||
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
|
||||
AND te.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
||||
`);
|
||||
} else {
|
||||
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
TO_CHAR(
|
||||
DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
|
||||
'YYYY-MM-DD'
|
||||
) AS period_start,
|
||||
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}
|
||||
AND p.client_id = ${target.clientId}
|
||||
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
|
||||
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
|
||||
AND te.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC')
|
||||
`);
|
||||
}
|
||||
|
||||
// Map tracked seconds by period start date string
|
||||
const trackedByPeriod = new Map<string, number>();
|
||||
for (const row of trackedRows) {
|
||||
// Normalise: for weekly, Postgres DATE_TRUNC('week') already gives Monday
|
||||
const key = typeof row.period_start === 'string'
|
||||
? row.period_start
|
||||
: (row.period_start as Date).toISOString().split('T')[0];
|
||||
trackedByPeriod.set(key, Number(row.tracked_seconds));
|
||||
}
|
||||
|
||||
// Index corrections by period start date
|
||||
const correctionsByPeriod = new Map<string, number>();
|
||||
for (const c of target.corrections) {
|
||||
const monday = getMondayOfWeek(new Date(c.date));
|
||||
const key = monday.toISOString().split('T')[0];
|
||||
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours);
|
||||
const corrDateStr = c.date.toISOString().split('T')[0];
|
||||
const period = getPeriodForDate(corrDateStr, periodType);
|
||||
const key = period.start;
|
||||
correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours);
|
||||
}
|
||||
|
||||
const targetSecondsPerWeek = target.weeklyHours * 3600;
|
||||
const weeks: WeekBalance[] = [];
|
||||
const periodBalances: PeriodBalance[] = [];
|
||||
let totalBalanceSeconds = 0;
|
||||
const isFirstPeriod = (i: number) => i === 0;
|
||||
|
||||
for (let i = 0; i < periods.length; i++) {
|
||||
const period = periods[i];
|
||||
|
||||
// Effective start for this period (clamped to startDate for first period)
|
||||
const effectiveStart = isFirstPeriod(i) && cmpDate(startDateStr, period.start) > 0
|
||||
? startDateStr
|
||||
: period.start;
|
||||
|
||||
// Period target hours (with possible pro-ration on the first period)
|
||||
const periodTargetHours = isFirstPeriod(i)
|
||||
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
|
||||
: target.targetHours;
|
||||
|
||||
// Add ongoing timer seconds to the period it started in (if it belongs to this client)
|
||||
const timerContribution =
|
||||
ongoingTimerPeriodStart !== null && period.start === ongoingTimerPeriodStart
|
||||
? ongoingTimerSeconds
|
||||
: 0;
|
||||
|
||||
const trackedSeconds = (trackedByPeriod.get(period.start) ?? 0) + timerContribution;
|
||||
const correctionHours = correctionsByPeriod.get(period.start) ?? 0;
|
||||
|
||||
const isOngoing = cmpDate(period.start, today) <= 0 && cmpDate(today, period.end) <= 0;
|
||||
|
||||
let balanceSeconds: number;
|
||||
let extra: Partial<PeriodBalance> = {};
|
||||
|
||||
if (isOngoing) {
|
||||
// §6: ongoing period — expected hours based on elapsed working days
|
||||
const workingDaysInPeriod = countWorkingDays(effectiveStart, period.end, workingDays);
|
||||
const dailyRateHours = workingDaysInPeriod > 0 ? periodTargetHours / workingDaysInPeriod : 0;
|
||||
|
||||
const elapsedEnd = today < period.end ? today : period.end;
|
||||
const elapsedWorkingDays = countWorkingDays(effectiveStart, elapsedEnd, workingDays);
|
||||
const expectedHours = elapsedWorkingDays * dailyRateHours;
|
||||
|
||||
// Only count corrections up to and including today — future corrections
|
||||
// within the ongoing period must not be counted until those days have elapsed,
|
||||
// otherwise a +8h correction for tomorrow inflates the balance immediately.
|
||||
const correctionHoursToDate = target.corrections.reduce((sum, c) => {
|
||||
const d = c.date.toISOString().split('T')[0];
|
||||
if (cmpDate(d, effectiveStart) >= 0 && cmpDate(d, today) <= 0) {
|
||||
return sum + c.hours;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
balanceSeconds = Math.round(
|
||||
(trackedSeconds + correctionHoursToDate * 3600) - expectedHours * 3600,
|
||||
);
|
||||
|
||||
extra = {
|
||||
dailyRateHours,
|
||||
workingDaysInPeriod,
|
||||
elapsedWorkingDays,
|
||||
expectedHours,
|
||||
};
|
||||
} else {
|
||||
// §4: completed period — simple formula
|
||||
balanceSeconds = Math.round(
|
||||
(trackedSeconds + correctionHours * 3600) - periodTargetHours * 3600,
|
||||
);
|
||||
}
|
||||
|
||||
for (const monday of mondays) {
|
||||
const key = monday.toISOString().split('T')[0];
|
||||
const sunday = getSundayOfWeek(monday);
|
||||
const trackedSeconds = trackedByWeek.get(key) ?? 0;
|
||||
const correctionHours = correctionsByWeek.get(key) ?? 0;
|
||||
const effectiveTargetSeconds = targetSecondsPerWeek - correctionHours * 3600;
|
||||
const balanceSeconds = trackedSeconds - effectiveTargetSeconds;
|
||||
totalBalanceSeconds += balanceSeconds;
|
||||
|
||||
weeks.push({
|
||||
weekStart: key,
|
||||
weekEnd: sunday.toISOString().split('T')[0],
|
||||
periodBalances.push({
|
||||
periodStart: period.start,
|
||||
periodEnd: period.end,
|
||||
targetHours: periodTargetHours,
|
||||
trackedSeconds,
|
||||
targetSeconds: effectiveTargetSeconds,
|
||||
correctionHours,
|
||||
balanceSeconds,
|
||||
isOngoing,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const currentWeek = weeks[weeks.length - 1];
|
||||
const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.length - 1];
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
clientId: target.clientId,
|
||||
clientName: target.client.name,
|
||||
userId: target.userId,
|
||||
weeklyHours: target.weeklyHours,
|
||||
startDate: target.startDate.toISOString().split('T')[0],
|
||||
periodType,
|
||||
targetHours: target.targetHours,
|
||||
workingDays,
|
||||
startDate: startDateStr,
|
||||
createdAt: target.createdAt.toISOString(),
|
||||
updatedAt: target.updatedAt.toISOString(),
|
||||
corrections: target.corrections.map(c => ({
|
||||
@@ -291,37 +603,33 @@ export class ClientTargetService {
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
totalBalanceSeconds,
|
||||
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0,
|
||||
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek,
|
||||
weeks,
|
||||
currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0,
|
||||
currentPeriodTargetSeconds: currentPeriod
|
||||
? Math.round(currentPeriod.targetHours * 3600)
|
||||
: Math.round(target.targetHours * 3600),
|
||||
periods: periodBalances,
|
||||
hasOngoingTimer: ongoingTimerSeconds > 0,
|
||||
};
|
||||
}
|
||||
|
||||
private emptyBalance(target: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
weeklyHours: number;
|
||||
startDate: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
client: { id: string; name: string };
|
||||
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
|
||||
}): ClientTargetWithBalance {
|
||||
private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance {
|
||||
return {
|
||||
id: target.id,
|
||||
clientId: target.clientId,
|
||||
clientName: target.client.name,
|
||||
userId: target.userId,
|
||||
weeklyHours: target.weeklyHours,
|
||||
periodType,
|
||||
targetHours: target.targetHours,
|
||||
workingDays: target.workingDays,
|
||||
startDate: target.startDate.toISOString().split('T')[0],
|
||||
createdAt: target.createdAt.toISOString(),
|
||||
updatedAt: target.updatedAt.toISOString(),
|
||||
corrections: [],
|
||||
totalBalanceSeconds: 0,
|
||||
currentWeekTrackedSeconds: 0,
|
||||
currentWeekTargetSeconds: target.weeklyHours * 3600,
|
||||
weeks: [],
|
||||
currentPeriodTrackedSeconds: 0,
|
||||
currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
|
||||
periods: [],
|
||||
hasOngoingTimer: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export class ProjectService {
|
||||
return prisma.project.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
client: { deletedAt: null },
|
||||
...(clientId && { clientId }),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
@@ -23,7 +25,12 @@ export class ProjectService {
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.project.findFirst({
|
||||
where: { id, userId },
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
client: { deletedAt: null },
|
||||
},
|
||||
include: {
|
||||
client: {
|
||||
select: {
|
||||
@@ -36,9 +43,9 @@ export class ProjectService {
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateProjectInput) {
|
||||
// Verify the client belongs to the user
|
||||
// Verify the client belongs to the user and is not soft-deleted
|
||||
const client = await prisma.client.findFirst({
|
||||
where: { id: data.clientId, userId },
|
||||
where: { id: data.clientId, userId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
@@ -70,10 +77,10 @@ export class ProjectService {
|
||||
throw new NotFoundError("Project not found");
|
||||
}
|
||||
|
||||
// If clientId is being updated, verify it belongs to the user
|
||||
// If clientId is being updated, verify it belongs to the user and is not soft-deleted
|
||||
if (data.clientId) {
|
||||
const client = await prisma.client.findFirst({
|
||||
where: { id: data.clientId, userId },
|
||||
where: { id: data.clientId, userId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
@@ -108,8 +115,9 @@ export class ProjectService {
|
||||
throw new NotFoundError("Project not found");
|
||||
}
|
||||
|
||||
await prisma.project.delete({
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,15 @@ 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
|
||||
JOIN clients c ON c.id = p.client_id
|
||||
WHERE te.user_id = ${userId}
|
||||
AND te.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
AND c.deleted_at IS NULL
|
||||
${filterClause}
|
||||
GROUP BY p.id, p.name, p.color
|
||||
ORDER BY total_seconds DESC
|
||||
@@ -63,12 +67,15 @@ 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
|
||||
JOIN clients c ON c.id = p.client_id
|
||||
WHERE te.user_id = ${userId}
|
||||
AND te.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
AND c.deleted_at IS NULL
|
||||
${filterClause}
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY total_seconds DESC
|
||||
@@ -77,11 +84,15 @@ 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
|
||||
JOIN clients c ON c.id = p.client_id
|
||||
WHERE te.user_id = ${userId}
|
||||
AND te.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
AND c.deleted_at IS NULL
|
||||
${filterClause}
|
||||
`,
|
||||
),
|
||||
@@ -125,10 +136,11 @@ export class TimeEntryService {
|
||||
|
||||
const where: {
|
||||
userId: string;
|
||||
deletedAt: null;
|
||||
startTime?: { gte?: Date; lte?: Date };
|
||||
projectId?: string;
|
||||
project?: { clientId?: string };
|
||||
} = { userId };
|
||||
project?: { deletedAt: null; clientId?: string; client: { deletedAt: null } };
|
||||
} = { userId, deletedAt: null };
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.startTime = {};
|
||||
@@ -140,9 +152,13 @@ export class TimeEntryService {
|
||||
where.projectId = projectId;
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
where.project = { clientId };
|
||||
}
|
||||
// Always filter out entries whose project or client is soft-deleted,
|
||||
// merging the optional clientId filter into the project relation filter.
|
||||
where.project = {
|
||||
deletedAt: null,
|
||||
client: { deletedAt: null },
|
||||
...(clientId && { clientId }),
|
||||
};
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.timeEntry.findMany({
|
||||
@@ -182,7 +198,12 @@ export class TimeEntryService {
|
||||
|
||||
async findById(id: string, userId: string) {
|
||||
return prisma.timeEntry.findFirst({
|
||||
where: { id, userId },
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
project: { deletedAt: null, client: { deletedAt: null } },
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
@@ -204,15 +225,22 @@ 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");
|
||||
}
|
||||
|
||||
// Verify the project belongs to the user
|
||||
// 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 and is not soft-deleted (nor its client)
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: data.projectId, userId },
|
||||
where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
@@ -235,6 +263,7 @@ export class TimeEntryService {
|
||||
data: {
|
||||
startTime,
|
||||
endTime,
|
||||
breakMinutes,
|
||||
description: data.description,
|
||||
userId,
|
||||
projectId: data.projectId,
|
||||
@@ -267,16 +296,23 @@ 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");
|
||||
}
|
||||
|
||||
// If project changed, verify it belongs to the user
|
||||
// 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 and is not soft-deleted
|
||||
if (data.projectId && data.projectId !== entry.projectId) {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id: data.projectId, userId },
|
||||
where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
@@ -302,6 +338,7 @@ export class TimeEntryService {
|
||||
data: {
|
||||
startTime,
|
||||
endTime,
|
||||
breakMinutes,
|
||||
description: data.description,
|
||||
projectId: data.projectId,
|
||||
},
|
||||
@@ -329,8 +366,9 @@ export class TimeEntryService {
|
||||
throw new NotFoundError("Time entry not found");
|
||||
}
|
||||
|
||||
await prisma.timeEntry.delete({
|
||||
await prisma.timeEntry.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
@@ -79,13 +82,17 @@ export interface StopTimerInput {
|
||||
|
||||
export interface CreateClientTargetInput {
|
||||
clientId: string;
|
||||
weeklyHours: number;
|
||||
startDate: string; // YYYY-MM-DD, always a Monday
|
||||
targetHours: number;
|
||||
periodType: 'weekly' | 'monthly';
|
||||
workingDays: string[]; // e.g. ["MON","WED","FRI"]
|
||||
startDate: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface UpdateClientTargetInput {
|
||||
weeklyHours?: number;
|
||||
startDate?: string; // YYYY-MM-DD, always a Monday
|
||||
targetHours?: number;
|
||||
periodType?: 'weekly' | 'monthly';
|
||||
workingDays?: string[];
|
||||
startDate?: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface CreateCorrectionInput {
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function hasOverlappingEntries(
|
||||
const count = await prisma.timeEntry.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||
// An entry overlaps when it starts before our end AND ends after our start.
|
||||
startTime: { lt: endTime },
|
||||
|
||||
285
docs/client-targets-v2-requirements.md
Normal file
285
docs/client-targets-v2-requirements.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Client Targets v2 — Feature Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the requirements for the second iteration of the Client Targets feature. The main additions are:
|
||||
|
||||
- Targets can be set on a **weekly or monthly** period.
|
||||
- Each target defines a **fixed weekly working-day pattern** (e.g. Mon + Wed).
|
||||
- The balance for the **current period** is calculated proportionally based on elapsed working days, so the user can see at any point in time whether they are ahead or behind.
|
||||
- The **start date** can be any calendar day (no longer restricted to Mondays).
|
||||
- Manual **balance corrections** are preserved and continue to work as before.
|
||||
|
||||
---
|
||||
|
||||
## 1. Target Configuration
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|---|---|---|
|
||||
| `periodType` | `WEEKLY \| MONTHLY` | Required |
|
||||
| `weeklyOrMonthlyHours` | positive float, ≤ 168 | Required; represents hours per week or per month |
|
||||
| `workingDays` | array of day names | At least one of `MON TUE WED THU FRI SAT SUN`; fixed repeating pattern |
|
||||
| `startDate` | `YYYY-MM-DD` | Any calendar day; no longer restricted to Mondays |
|
||||
| `clientId` | UUID | Must belong to the authenticated user |
|
||||
|
||||
**One active target per client** — the unique `(userId, clientId)` constraint is preserved. To change period type, hours, or working days the user creates a new target with a new `startDate`; the old target is soft-deleted. History from the old target is retained as-is and is no longer recalculated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Period Definitions
|
||||
|
||||
| `periodType` | Period start | Period end |
|
||||
|---|---|---|
|
||||
| `WEEKLY` | Monday 00:00 of the calendar week | Sunday 23:59 of that same calendar week |
|
||||
| `MONTHLY` | 1st of the calendar month 00:00 | Last day of the calendar month 23:59 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Balance Calculation — Overview
|
||||
|
||||
The total balance is the **sum of individual period balances** from the period containing `startDate` up to and including the **current period** (the period that contains today).
|
||||
|
||||
Each period is classified as either **completed** or **ongoing**.
|
||||
|
||||
```
|
||||
total_balance_seconds = SUM( balance_seconds ) over all periods
|
||||
```
|
||||
|
||||
Positive = overtime. Negative = undertime.
|
||||
|
||||
---
|
||||
|
||||
## 4. Completed Period Balance
|
||||
|
||||
A period is **completed** when its end date is strictly before today.
|
||||
|
||||
```
|
||||
balance = tracked_hours + correction_hours - period_target_hours
|
||||
```
|
||||
|
||||
- `period_target_hours` — see §5 (pro-ration) for the first period; full `weeklyOrMonthlyHours` for all subsequent periods.
|
||||
- `tracked_hours` — sum of all time entries for this client whose date falls within `[period_start, period_end]`.
|
||||
- `correction_hours` — sum of manual corrections whose `date` falls within `[period_start, period_end]`.
|
||||
|
||||
No working-day logic is applied to completed periods. The target is simply the (optionally pro-rated) hours for that period.
|
||||
|
||||
---
|
||||
|
||||
## 5. First Period Pro-ration
|
||||
|
||||
If `startDate` does not fall on the natural first day of a period (Monday for weekly, 1st for monthly), the target hours for that first period are pro-rated by calendar days.
|
||||
|
||||
### Monthly
|
||||
|
||||
```
|
||||
full_period_days = total calendar days in that month
|
||||
remaining_days = (last day of month) − startDate + 1 // inclusive
|
||||
period_target_hours = (remaining_days / full_period_days) × weeklyOrMonthlyHours
|
||||
```
|
||||
|
||||
**Example:** startDate = Jan 25, target = 40 h/month, January has 31 days.
|
||||
`remaining_days = 7`, `period_target_hours = (7 / 31) × 40 = 9.032 h`
|
||||
|
||||
### Weekly
|
||||
|
||||
```
|
||||
full_period_days = 7
|
||||
remaining_days = Sunday of that calendar week − startDate + 1 // inclusive
|
||||
period_target_hours = (remaining_days / 7) × weeklyOrMonthlyHours
|
||||
```
|
||||
|
||||
**Example:** startDate = Wednesday, target = 40 h/week.
|
||||
`remaining_days = 5 (Wed–Sun)`, `period_target_hours = (5 / 7) × 40 = 28.571 h`
|
||||
|
||||
All periods after the first use the full `weeklyOrMonthlyHours`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ongoing Period Balance (Current Period)
|
||||
|
||||
The current period is **ongoing** when today falls within it. The balance reflects how the user is doing *so far* — future working days within the current period are not considered.
|
||||
|
||||
### Step 1 — Period target hours
|
||||
|
||||
Apply §5 if this is the first period; otherwise use full `weeklyOrMonthlyHours`.
|
||||
|
||||
### Step 2 — Daily rate
|
||||
|
||||
```
|
||||
working_days_in_period = COUNT of days in [period_start, period_end]
|
||||
that match the working day pattern
|
||||
daily_rate_hours = period_target_hours / working_days_in_period
|
||||
```
|
||||
|
||||
The rate is fixed at the start of the period and does not change as time passes.
|
||||
|
||||
### Step 3 — Elapsed working days
|
||||
|
||||
```
|
||||
elapsed_working_days = COUNT of days in [period_start, TODAY] (both inclusive)
|
||||
that match the working day pattern
|
||||
```
|
||||
|
||||
- If today matches the working day pattern, it is counted as a **full** elapsed working day.
|
||||
- If today does not match the working day pattern, it is not counted.
|
||||
|
||||
### Step 4 — Expected hours so far
|
||||
|
||||
```
|
||||
expected_hours = elapsed_working_days × daily_rate_hours
|
||||
```
|
||||
|
||||
### Step 5 — Balance
|
||||
|
||||
```
|
||||
tracked_hours = SUM of time entries for this client in [period_start, today]
|
||||
correction_hours = SUM of manual corrections whose date ∈ [period_start, today]
|
||||
balance = tracked_hours + correction_hours − expected_hours
|
||||
```
|
||||
|
||||
### Worked example
|
||||
|
||||
> Target: 40 h/month. Working days: Mon + Wed.
|
||||
> Current month has 4 Mondays and 4 Wednesdays → `working_days_in_period = 8`.
|
||||
> `daily_rate_hours = 40 / 8 = 5 h`.
|
||||
> 3 working days have elapsed → `expected_hours = 15 h`.
|
||||
> Tracked so far: 13 h, no corrections.
|
||||
> `balance = 13 − 15 = −2 h` (2 hours behind).
|
||||
|
||||
---
|
||||
|
||||
## 7. Manual Balance Corrections
|
||||
|
||||
| Field | Type | Constraints |
|
||||
|---|---|---|
|
||||
| `date` | `YYYY-MM-DD` | Must be ≥ `startDate`; not more than one period in the future |
|
||||
| `hours` | signed float | Positive = extra credit (reduces deficit). Negative = reduces tracked credit |
|
||||
| `description` | string | Optional, max 255 chars |
|
||||
|
||||
- The system automatically assigns a correction to the period that contains its `date`.
|
||||
- Corrections in **completed periods** are included in the completed period formula (§4).
|
||||
- Corrections in the **ongoing period** are included in the ongoing balance formula (§6).
|
||||
- Corrections in a **future period** (not yet started) are stored and will be applied when that period becomes active.
|
||||
- A correction whose `date` is before `startDate` is rejected with a validation error.
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge Cases
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| `startDate` = 1st of month / Monday | No pro-ration; `period_target_hours = weeklyOrMonthlyHours` |
|
||||
| `startDate` = last day of period | `remaining_days = 1`; target is heavily reduced (e.g. 1/31 × hours) |
|
||||
| Working pattern has no matches in the partial first period | `elapsed_working_days = 0`; `expected_hours = 0`; balance = `tracked + corrections` |
|
||||
| Current period has zero elapsed working days | `expected_hours = 0`; balance = `tracked + corrections` (cannot divide by zero — guard required) |
|
||||
| `working_days_in_period = 0` | Impossible by validation (at least one day required), but system must guard: treat as `daily_rate_hours = 0` |
|
||||
| Today is not a working day | `elapsed_working_days` does not include today |
|
||||
| Correction date before `startDate` | Rejected with a validation error |
|
||||
| Correction date in future period | Accepted and stored; applied when that period is ongoing or completed |
|
||||
| User changes working days or period type | Must create a new target with a new `startDate`; old target history is frozen |
|
||||
| Two periods with the same client exist (old soft-deleted, new active) | Only the active target's periods contribute to the displayed balance |
|
||||
| A month with only partial working day coverage (e.g. all Mondays are public holidays) | No automatic holiday handling; user adds manual corrections to compensate |
|
||||
|
||||
---
|
||||
|
||||
## 9. Data Model Changes
|
||||
|
||||
### `ClientTarget` table — additions / changes
|
||||
|
||||
| Column | Change | Notes |
|
||||
|---|---|---|
|
||||
| `period_type` | **Add** | Enum: `WEEKLY`, `MONTHLY` |
|
||||
| `working_days` | **Add** | Array/bitmask of day names: `MON TUE WED THU FRI SAT SUN` |
|
||||
| `start_date` | **Modify** | Remove "must be Monday" validation constraint |
|
||||
| `weekly_hours` | **Rename** | → `target_hours` (represents hours per week or per month depending on `period_type`) |
|
||||
|
||||
### `BalanceCorrection` table — no structural changes
|
||||
|
||||
Date-to-period assignment is computed at query time, not stored.
|
||||
|
||||
---
|
||||
|
||||
## 10. API Changes
|
||||
|
||||
### `ClientTargetWithBalance` response shape
|
||||
|
||||
```typescript
|
||||
interface ClientTargetWithBalance {
|
||||
id: string
|
||||
clientId: string
|
||||
clientName: string
|
||||
userId: string
|
||||
periodType: "weekly" | "monthly"
|
||||
targetHours: number // renamed from weeklyHours
|
||||
workingDays: string[] // e.g. ["MON", "WED"]
|
||||
startDate: string // YYYY-MM-DD
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
corrections: BalanceCorrection[]
|
||||
totalBalanceSeconds: number // running total across all periods
|
||||
currentPeriodTrackedSeconds: number // replaces currentWeekTrackedSeconds
|
||||
currentPeriodTargetSeconds: number // replaces currentWeekTargetSeconds
|
||||
periods: PeriodBalance[] // replaces weeks[]
|
||||
}
|
||||
|
||||
interface PeriodBalance {
|
||||
periodStart: string // YYYY-MM-DD (Monday or 1st of month)
|
||||
periodEnd: string // YYYY-MM-DD (Sunday or last of month)
|
||||
targetHours: number // pro-rated for first period
|
||||
trackedSeconds: number
|
||||
correctionHours: number
|
||||
balanceSeconds: number
|
||||
isOngoing: boolean
|
||||
// only present when isOngoing = true
|
||||
dailyRateHours?: number
|
||||
workingDaysInPeriod?: number
|
||||
elapsedWorkingDays?: number
|
||||
expectedHours?: number
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoint changes
|
||||
|
||||
| Method | Path | Change |
|
||||
|---|---|---|
|
||||
| `POST /client-targets` | Create | Accepts `periodType`, `workingDays`, `targetHours`; `startDate` unconstrained |
|
||||
| `PUT /client-targets/:id` | Update | Accepts same new fields |
|
||||
| `GET /client-targets` | List | Returns updated `ClientTargetWithBalance` shape |
|
||||
| `POST /client-targets/:id/corrections` | Add correction | No change to signature |
|
||||
| `DELETE /client-targets/:id/corrections/:corrId` | Delete correction | No change |
|
||||
|
||||
### Zod schema changes
|
||||
|
||||
- `CreateClientTargetSchema` / `UpdateClientTargetSchema`:
|
||||
- Add `periodType: z.enum(["weekly", "monthly"])`
|
||||
- Add `workingDays: z.array(z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"])).min(1)`
|
||||
- Rename `weeklyHours` → `targetHours`
|
||||
- Remove Monday-only regex constraint from `startDate`
|
||||
|
||||
---
|
||||
|
||||
## 11. Frontend Changes
|
||||
|
||||
### Types (`frontend/src/types/index.ts`)
|
||||
- `ClientTargetWithBalance` — add `periodType`, `workingDays`, `targetHours`; replace `weeks` → `periods: PeriodBalance[]`; replace `currentWeek*` → `currentPeriod*`
|
||||
- Add `PeriodBalance` interface
|
||||
- `CreateClientTargetInput` / `UpdateClientTargetInput` — same field additions
|
||||
|
||||
### Hook (`frontend/src/hooks/useClientTargets.ts`)
|
||||
- No structural changes; mutations pass through new fields
|
||||
|
||||
### API client (`frontend/src/api/clientTargets.ts`)
|
||||
- No structural changes; payload shapes updated
|
||||
|
||||
### `ClientsPage` — `ClientTargetPanel`
|
||||
- Working day selector (checkboxes: Mon–Sun, at least one required)
|
||||
- Period type selector (Weekly / Monthly)
|
||||
- Label for hours input updates dynamically: "Hours/week" or "Hours/month"
|
||||
- Start date picker: free date input (no week-picker)
|
||||
- Balance display: label changes from "this week" to "this week" or "this month" based on `periodType`
|
||||
- Expanded period list replaces the expanded week list
|
||||
|
||||
### `DashboardPage`
|
||||
- "Weekly Targets" widget renamed to "Targets"
|
||||
- "This week" label becomes "This week" / "This month" dynamically
|
||||
- `currentWeek*` fields replaced with `currentPeriod*`
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TimeTracker</title>
|
||||
</head>
|
||||
|
||||
23
frontend/public/favicon.svg
Normal file
23
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||
<svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
|
||||
<linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
<path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 96 0 L 320 0 C 373.019348 0 416 42.980652 416 96 L 416 320 C 416 373.019348 373.019348 416 320 416 L 96 416 C 42.980667 416 0 373.019348 0 320 L 0 96 C 0 42.980652 42.980667 0 96 0 Z"/>
|
||||
<g id="Group">
|
||||
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
|
||||
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 176 40 L 240 40"/>
|
||||
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 304 128 L 328 104"/>
|
||||
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
|
||||
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 340 224 C 340 296.901581 280.901581 356 208 356 C 135.098419 356 76 296.901581 76 224 C 76 151.098419 135.098419 92 208 92 C 280.901581 92 340 151.098419 340 224 Z"/>
|
||||
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
|
||||
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
|
||||
<g id="g1" opacity="0.6">
|
||||
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 208 308 L 208 324"/>
|
||||
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
|
||||
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
33
frontend/public/icon.svg
Normal file
33
frontend/public/icon.svg
Normal file
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
<filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="16"/>
|
||||
<feOffset dx="0" dy="12" result="offsetblur"/>
|
||||
<feFlood flood-color="#4f46e5" flood-opacity="0.4"/>
|
||||
<feComposite in2="offsetblur" operator="in"/>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<path id="Path" fill="url(#linearGradient1)" stroke="none" filter="url(#filter1)" d="M 144 48 L 368 48 C 421.019348 48 464 90.980652 464 144 L 464 368 C 464 421.019348 421.019348 464 368 464 L 144 464 C 90.980667 464 48 421.019348 48 368 L 48 144 C 48 90.980652 90.980667 48 144 48 Z"/>
|
||||
<g id="Group">
|
||||
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
|
||||
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 224 88 L 288 88"/>
|
||||
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 352 176 L 376 152"/>
|
||||
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
|
||||
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 388 272 C 388 344.901581 328.901581 404 256 404 C 183.098419 404 124 344.901581 124 272 C 124 199.098419 183.098419 140 256 140 C 328.901581 140 388 199.098419 388 272 Z"/>
|
||||
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
|
||||
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
|
||||
<g id="g1" opacity="0.6">
|
||||
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 256 356 L 256 372"/>
|
||||
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
|
||||
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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<OngoingTimer | null> => {
|
||||
const { data } = await apiClient.get<OngoingTimer | null>('/timer');
|
||||
@@ -14,10 +19,8 @@ export const timerApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
update: async (projectId?: string | null): Promise<OngoingTimer> => {
|
||||
const { data } = await apiClient.put<OngoingTimer>('/timer', {
|
||||
projectId,
|
||||
});
|
||||
update: async (payload: UpdateTimerPayload): Promise<OngoingTimer> => {
|
||||
const { data } = await apiClient.put<OngoingTimer>('/timer', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -27,4 +30,8 @@ export const timerApi = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
cancel: async (): Promise<void> => {
|
||||
await apiClient.delete('/timer');
|
||||
},
|
||||
};
|
||||
@@ -47,20 +47,27 @@ export function Navbar() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Clock className="h-8 w-8 text-primary-600" />
|
||||
<NavLink
|
||||
className="flex-shrink-0 flex items-center"
|
||||
to={"/dashboard"}
|
||||
>
|
||||
<img
|
||||
src="/icon.svg"
|
||||
alt="TimeTracker Logo"
|
||||
className="h-8 w-8 drop-shadow-sm"
|
||||
/>
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">
|
||||
TimeTracker
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-4">
|
||||
</NavLink>
|
||||
<div className="hidden sm:ml-8 sm:flex sm:space-x-4 items-center">
|
||||
{/* Main Navigation Items */}
|
||||
{mainNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors h-min ${
|
||||
isActive
|
||||
? "text-primary-600 bg-primary-50"
|
||||
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
|
||||
|
||||
@@ -3,25 +3,35 @@ interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
color: 'blue' | 'green' | 'purple' | 'orange';
|
||||
/** When true, renders a pulsing green dot to signal a live/active state. */
|
||||
indicator?: boolean;
|
||||
}
|
||||
|
||||
const colorClasses: Record<StatCardProps['color'], string> = {
|
||||
const colorClasses: Record<NonNullable<StatCardProps['color']>, string> = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
purple: 'bg-purple-50 text-purple-600',
|
||||
orange: 'bg-orange-50 text-orange-600',
|
||||
};
|
||||
|
||||
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
|
||||
export function StatCard({ icon: Icon, label, value, color, indicator }: StatCardProps) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`p-3 rounded-lg ${colorClasses[color]}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-600">{label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{indicator && (
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
|
||||
title="Timer running"
|
||||
/>
|
||||
)}
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Break (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.breakMinutes ?? 0}
|
||||
onChange={(e) => setFormData({ ...formData, breakMinutes: parseInt(e.target.value) || 0 })}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Play, Square, ChevronDown } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Play, Square, ChevronDown, Pencil, Check, X, Trash2 } from "lucide-react";
|
||||
import { useTimer } from "@/contexts/TimerContext";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||
@@ -27,6 +27,21 @@ function TimerDisplay({ totalSeconds }: { totalSeconds: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Converts a HH:mm string to an ISO datetime, inferring the correct date.
|
||||
* If the resulting time would be in the future, it is assumed to belong to the previous day.
|
||||
*/
|
||||
function timeInputToIso(timeValue: string): string {
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
const now = new Date();
|
||||
const candidate = new Date(now);
|
||||
candidate.setHours(hours, minutes, 0, 0);
|
||||
// If the candidate is in the future, roll back one day
|
||||
if (candidate > now) {
|
||||
candidate.setDate(candidate.getDate() - 1);
|
||||
}
|
||||
return candidate.toISOString();
|
||||
}
|
||||
|
||||
export function TimerWidget() {
|
||||
const {
|
||||
ongoingTimer,
|
||||
@@ -34,12 +49,19 @@ export function TimerWidget() {
|
||||
elapsedSeconds,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
cancelTimer,
|
||||
updateTimerProject,
|
||||
updateTimerStartTime,
|
||||
} = useTimer();
|
||||
const { projects } = useProjects();
|
||||
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Start time editing state
|
||||
const [editingStartTime, setEditingStartTime] = useState(false);
|
||||
const [startTimeInput, setStartTimeInput] = useState("");
|
||||
const startTimeInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleStart = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -58,6 +80,15 @@ export function TimerWidget() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelTimer = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await cancelTimer();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cancel timer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectChange = async (projectId: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -78,6 +109,42 @@ export function TimerWidget() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEditStartTime = () => {
|
||||
if (!ongoingTimer) return;
|
||||
const start = new Date(ongoingTimer.startTime);
|
||||
const hh = start.getHours().toString().padStart(2, "0");
|
||||
const mm = start.getMinutes().toString().padStart(2, "0");
|
||||
setStartTimeInput(`${hh}:${mm}`);
|
||||
setEditingStartTime(true);
|
||||
// Focus the input on next render
|
||||
setTimeout(() => startTimeInputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const handleCancelEditStartTime = () => {
|
||||
setEditingStartTime(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleConfirmStartTime = async () => {
|
||||
if (!startTimeInput) return;
|
||||
setError(null);
|
||||
try {
|
||||
const iso = timeInputToIso(startTimeInput);
|
||||
await updateTimerStartTime(iso);
|
||||
setEditingStartTime(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update start time");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTimeKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleConfirmStartTime();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancelEditStartTime();
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
||||
@@ -95,20 +162,68 @@ export function TimerWidget() {
|
||||
<>
|
||||
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
||||
<div className="flex items-center justify-between w-full sm:contents">
|
||||
{/* Timer Display */}
|
||||
{/* Timer Display + Start Time Editor */}
|
||||
<div className="flex items-center space-x-2 shrink-0">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||
<TimerDisplay totalSeconds={elapsedSeconds} />
|
||||
{editingStartTime ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-gray-500 mr-1">Started at</span>
|
||||
<input
|
||||
ref={startTimeInputRef}
|
||||
type="time"
|
||||
value={startTimeInput}
|
||||
onChange={(e) => setStartTimeInput(e.target.value)}
|
||||
onKeyDown={handleStartTimeKeyDown}
|
||||
className="font-mono text-lg font-bold text-gray-900 border border-primary-400 rounded px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-primary-500 w-28"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleConfirmStartTime()}
|
||||
title="Confirm"
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 rounded"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEditStartTime}
|
||||
title="Cancel"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<TimerDisplay totalSeconds={elapsedSeconds} />
|
||||
<button
|
||||
onClick={handleStartEditStartTime}
|
||||
title="Adjust start time"
|
||||
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stop Button */}
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors shrink-0 sm:order-last"
|
||||
>
|
||||
<Square className="h-5 w-5 fill-current" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
{/* Stop + Cancel Buttons */}
|
||||
<div className="flex items-center space-x-2 shrink-0 sm:order-last">
|
||||
<button
|
||||
onClick={() => void handleCancelTimer()}
|
||||
title="Discard timer"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!ongoingTimer.project}
|
||||
title={!ongoingTimer.project ? "Select a project to stop the timer" : undefined}
|
||||
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
||||
>
|
||||
<Square className="h-5 w-5 fill-current" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Selector — full width on mobile, auto on desktop */}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface TimerContextType {
|
||||
elapsedSeconds: number;
|
||||
startTimer: (projectId?: string) => Promise<void>;
|
||||
updateTimerProject: (projectId?: string | null) => Promise<void>;
|
||||
updateTimerStartTime: (startTime: string) => Promise<void>;
|
||||
cancelTimer: () => Promise<void>;
|
||||
stopTimer: (projectId?: string) => Promise<TimeEntry | null>;
|
||||
}
|
||||
|
||||
@@ -84,6 +86,14 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
// Cancel timer mutation
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: timerApi.cancel,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["ongoingTimer"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Stop timer mutation
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: timerApi.stop,
|
||||
@@ -102,11 +112,22 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const updateTimerProject = useCallback(
|
||||
async (projectId?: string | null) => {
|
||||
await updateMutation.mutateAsync(projectId);
|
||||
await updateMutation.mutateAsync({ projectId });
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const updateTimerStartTime = useCallback(
|
||||
async (startTime: string) => {
|
||||
await updateMutation.mutateAsync({ startTime });
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const cancelTimer = useCallback(async () => {
|
||||
await cancelMutation.mutateAsync();
|
||||
}, [cancelMutation]);
|
||||
|
||||
const stopTimer = useCallback(
|
||||
async (projectId?: string): Promise<TimeEntry | null> => {
|
||||
try {
|
||||
@@ -127,6 +148,8 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
||||
elapsedSeconds,
|
||||
startTimer,
|
||||
updateTimerProject,
|
||||
updateTimerStartTime,
|
||||
cancelTimer,
|
||||
stopTimer,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { clientTargetsApi } from '@/api/clientTargets';
|
||||
import { useTimer } from '@/contexts/TimerContext';
|
||||
import type {
|
||||
CreateClientTargetInput,
|
||||
UpdateClientTargetInput,
|
||||
@@ -8,10 +9,13 @@ import type {
|
||||
|
||||
export function useClientTargets() {
|
||||
const queryClient = useQueryClient();
|
||||
const { ongoingTimer } = useTimer();
|
||||
|
||||
const { data: targets, isLoading, error } = useQuery({
|
||||
queryKey: ['clientTargets'],
|
||||
queryFn: clientTargetsApi.getAll,
|
||||
// Poll every 30 s while a timer is running so the balance stays current
|
||||
refetchInterval: ongoingTimer ? 30_000 : false,
|
||||
});
|
||||
|
||||
const createTarget = useMutation({
|
||||
|
||||
@@ -14,33 +14,10 @@ import type {
|
||||
CreateCorrectionInput,
|
||||
} from '@/types';
|
||||
|
||||
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16"
|
||||
function weekInputToMonday(weekValue: string): string {
|
||||
const [yearStr, weekStr] = weekValue.split('-W');
|
||||
const year = parseInt(yearStr, 10);
|
||||
const week = parseInt(weekStr, 10);
|
||||
// ISO week 1 is the week containing the first Thursday of January
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7
|
||||
const monday = new Date(jan4);
|
||||
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
|
||||
return monday.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input
|
||||
function mondayToWeekInput(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00Z');
|
||||
// ISO week number calculation
|
||||
const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
|
||||
const jan4Day = jan4.getUTCDay() || 7;
|
||||
const firstMonday = new Date(jan4);
|
||||
firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1);
|
||||
const diff = date.getTime() - firstMonday.getTime();
|
||||
const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1;
|
||||
// Handle year boundary: if week > 52 we might be in week 1 of next year
|
||||
const year = date.getUTCFullYear();
|
||||
return `${year}-W${week.toString().padStart(2, '0')}`;
|
||||
}
|
||||
const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] as const;
|
||||
const DAY_LABELS: Record<string, string> = {
|
||||
MON: 'Mon', TUE: 'Tue', WED: 'Wed', THU: 'Thu', FRI: 'Fri', SAT: 'Sat', SUN: 'Sun',
|
||||
};
|
||||
|
||||
function balanceLabel(seconds: number): { text: string; color: string } {
|
||||
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
|
||||
@@ -58,7 +35,12 @@ function ClientTargetPanel({
|
||||
}: {
|
||||
client: Client;
|
||||
target: ClientTargetWithBalance | undefined;
|
||||
onCreated: (weeklyHours: number, startDate: string) => Promise<void>;
|
||||
onCreated: (input: {
|
||||
targetHours: number;
|
||||
periodType: 'weekly' | 'monthly';
|
||||
workingDays: string[];
|
||||
startDate: string;
|
||||
}) => Promise<void>;
|
||||
onDeleted: () => Promise<void>;
|
||||
}) {
|
||||
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
|
||||
@@ -69,7 +51,9 @@ function ClientTargetPanel({
|
||||
|
||||
// Create/edit form state
|
||||
const [formHours, setFormHours] = useState('');
|
||||
const [formWeek, setFormWeek] = useState('');
|
||||
const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly');
|
||||
const [formWorkingDays, setFormWorkingDays] = useState<string[]>(['MON', 'TUE', 'WED', 'THU', 'FRI']);
|
||||
const [formStartDate, setFormStartDate] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [formSaving, setFormSaving] = useState(false);
|
||||
|
||||
@@ -81,13 +65,13 @@ function ClientTargetPanel({
|
||||
const [corrError, setCorrError] = useState<string | null>(null);
|
||||
const [corrSaving, setCorrSaving] = useState(false);
|
||||
|
||||
const todayIso = new Date().toISOString().split('T')[0];
|
||||
|
||||
const openCreate = () => {
|
||||
setFormHours('');
|
||||
const today = new Date();
|
||||
const day = today.getUTCDay() || 7;
|
||||
const monday = new Date(today);
|
||||
monday.setUTCDate(today.getUTCDate() - day + 1);
|
||||
setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0]));
|
||||
setFormPeriodType('weekly');
|
||||
setFormWorkingDays(['MON', 'TUE', 'WED', 'THU', 'FRI']);
|
||||
setFormStartDate(todayIso);
|
||||
setFormError(null);
|
||||
setEditing(false);
|
||||
setShowForm(true);
|
||||
@@ -95,32 +79,56 @@ function ClientTargetPanel({
|
||||
|
||||
const openEdit = () => {
|
||||
if (!target) return;
|
||||
setFormHours(String(target.weeklyHours));
|
||||
setFormWeek(mondayToWeekInput(target.startDate));
|
||||
setFormHours(String(target.targetHours));
|
||||
setFormPeriodType(target.periodType);
|
||||
setFormWorkingDays([...target.workingDays]);
|
||||
setFormStartDate(target.startDate);
|
||||
setFormError(null);
|
||||
setEditing(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const toggleDay = (day: string) => {
|
||||
setFormWorkingDays(prev =>
|
||||
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day],
|
||||
);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
const hours = parseFloat(formHours);
|
||||
if (isNaN(hours) || hours <= 0 || hours > 168) {
|
||||
setFormError('Weekly hours must be between 0 and 168');
|
||||
setFormError(`${formPeriodType === 'weekly' ? 'Weekly' : 'Monthly'} hours must be between 0 and 168`);
|
||||
return;
|
||||
}
|
||||
if (!formWeek) {
|
||||
setFormError('Please select a start week');
|
||||
if (formWorkingDays.length === 0) {
|
||||
setFormError('Select at least one working day');
|
||||
return;
|
||||
}
|
||||
if (!formStartDate) {
|
||||
setFormError('Please select a start date');
|
||||
return;
|
||||
}
|
||||
const startDate = weekInputToMonday(formWeek);
|
||||
setFormSaving(true);
|
||||
try {
|
||||
if (editing && target) {
|
||||
await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } });
|
||||
await updateTarget.mutateAsync({
|
||||
id: target.id,
|
||||
input: {
|
||||
targetHours: hours,
|
||||
periodType: formPeriodType,
|
||||
workingDays: formWorkingDays,
|
||||
startDate: formStartDate,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await onCreated(hours, startDate);
|
||||
await onCreated({
|
||||
targetHours: hours,
|
||||
periodType: formPeriodType,
|
||||
workingDays: formWorkingDays,
|
||||
startDate: formStartDate,
|
||||
});
|
||||
}
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
@@ -185,23 +193,46 @@ function ClientTargetPanel({
|
||||
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
Set weekly target
|
||||
Set target
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showForm) {
|
||||
const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month';
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||
{editing ? 'Edit target' : 'Set weekly target'}
|
||||
{editing ? 'Edit target' : 'Set target'}
|
||||
</p>
|
||||
<form onSubmit={handleFormSubmit} className="space-y-2">
|
||||
{formError && <p className="text-xs text-red-600">{formError}</p>}
|
||||
|
||||
{/* Period type */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-0.5">Period</label>
|
||||
<div className="flex gap-2">
|
||||
{(['weekly', 'monthly'] as const).map(pt => (
|
||||
<label key={pt} className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="periodType"
|
||||
value={pt}
|
||||
checked={formPeriodType === pt}
|
||||
onChange={() => setFormPeriodType(pt)}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{pt.charAt(0).toUpperCase() + pt.slice(1)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours + Start Date */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label>
|
||||
<label className="block text-xs text-gray-500 mb-0.5">{hoursLabel}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formHours}
|
||||
@@ -215,16 +246,41 @@ function ClientTargetPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label>
|
||||
<label className="block text-xs text-gray-500 mb-0.5">Start date</label>
|
||||
<input
|
||||
type="week"
|
||||
value={formWeek}
|
||||
onChange={e => setFormWeek(e.target.value)}
|
||||
type="date"
|
||||
value={formStartDate}
|
||||
onChange={e => setFormStartDate(e.target.value)}
|
||||
className="input text-sm py-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Working days */}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-0.5">Working days</label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{ALL_DAYS.map(day => {
|
||||
const active = formWorkingDays.includes(day);
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day)}
|
||||
className={`text-xs px-2 py-0.5 rounded border font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-primary-600 border-primary-600 text-white'
|
||||
: 'bg-white border-gray-300 text-gray-600 hover:border-primary-400'
|
||||
}`}
|
||||
>
|
||||
{DAY_LABELS[day]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,6 +304,7 @@ function ClientTargetPanel({
|
||||
|
||||
// Target exists — show summary + expandable details
|
||||
const balance = balanceLabel(target!.totalBalanceSeconds);
|
||||
const periodLabel = target!.periodType === 'weekly' ? 'week' : 'month';
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
@@ -256,9 +313,15 @@ function ClientTargetPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="font-medium">{target!.weeklyHours}h</span>/week
|
||||
<span className="font-medium">{target!.targetHours}h</span>/{periodLabel}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
|
||||
{target!.hasOngoingTimer && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full bg-green-500 animate-pulse"
|
||||
title="Timer running — balance updates every 30 s"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -531,8 +594,14 @@ export function ClientsPage() {
|
||||
<ClientTargetPanel
|
||||
client={client}
|
||||
target={target}
|
||||
onCreated={async (weeklyHours, startDate) => {
|
||||
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
|
||||
onCreated={async ({ targetHours, periodType, workingDays, startDate }) => {
|
||||
await createTarget.mutateAsync({
|
||||
clientId: client.id,
|
||||
targetHours,
|
||||
periodType,
|
||||
workingDays,
|
||||
startDate,
|
||||
});
|
||||
}}
|
||||
onDeleted={async () => {
|
||||
if (target) await deleteTarget.mutateAsync(target.id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
|
||||
import { useTimeEntries } from "@/hooks/useTimeEntries";
|
||||
import { useClientTargets } from "@/hooks/useClientTargets";
|
||||
import { useTimer } from "@/contexts/TimerContext";
|
||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
|
||||
@@ -30,6 +31,7 @@ export function DashboardPage() {
|
||||
});
|
||||
|
||||
const { targets } = useClientTargets();
|
||||
const { ongoingTimer, elapsedSeconds } = useTimer();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
||||
@@ -54,12 +56,19 @@ export function DashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const totalTodaySeconds =
|
||||
const completedTodaySeconds =
|
||||
todayEntries?.entries.reduce((total, entry) => {
|
||||
return total + calculateDuration(entry.startTime, entry.endTime);
|
||||
}, 0) || 0;
|
||||
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
||||
}, 0) ?? 0;
|
||||
|
||||
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
||||
// Only add the running timer if it started today (not a timer left running from yesterday)
|
||||
const timerStartedToday =
|
||||
ongoingTimer !== null &&
|
||||
new Date(ongoingTimer.startTime) >= startOfDay(today);
|
||||
|
||||
const totalTodaySeconds = completedTodaySeconds + (timerStartedToday ? elapsedSeconds : 0);
|
||||
|
||||
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -78,6 +87,7 @@ export function DashboardPage() {
|
||||
label="Today"
|
||||
value={formatDurationHoursMinutes(totalTodaySeconds)}
|
||||
color="blue"
|
||||
indicator={timerStartedToday}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Calendar}
|
||||
@@ -108,7 +118,7 @@ export function DashboardPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Target className="h-5 w-5 text-primary-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Targets</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{targetsWithData.map(target => {
|
||||
@@ -116,8 +126,9 @@ export function DashboardPage() {
|
||||
const absBalance = Math.abs(balance);
|
||||
const isOver = balance > 0;
|
||||
const isEven = balance === 0;
|
||||
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
|
||||
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
|
||||
const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
|
||||
const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
|
||||
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -127,23 +138,31 @@ export function DashboardPage() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
This week: {currentWeekTracked} / {currentWeekTarget}
|
||||
</p>
|
||||
{periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`text-sm font-bold ${
|
||||
isEven
|
||||
? 'text-gray-500'
|
||||
: isOver
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{isEven
|
||||
? '±0'
|
||||
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{target.hasOngoingTimer && (
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
|
||||
title="Timer running — balance updates every 30 s"
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className={`text-sm font-bold ${
|
||||
isEven
|
||||
? 'text-gray-500'
|
||||
: isOver
|
||||
? 'text-green-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{isEven
|
||||
? '±0'
|
||||
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">running balance</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +235,10 @@ export function DashboardPage() {
|
||||
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} – {formatTime(entry.endTime)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)}
|
||||
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
|
||||
{entry.breakMinutes > 0 && (
|
||||
<span className="text-xs text-gray-400 ml-1">(−{entry.breakMinutes}m break)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -8,8 +7,8 @@ export function LoginPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8 p-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-16 w-16 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<Clock className="h-8 w-8 text-primary-600" />
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center drop-shadow-sm">
|
||||
<img src="/icon.svg" alt="TimeTracker Logo" className="h-16 w-16" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
|
||||
@@ -78,7 +78,10 @@ export function TimeEntriesPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)}
|
||||
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
|
||||
{entry.breakMinutes > 0 && (
|
||||
<span className="text-xs text-gray-400 ml-1">(−{entry.breakMinutes}m)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Client {
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
@@ -22,12 +23,14 @@ export interface Project {
|
||||
client: Pick<Client, 'id' | 'name'>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
breakMinutes: number;
|
||||
description: string | null;
|
||||
projectId: string;
|
||||
project: Pick<Project, 'id' | 'name' | 'color'> & {
|
||||
@@ -35,6 +38,7 @@ export interface TimeEntry {
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OngoingTimer {
|
||||
@@ -129,6 +133,7 @@ export interface UpdateProjectInput {
|
||||
export interface CreateTimeEntryInput {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
breakMinutes?: number;
|
||||
description?: string;
|
||||
projectId: string;
|
||||
}
|
||||
@@ -136,6 +141,7 @@ export interface CreateTimeEntryInput {
|
||||
export interface UpdateTimeEntryInput {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
breakMinutes?: number;
|
||||
description?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
@@ -146,15 +152,22 @@ export interface BalanceCorrection {
|
||||
hours: number;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WeekBalance {
|
||||
weekStart: string; // YYYY-MM-DD (Monday)
|
||||
weekEnd: string; // YYYY-MM-DD (Sunday)
|
||||
export interface PeriodBalance {
|
||||
periodStart: string; // YYYY-MM-DD
|
||||
periodEnd: string; // YYYY-MM-DD
|
||||
targetHours: number; // pro-rated for first period
|
||||
trackedSeconds: number;
|
||||
targetSeconds: number;
|
||||
correctionHours: number;
|
||||
balanceSeconds: number;
|
||||
isOngoing: boolean;
|
||||
// only present when isOngoing = true
|
||||
dailyRateHours?: number;
|
||||
workingDaysInPeriod?: number;
|
||||
elapsedWorkingDays?: number;
|
||||
expectedHours?: number;
|
||||
}
|
||||
|
||||
export interface ClientTargetWithBalance {
|
||||
@@ -162,26 +175,34 @@ export interface ClientTargetWithBalance {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
userId: string;
|
||||
weeklyHours: number;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
periodType: "weekly" | "monthly";
|
||||
targetHours: number;
|
||||
workingDays: string[]; // e.g. ["MON","WED"]
|
||||
startDate: string; // YYYY-MM-DD
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
corrections: BalanceCorrection[];
|
||||
totalBalanceSeconds: number;
|
||||
currentWeekTrackedSeconds: number;
|
||||
currentWeekTargetSeconds: number;
|
||||
weeks: WeekBalance[];
|
||||
currentPeriodTrackedSeconds: number;
|
||||
currentPeriodTargetSeconds: number;
|
||||
periods: PeriodBalance[];
|
||||
/** True when an active timer for a project belonging to this client is running. */
|
||||
hasOngoingTimer: boolean;
|
||||
}
|
||||
|
||||
export interface CreateClientTargetInput {
|
||||
clientId: string;
|
||||
weeklyHours: number;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
targetHours: number;
|
||||
periodType: "weekly" | "monthly";
|
||||
workingDays: string[]; // e.g. ["MON","WED","FRI"]
|
||||
startDate: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface UpdateClientTargetInput {
|
||||
weeklyHours?: number;
|
||||
startDate?: string;
|
||||
targetHours?: number;
|
||||
periodType?: "weekly" | "monthly";
|
||||
workingDays?: string[];
|
||||
startDate?: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface CreateCorrectionInput {
|
||||
|
||||
@@ -43,7 +43,14 @@ export function formatDurationHoursMinutes(totalSeconds: number): string {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export function calculateDuration(startTime: string, endTime: string): number {
|
||||
export function calculateDuration(startTime: string, endTime: string, breakMinutes: number = 0): number {
|
||||
const start = parseISO(startTime);
|
||||
const end = parseISO(endTime);
|
||||
const totalSeconds = differenceInSeconds(end, start);
|
||||
return totalSeconds - (breakMinutes * 60);
|
||||
}
|
||||
|
||||
export function calculateGrossDuration(startTime: string, endTime: string): number {
|
||||
const start = parseISO(startTime);
|
||||
const end = parseISO(endTime);
|
||||
return differenceInSeconds(end, start);
|
||||
@@ -52,16 +59,18 @@ export function calculateDuration(startTime: string, endTime: string): number {
|
||||
export function formatDurationFromDates(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
breakMinutes: number = 0,
|
||||
): string {
|
||||
const seconds = calculateDuration(startTime, endTime);
|
||||
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||
return formatDuration(seconds);
|
||||
}
|
||||
|
||||
export function formatDurationFromDatesHoursMinutes(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
breakMinutes: number = 0,
|
||||
): string {
|
||||
const seconds = calculateDuration(startTime, endTime);
|
||||
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||
return formatDurationHoursMinutes(seconds);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,27 +2,19 @@ import SwiftUI
|
||||
|
||||
struct TimeEntriesView: View {
|
||||
@StateObject private var viewModel = TimeEntriesViewModel()
|
||||
@State private var selectedDay: Date? = Calendar.current.startOfDay(for: Date())
|
||||
@State private var visibleWeekStart: Date = Self.mondayOfWeek(containing: Date())
|
||||
|
||||
// dayOffset is the source of truth: 0 = today, -1 = yesterday, etc.
|
||||
@State private var dayOffset: Int = 0
|
||||
// tabSelection is always snapped back to 1 (middle) after each swipe.
|
||||
// Pages are: 0 = dayOffset-1, 1 = dayOffset, 2 = dayOffset+1
|
||||
@State private var tabSelection: Int = 1
|
||||
|
||||
@State private var showFilterSheet = false
|
||||
@State private var showAddEntry = false
|
||||
@State private var entryToEdit: TimeEntry?
|
||||
@State private var entryToDelete: TimeEntry?
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
private static func mondayOfWeek(containing date: Date) -> Date {
|
||||
var cal = Calendar.current
|
||||
cal.firstWeekday = 2 // Monday
|
||||
let comps = cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date)
|
||||
return cal.date(from: comps) ?? Calendar.current.startOfDay(for: date)
|
||||
}
|
||||
|
||||
private var visibleWeekDays: [Date] {
|
||||
(0..<7).compactMap {
|
||||
Calendar.current.date(byAdding: .day, value: $0, to: visibleWeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
@@ -37,6 +29,7 @@ struct TimeEntriesView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Entries")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
.task { await viewModel.loadEntries() }
|
||||
.refreshable { await viewModel.loadEntries() }
|
||||
@@ -81,32 +74,64 @@ struct TimeEntriesView: View {
|
||||
Image(systemName: viewModel.activeFilters.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
// Place the DatePicker in the principal placement (center of nav bar)
|
||||
ToolbarItem(placement: .principal) {
|
||||
DatePicker(
|
||||
"",
|
||||
selection: Binding(
|
||||
get: {
|
||||
Calendar.current.date(byAdding: .day, value: dayOffset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
||||
},
|
||||
set: { newDate in
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
let normalizedNewDate = Calendar.current.startOfDay(for: newDate)
|
||||
let components = Calendar.current.dateComponents([.day], from: today, to: normalizedNewDate)
|
||||
if let dayDifference = components.day {
|
||||
dayOffset = dayDifference
|
||||
}
|
||||
}
|
||||
),
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.compact)
|
||||
.labelsHidden()
|
||||
.environment(\.locale, Locale.current) // Ensure correct start of week
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main content
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
WeekStripView(
|
||||
weekDays: visibleWeekDays,
|
||||
selectedDay: $selectedDay,
|
||||
daysWithEntries: viewModel.daysWithEntries,
|
||||
onSwipeLeft: {
|
||||
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart
|
||||
},
|
||||
onSwipeRight: {
|
||||
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart
|
||||
}
|
||||
)
|
||||
// Only 3 pages exist at any time: previous, current, next.
|
||||
// After each swipe settles, we reset tabSelection to 1 and shift
|
||||
// dayOffset, so the carousel appears infinite while staying cheap.
|
||||
TabView(selection: $tabSelection) {
|
||||
ForEach(0..<3, id: \.self) { page in
|
||||
let offset = dayOffset + (page - 1)
|
||||
let day = Calendar.current.date(
|
||||
byAdding: .day,
|
||||
value: offset,
|
||||
to: Calendar.current.startOfDay(for: Date())
|
||||
) ?? Date()
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
if let day = selectedDay {
|
||||
ScrollView {
|
||||
dayEntriesSection(for: day)
|
||||
} else {
|
||||
allEntriesSection
|
||||
}
|
||||
.tag(page)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.onChange(of: tabSelection) { _, newPage in
|
||||
guard newPage != 1 else { return }
|
||||
// Shift the logical day offset by how many pages we moved.
|
||||
dayOffset += newPage - 1
|
||||
// Snap back to the middle page without animation so the
|
||||
// surrounding pages are refreshed invisibly.
|
||||
var tx = Transaction()
|
||||
tx.disablesAnimations = true
|
||||
withTransaction(tx) {
|
||||
tabSelection = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,7 +141,8 @@ struct TimeEntriesView: View {
|
||||
private func dayEntriesSection(for day: Date) -> some View {
|
||||
let dayEntries = viewModel.entries(for: day)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
// Section header
|
||||
|
||||
// Optional: A small summary header for the day
|
||||
HStack {
|
||||
Text(dayTitle(day))
|
||||
.font(.subheadline)
|
||||
@@ -155,41 +181,7 @@ struct TimeEntriesView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - All entries (no day selected) — grouped by day
|
||||
|
||||
private var allEntriesSection: some View {
|
||||
LazyVStack(alignment: .leading, pinnedViews: .sectionHeaders) {
|
||||
ForEach(viewModel.entriesByDay, id: \.date) { group in
|
||||
Section {
|
||||
ForEach(Array(group.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
EntryRow(entry: entry)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { entryToEdit = entry }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
entryToDelete = entry
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
if index < group.entries.count - 1 {
|
||||
Divider().padding(.leading, 56)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(dayTitle(group.date))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@@ -198,6 +190,7 @@ struct TimeEntriesView: View {
|
||||
let cal = Calendar.current
|
||||
if cal.isDateInToday(date) { return "Today" }
|
||||
if cal.isDateInYesterday(date) { return "Yesterday" }
|
||||
if cal.isDateInTomorrow(date) { return "Tomorrow" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
return formatter.string(from: date)
|
||||
@@ -259,139 +252,6 @@ struct EntryRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Week Strip View
|
||||
|
||||
struct WeekStripView: View {
|
||||
let weekDays: [Date]
|
||||
@Binding var selectedDay: Date?
|
||||
let daysWithEntries: Set<Date>
|
||||
let onSwipeLeft: () -> Void
|
||||
let onSwipeRight: () -> Void
|
||||
|
||||
@GestureState private var dragOffset: CGFloat = 0
|
||||
|
||||
private let cal = Calendar.current
|
||||
|
||||
private var monthYearLabel: String {
|
||||
// Show the month/year of the majority of days in the strip
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
let midWeek = weekDays.count >= 4 ? weekDays[3] : (weekDays.first ?? Date())
|
||||
return formatter.string(from: midWeek)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
// Month / year header with navigation arrows
|
||||
HStack {
|
||||
Button { onSwipeRight() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
Spacer()
|
||||
Text(monthYearLabel)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Button { onSwipeLeft() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.top, 6)
|
||||
|
||||
// Day cells
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
DayCell(
|
||||
day: day,
|
||||
isSelected: selectedDay.map { cal.isDate($0, inSameDayAs: day) } ?? false,
|
||||
isToday: cal.isDateInToday(day),
|
||||
hasDot: daysWithEntries.contains(cal.startOfDay(for: day))
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let normalized = cal.startOfDay(for: day)
|
||||
if let current = selectedDay, cal.isDate(current, inSameDayAs: normalized) {
|
||||
selectedDay = nil
|
||||
} else {
|
||||
selectedDay = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 40, coordinateSpace: .local)
|
||||
.onEnded { value in
|
||||
if value.translation.width < -40 {
|
||||
onSwipeLeft()
|
||||
} else if value.translation.width > 40 {
|
||||
onSwipeRight()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Cell
|
||||
|
||||
private struct DayCell: View {
|
||||
let day: Date
|
||||
let isSelected: Bool
|
||||
let isToday: Bool
|
||||
let hasDot: Bool
|
||||
|
||||
private static let weekdayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "EEEEE" // Single letter: M T W T F S S
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "d"
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 3) {
|
||||
Text(Self.weekdayFormatter.string(from: day))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ZStack {
|
||||
if isSelected {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 32, height: 32)
|
||||
} else if isToday {
|
||||
Circle()
|
||||
.strokeBorder(Color.accentColor, lineWidth: 1.5)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
|
||||
Text(Self.dayFormatter.string(from: day))
|
||||
.font(.callout.weight(isToday || isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? .white : (isToday ? Color.accentColor : .primary))
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
// Dot indicator
|
||||
Circle()
|
||||
.fill(hasDot ? Color.accentColor.opacity(isSelected ? 0 : 0.7) : Color.clear)
|
||||
.frame(width: 4, height: 4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Sheet
|
||||
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "app_icon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 918 KiB |
Reference in New Issue
Block a user