Compare commits
14 Commits
c99bdf56e6
...
0d116c8c26
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d116c8c26 | |||
| 25b7371d08 | |||
|
|
685a311001 | ||
| d09247d2a5 | |||
|
|
078dc8c304 | ||
|
|
59eda58ee6 | ||
| d56eed8dde | |||
|
|
3fa13e1428 | ||
|
|
2e629d8017 | ||
|
|
6e0567d021 | ||
|
|
3ab39643dd | ||
|
|
e01e5e59df | ||
|
|
06596dcee9 | ||
|
|
7358fa6256 |
226
AGENTS.md
226
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.
|
This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. Read it in full before making changes.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
This is a monorepo with three sub-projects:
|
```text
|
||||||
|
|
||||||
```
|
|
||||||
/
|
/
|
||||||
├── frontend/ # React SPA (Vite + TypeScript + Tailwind)
|
├── 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)
|
├── 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)
|
├── ios/ # Native iOS app (Swift/Xcode)
|
||||||
├── timetracker-chart/ # Helm chart for Kubernetes deployment
|
├── timetracker-chart/ # Helm chart for Kubernetes deployment
|
||||||
├── docker-compose.yml
|
└── docker-compose.yml
|
||||||
└── project.md # Product requirements document
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
## Build, Lint, and Dev Commands
|
||||||
|
|
||||||
### Frontend (`frontend/`)
|
### Frontend (`frontend/`)
|
||||||
```bash
|
- **Dev Server:** `npm run dev` (port 5173)
|
||||||
npm run dev # Start Vite dev server (port 5173)
|
- **Build:** `npm run build` (tsc & vite build)
|
||||||
npm run build # Type-check (tsc) then bundle (vite build)
|
- **Lint:** `npm run lint` (ESLint, zero warnings allowed)
|
||||||
npm run preview # Preview production build locally
|
- **Preview:** `npm run preview`
|
||||||
npm run lint # ESLint over .ts/.tsx, zero warnings allowed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend (`backend/`)
|
### Backend (`backend/`)
|
||||||
```bash
|
- **Dev Server:** `npm run dev` (tsx watch)
|
||||||
npm run dev # Hot-reload dev server via tsx watch
|
- **Build:** `npm run build` (tsc to dist/)
|
||||||
npm run build # Compile TypeScript to dist/
|
- **Start:** `npm run start` (node dist/index.js)
|
||||||
npm run start # Run compiled output (node dist/index.js)
|
- **Database:**
|
||||||
npm run db:migrate # Run Prisma migrations
|
- `npm run db:migrate` (Run migrations)
|
||||||
npm run db:generate # Regenerate Prisma client
|
- `npm run db:generate` (Regenerate client)
|
||||||
npm run db:seed # Seed the database
|
- `npm run db:seed` (Seed database)
|
||||||
```
|
|
||||||
|
|
||||||
### Full stack (repo root)
|
### Full Stack (Root)
|
||||||
```bash
|
- **Run all:** `docker-compose up`
|
||||||
docker-compose up # Start all services (frontend, backend, postgres)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
### 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:
|
**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
|
```bash
|
||||||
npx vitest run src/path/to/file.test.ts
|
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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
### Imports
|
### Imports & Exports
|
||||||
- Use the `@/` alias for all internal frontend imports: `import { useAuth } from "@/contexts/AuthContext"`
|
- Use `@/` for all internal frontend imports: `import { useAuth } from "@/contexts/AuthContext"`
|
||||||
- Use `import type { ... }` for type-only imports: `import type { User } from "@/types"`
|
- Use `import type { ... }` for type-only imports. Order external libraries first.
|
||||||
- Order: external libraries first, then internal `@/` imports
|
- Named exports are standard. Avoid default exports (except in `App.tsx`).
|
||||||
- Named exports are the standard; avoid default exports (only `App.tsx` uses one)
|
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
- 2-space indentation throughout
|
- 2-space indentation. No Prettier config exists; maintain consistency with surrounding code.
|
||||||
- No Prettier config exists — maintain consistency with surrounding code
|
- Prefer double quotes. Trailing commas in multi-line objects/arrays.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Types and Interfaces
|
### Types & Naming Conventions
|
||||||
- Define all shared types as `interface` (not `type` aliases) in the relevant `types/index.ts`
|
- Define shared types as `interface` in `types/index.ts`.
|
||||||
- Suffix input/mutation types: `CreateClientInput`, `UpdateProjectInput`
|
- Suffix input types: `CreateClientInput`.
|
||||||
- Use `?` for optional fields, not `field: T | undefined`
|
- Use `?` for optional fields, `string | null` for nullable fields (not `undefined`).
|
||||||
- Use `string | null` for nullable fields (not `undefined`)
|
- **Components:** `PascalCase.tsx` (`DashboardPage.tsx`)
|
||||||
- Backend Zod schemas live in `backend/src/schemas/index.ts`, named `<Entity>Schema` (e.g., `CreateClientSchema`)
|
- **Hooks/Utils/API:** `camelCase.ts` (`useTimeEntries.ts`, `dateUtils.ts`)
|
||||||
- Backend custom errors extend `AppError`: `NotFoundError`, `BadRequestError`, `ConflictError`, `UnauthorizedError`
|
- **Backend Routes/Services:** `camelCase.routes.ts`, `camelCase.service.ts`
|
||||||
|
- **Backend Schemas:** Zod schemas in `backend/src/schemas/index.ts` (e.g., `CreateClientSchema`).
|
||||||
### 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/` |
|
|
||||||
|
|
||||||
### React Components
|
### React Components
|
||||||
- Use named function declarations, not arrow functions assigned to `const`:
|
- Use named function declarations: `export function DashboardPage() { ... }`
|
||||||
```ts
|
- Context hooks throw an error if called outside their provider.
|
||||||
// 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
|
|
||||||
|
|
||||||
### State Management
|
### State Management
|
||||||
- **Server state**: TanStack React Query (all remote data). Never use `useState` for server data.
|
- **Server state:** TanStack React Query. Never use `useState` for server data.
|
||||||
- Custom hooks encapsulate `useQuery` + `useMutation` + cache invalidation
|
- Use `mutateAsync` so callers can await and handle errors.
|
||||||
- Query keys are arrays: `["timeEntries", filters]`, `["projects", clientId]`
|
- Invalidate related queries after mutations: `queryClient.invalidateQueries`.
|
||||||
- Use `mutateAsync` (not `mutate`) so callers can `await` and handle errors
|
- **Shared client state:** React Context.
|
||||||
- Invalidate related queries after mutations via `queryClient.invalidateQueries`
|
- **Local UI state:** `useState`.
|
||||||
- **Shared client state**: React Context (`AuthContext`, `TimerContext`)
|
- **NO Redux or Zustand.**
|
||||||
- **Local UI state**: `useState` per component (modals, form data, error messages)
|
|
||||||
- No Redux or Zustand — do not introduce them
|
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
- **Frontend:**
|
||||||
**Frontend:**
|
```typescript
|
||||||
```ts
|
|
||||||
try {
|
try {
|
||||||
await someAsyncOperation()
|
await someAsyncOperation()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "An error occurred")
|
setError(err instanceof Error ? err.message : "An error occurred")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- Store errors in local `useState<string | null>` and render inline as red text
|
Store errors in local state and render inline as red text. No global error boundary exists.
|
||||||
- No global error boundary exists; handle errors close to where they occur
|
- **Backend:** Throw `AppError` subclasses from services.
|
||||||
|
```typescript
|
||||||
**Backend:**
|
router.get("/:id", async (req, res, next) => {
|
||||||
```ts
|
|
||||||
router.get("/resource/:id", async (req, res, next) => {
|
|
||||||
try {
|
try {
|
||||||
const result = await service.getById(req.params.id)
|
res.json(await service.getById(req.params.id))
|
||||||
res.json(result)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error) // always forward to errorHandler middleware
|
next(error) // Always forward to errorHandler middleware
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
- 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
|
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
- **Tailwind CSS v3** for all styling — no CSS modules, no styled-components
|
- **Tailwind CSS v3** only. No CSS modules or styled-components.
|
||||||
- Use `clsx` + `tailwind-merge` for conditional class merging when needed
|
- Use `clsx` + `tailwind-merge` for class merging. Icons from `lucide-react` only.
|
||||||
- Icons from `lucide-react` only
|
|
||||||
|
|
||||||
### Backend Validation
|
### Backend Validation & Database
|
||||||
- All incoming request data validated with Zod schemas before reaching service layer
|
- Validate all incoming request data with Zod schemas in middleware.
|
||||||
- Schemas defined in `backend/src/schemas/index.ts`
|
- Prisma v6 with PostgreSQL. Use the Prisma client singleton from `backend/src/prisma/`.
|
||||||
- Validation middleware applied per-route; never trust `req.body` without parsing through a schema
|
- DB columns are `snake_case`, mapped to `camelCase` TypeScript via `@map`.
|
||||||
|
|
||||||
### 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/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Architectural Decisions
|
## Key Architectural Decisions
|
||||||
- The frontend communicates with the backend exclusively through the typed Axios modules in `frontend/src/api/`
|
- Frontend communicates with Backend exclusively via typed Axios modules in `frontend/src/api/`.
|
||||||
- Authentication supports two flows: OIDC (web, via `express-session`) and JWT (iOS client, via `jsonwebtoken`)
|
- iOS app shares no code with the web frontend.
|
||||||
- The iOS app lives in `ios/` and shares no code with the web frontend — do not couple them
|
- Backend routes only handle HTTP concerns (parsing, validation, formatting); business logic belongs purely in services.
|
||||||
- All business logic belongs in service classes; routes only handle HTTP concerns (parsing, validation, response formatting)
|
|
||||||
|
|||||||
76
backend/package-lock.json
generated
76
backend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@quixo3/prisma-session-store": "^3.1.19",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -470,6 +471,27 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
||||||
@@ -555,6 +577,24 @@
|
|||||||
"@prisma/debug": "6.19.2"
|
"@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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2133,6 +2173,18 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
@@ -2152,6 +2204,15 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
@@ -2172,6 +2233,21 @@
|
|||||||
"fsevents": "~2.3.3"
|
"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": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@quixo3/prisma-session-store": "^3.1.19",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.18.2",
|
"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;
|
||||||
@@ -10,7 +10,7 @@ datasource db {
|
|||||||
model User {
|
model User {
|
||||||
id String @id @db.VarChar(255)
|
id String @id @db.VarChar(255)
|
||||||
username String @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)
|
email String @db.VarChar(255)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -65,6 +65,7 @@ model TimeEntry {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
startTime DateTime @map("start_time") @db.Timestamptz()
|
startTime DateTime @map("start_time") @db.Timestamptz()
|
||||||
endTime DateTime @map("end_time") @db.Timestamptz()
|
endTime DateTime @map("end_time") @db.Timestamptz()
|
||||||
|
breakMinutes Int @default(0) @map("break_minutes")
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -86,7 +87,7 @@ model OngoingTimer {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255) @unique
|
userId String @unique @map("user_id") @db.VarChar(255)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
projectId String? @map("project_id")
|
projectId String? @map("project_id")
|
||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
@@ -129,3 +130,12 @@ model BalanceCorrection {
|
|||||||
@@index([clientTargetId])
|
@@index([clientTargetId])
|
||||||
@@map("balance_corrections")
|
@@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 express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
|
import { PrismaSessionStore } from "@quixo3/prisma-session-store";
|
||||||
import { config, validateConfig } from "./config";
|
import { config, validateConfig } from "./config";
|
||||||
import { connectDatabase } from "./prisma/client";
|
import { connectDatabase, prisma } from "./prisma/client";
|
||||||
import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
|
import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
@@ -43,6 +44,11 @@ async function main() {
|
|||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
name: "sessionId",
|
name: "sessionId",
|
||||||
|
store: new PrismaSessionStore(prisma, {
|
||||||
|
checkPeriod: 2 * 60 * 1000, // ms
|
||||||
|
dbRecordIdIsSessionId: true,
|
||||||
|
dbRecordIdFunction: undefined,
|
||||||
|
}),
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: config.nodeEnv === "production",
|
secure: config.nodeEnv === "production",
|
||||||
httpOnly: true,
|
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
|
// POST /api/timer/stop - Stop timer
|
||||||
router.post(
|
router.post(
|
||||||
'/stop',
|
'/stop',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const UpdateProjectSchema = z.object({
|
|||||||
export const CreateTimeEntrySchema = z.object({
|
export const CreateTimeEntrySchema = z.object({
|
||||||
startTime: z.string().datetime(),
|
startTime: z.string().datetime(),
|
||||||
endTime: z.string().datetime(),
|
endTime: z.string().datetime(),
|
||||||
|
breakMinutes: z.number().int().min(0).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
@@ -38,6 +39,7 @@ export const CreateTimeEntrySchema = z.object({
|
|||||||
export const UpdateTimeEntrySchema = z.object({
|
export const UpdateTimeEntrySchema = z.object({
|
||||||
startTime: z.string().datetime().optional(),
|
startTime: z.string().datetime().optional(),
|
||||||
endTime: z.string().datetime().optional(),
|
endTime: z.string().datetime().optional(),
|
||||||
|
breakMinutes: z.number().int().min(0).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
projectId: z.string().uuid().optional(),
|
projectId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
@@ -64,6 +66,7 @@ export const StartTimerSchema = z.object({
|
|||||||
|
|
||||||
export const UpdateTimerSchema = z.object({
|
export const UpdateTimerSchema = z.object({
|
||||||
projectId: z.string().uuid().optional().nullable(),
|
projectId: z.string().uuid().optional().nullable(),
|
||||||
|
startTime: z.string().datetime().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StopTimerSchema = z.object({
|
export const StopTimerSchema = z.object({
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export class ClientTargetService {
|
|||||||
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start,
|
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start,
|
||||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS tracked_seconds
|
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
WHERE te.user_id = ${target.userId}
|
WHERE te.user_id = ${target.userId}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export class TimeEntryService {
|
|||||||
p.id AS project_id,
|
p.id AS project_id,
|
||||||
p.name AS project_name,
|
p.name AS project_name,
|
||||||
p.color AS project_color,
|
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
|
COUNT(te.id)::bigint AS entry_count
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
@@ -63,7 +63,7 @@ export class TimeEntryService {
|
|||||||
SELECT
|
SELECT
|
||||||
c.id AS client_id,
|
c.id AS client_id,
|
||||||
c.name AS client_name,
|
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
|
COUNT(te.id)::bigint AS entry_count
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
@@ -77,7 +77,7 @@ export class TimeEntryService {
|
|||||||
prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>(
|
prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>(
|
||||||
Prisma.sql`
|
Prisma.sql`
|
||||||
SELECT
|
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
|
COUNT(te.id)::bigint AS entry_count
|
||||||
FROM time_entries te
|
FROM time_entries te
|
||||||
JOIN projects p ON p.id = te.project_id
|
JOIN projects p ON p.id = te.project_id
|
||||||
@@ -204,12 +204,19 @@ export class TimeEntryService {
|
|||||||
async create(userId: string, data: CreateTimeEntryInput) {
|
async create(userId: string, data: CreateTimeEntryInput) {
|
||||||
const startTime = new Date(data.startTime);
|
const startTime = new Date(data.startTime);
|
||||||
const endTime = new Date(data.endTime);
|
const endTime = new Date(data.endTime);
|
||||||
|
const breakMinutes = data.breakMinutes ?? 0;
|
||||||
|
|
||||||
// Validate end time is after start time
|
// Validate end time is after start time
|
||||||
if (endTime <= startTime) {
|
if (endTime <= startTime) {
|
||||||
throw new BadRequestError("End time must be after start time");
|
throw new BadRequestError("End time must be after start time");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate break time doesn't exceed duration
|
||||||
|
const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000;
|
||||||
|
if (breakMinutes > durationMinutes) {
|
||||||
|
throw new BadRequestError("Break time cannot exceed total duration");
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the project belongs to the user
|
// Verify the project belongs to the user
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id: data.projectId, userId },
|
where: { id: data.projectId, userId },
|
||||||
@@ -235,6 +242,7 @@ export class TimeEntryService {
|
|||||||
data: {
|
data: {
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
breakMinutes,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
userId,
|
userId,
|
||||||
projectId: data.projectId,
|
projectId: data.projectId,
|
||||||
@@ -267,12 +275,19 @@ export class TimeEntryService {
|
|||||||
? new Date(data.startTime)
|
? new Date(data.startTime)
|
||||||
: entry.startTime;
|
: entry.startTime;
|
||||||
const endTime = data.endTime ? new Date(data.endTime) : entry.endTime;
|
const endTime = data.endTime ? new Date(data.endTime) : entry.endTime;
|
||||||
|
const breakMinutes = data.breakMinutes ?? entry.breakMinutes;
|
||||||
|
|
||||||
// Validate end time is after start time
|
// Validate end time is after start time
|
||||||
if (endTime <= startTime) {
|
if (endTime <= startTime) {
|
||||||
throw new BadRequestError("End time must be after start time");
|
throw new BadRequestError("End time must be after start time");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate break time doesn't exceed duration
|
||||||
|
const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000;
|
||||||
|
if (breakMinutes > durationMinutes) {
|
||||||
|
throw new BadRequestError("Break time cannot exceed total duration");
|
||||||
|
}
|
||||||
|
|
||||||
// If project changed, verify it belongs to the user
|
// If project changed, verify it belongs to the user
|
||||||
if (data.projectId && data.projectId !== entry.projectId) {
|
if (data.projectId && data.projectId !== entry.projectId) {
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
@@ -302,6 +317,7 @@ export class TimeEntryService {
|
|||||||
data: {
|
data: {
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
|
breakMinutes,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
projectId: data.projectId,
|
projectId: data.projectId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,9 +102,24 @@ export class TimerService {
|
|||||||
projectId = data.projectId;
|
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({
|
return prisma.ongoingTimer.update({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: projectId !== undefined ? { projectId } : {},
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
select: {
|
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) {
|
async stop(userId: string, data?: StopTimerInput) {
|
||||||
const timer = await this.getOngoingTimer(userId);
|
const timer = await this.getOngoingTimer(userId);
|
||||||
if (!timer) {
|
if (!timer) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface UpdateProjectInput {
|
|||||||
export interface CreateTimeEntryInput {
|
export interface CreateTimeEntryInput {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
breakMinutes?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ export interface CreateTimeEntryInput {
|
|||||||
export interface UpdateTimeEntryInput {
|
export interface UpdateTimeEntryInput {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
breakMinutes?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ export interface StartTimerInput {
|
|||||||
|
|
||||||
export interface UpdateTimerInput {
|
export interface UpdateTimerInput {
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
startTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StopTimerInput {
|
export interface StopTimerInput {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>TimeTracker</title>
|
<title>TimeTracker</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
40
frontend/public/favicon.svg
Normal file
40
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#818CF8" />
|
||||||
|
<stop offset="100%" stop-color="#4F46E5" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- App Icon Background -->
|
||||||
|
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" />
|
||||||
|
|
||||||
|
<!-- Inner Icon Group -->
|
||||||
|
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Stopwatch Top Button -->
|
||||||
|
<path d="M256 96 v44" stroke-width="28" />
|
||||||
|
<path d="M224 88 h64" stroke-width="24" />
|
||||||
|
|
||||||
|
<!-- Stopwatch Side Button -->
|
||||||
|
<path d="M352 176 l 24 -24" stroke-width="24" />
|
||||||
|
<!-- Cap for side button -->
|
||||||
|
<path d="M362 138 l 28 28" stroke-width="24" />
|
||||||
|
|
||||||
|
<!-- Outer Ring -->
|
||||||
|
<circle cx="256" cy="272" r="132" stroke-width="28" />
|
||||||
|
|
||||||
|
<!-- Clock Hands -->
|
||||||
|
<!-- Minute Hand -->
|
||||||
|
<path d="M256 184 v 88" stroke-width="24" />
|
||||||
|
<!-- Hour Hand -->
|
||||||
|
<path d="M256 272 l 48 32" stroke-width="24" />
|
||||||
|
|
||||||
|
<!-- Dial Tick Marks -->
|
||||||
|
<g stroke-width="12" opacity="0.6">
|
||||||
|
<line x1="256" y1="172" x2="256" y2="188" />
|
||||||
|
<line x1="256" y1="356" x2="256" y2="372" />
|
||||||
|
<line x1="172" y1="272" x2="188" y2="272" />
|
||||||
|
<line x1="340" y1="272" x2="324" y2="272" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
43
frontend/public/icon.svg
Normal file
43
frontend/public/icon.svg
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#818CF8" />
|
||||||
|
<stop offset="100%" stop-color="#4F46E5" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- App Icon Background -->
|
||||||
|
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" />
|
||||||
|
|
||||||
|
<!-- Inner Icon Group -->
|
||||||
|
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Stopwatch Top Button -->
|
||||||
|
<path d="M256 96 v44" stroke-width="28" />
|
||||||
|
<path d="M224 88 h64" stroke-width="24" />
|
||||||
|
|
||||||
|
<!-- Stopwatch Side Button -->
|
||||||
|
<path d="M352 176 l 24 -24" stroke-width="24" />
|
||||||
|
<!-- Cap for side button -->
|
||||||
|
<path d="M362 138 l 28 28" stroke-width="24" />
|
||||||
|
|
||||||
|
<!-- Outer Ring -->
|
||||||
|
<circle cx="256" cy="272" r="132" stroke-width="28" />
|
||||||
|
|
||||||
|
<!-- Clock Hands -->
|
||||||
|
<!-- Minute Hand -->
|
||||||
|
<path d="M256 184 v 88" stroke-width="24" />
|
||||||
|
<!-- Hour Hand -->
|
||||||
|
<path d="M256 272 l 48 32" stroke-width="24" />
|
||||||
|
|
||||||
|
<!-- Dial Tick Marks -->
|
||||||
|
<g stroke-width="12" opacity="0.6">
|
||||||
|
<line x1="256" y1="172" x2="256" y2="188" />
|
||||||
|
<line x1="256" y1="356" x2="256" y2="372" />
|
||||||
|
<line x1="172" y1="272" x2="188" y2="272" />
|
||||||
|
<line x1="340" y1="272" x2="324" y2="272" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,11 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
import type { OngoingTimer, TimeEntry } from '@/types';
|
import type { OngoingTimer, TimeEntry } from '@/types';
|
||||||
|
|
||||||
|
export interface UpdateTimerPayload {
|
||||||
|
projectId?: string | null;
|
||||||
|
startTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const timerApi = {
|
export const timerApi = {
|
||||||
getOngoing: async (): Promise<OngoingTimer | null> => {
|
getOngoing: async (): Promise<OngoingTimer | null> => {
|
||||||
const { data } = await apiClient.get<OngoingTimer | null>('/timer');
|
const { data } = await apiClient.get<OngoingTimer | null>('/timer');
|
||||||
@@ -14,10 +19,8 @@ export const timerApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (projectId?: string | null): Promise<OngoingTimer> => {
|
update: async (payload: UpdateTimerPayload): Promise<OngoingTimer> => {
|
||||||
const { data } = await apiClient.put<OngoingTimer>('/timer', {
|
const { data } = await apiClient.put<OngoingTimer>('/timer', payload);
|
||||||
projectId,
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -27,4 +30,8 @@ export const timerApi = {
|
|||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cancel: async (): Promise<void> => {
|
||||||
|
await apiClient.delete('/timer');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -48,7 +48,7 @@ export function Navbar() {
|
|||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<Clock className="h-8 w-8 text-primary-600" />
|
<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">
|
<span className="ml-2 text-xl font-bold text-gray-900">
|
||||||
TimeTracker
|
TimeTracker
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
|
|||||||
return {
|
return {
|
||||||
startTime: getLocalISOString(new Date(entry.startTime)),
|
startTime: getLocalISOString(new Date(entry.startTime)),
|
||||||
endTime: getLocalISOString(new Date(entry.endTime)),
|
endTime: getLocalISOString(new Date(entry.endTime)),
|
||||||
|
breakMinutes: entry.breakMinutes,
|
||||||
description: entry.description || '',
|
description: entry.description || '',
|
||||||
projectId: entry.projectId,
|
projectId: entry.projectId,
|
||||||
};
|
};
|
||||||
@@ -29,6 +30,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
|
|||||||
return {
|
return {
|
||||||
startTime: getLocalISOString(oneHourAgo),
|
startTime: getLocalISOString(oneHourAgo),
|
||||||
endTime: getLocalISOString(now),
|
endTime: getLocalISOString(now),
|
||||||
|
breakMinutes: 0,
|
||||||
description: '',
|
description: '',
|
||||||
projectId: projects?.[0]?.id || '',
|
projectId: projects?.[0]?.id || '',
|
||||||
};
|
};
|
||||||
@@ -97,6 +99,16 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="label">Description</label>
|
<label className="label">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Play, Square, ChevronDown } from "lucide-react";
|
import { Play, Square, ChevronDown, Pencil, Check, X, Trash2 } from "lucide-react";
|
||||||
import { useTimer } from "@/contexts/TimerContext";
|
import { useTimer } from "@/contexts/TimerContext";
|
||||||
import { useProjects } from "@/hooks/useProjects";
|
import { useProjects } from "@/hooks/useProjects";
|
||||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
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() {
|
export function TimerWidget() {
|
||||||
const {
|
const {
|
||||||
ongoingTimer,
|
ongoingTimer,
|
||||||
@@ -34,12 +49,19 @@ export function TimerWidget() {
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
startTimer,
|
startTimer,
|
||||||
stopTimer,
|
stopTimer,
|
||||||
|
cancelTimer,
|
||||||
updateTimerProject,
|
updateTimerProject,
|
||||||
|
updateTimerStartTime,
|
||||||
} = useTimer();
|
} = useTimer();
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const handleStart = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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) => {
|
const handleProjectChange = async (projectId: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
||||||
@@ -95,21 +162,69 @@ export function TimerWidget() {
|
|||||||
<>
|
<>
|
||||||
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
{/* 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">
|
<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="flex items-center space-x-2 shrink-0">
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
|
||||||
|
{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} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Stop 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
|
<button
|
||||||
onClick={handleStop}
|
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"
|
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" />
|
<Square className="h-5 w-5 fill-current" />
|
||||||
<span>Stop</span>
|
<span>Stop</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project Selector — full width on mobile, auto on desktop */}
|
{/* Project Selector — full width on mobile, auto on desktop */}
|
||||||
<div className="relative w-full sm:w-auto sm:flex-1 sm:mx-4">
|
<div className="relative w-full sm:w-auto sm:flex-1 sm:mx-4">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface TimerContextType {
|
|||||||
elapsedSeconds: number;
|
elapsedSeconds: number;
|
||||||
startTimer: (projectId?: string) => Promise<void>;
|
startTimer: (projectId?: string) => Promise<void>;
|
||||||
updateTimerProject: (projectId?: string | null) => Promise<void>;
|
updateTimerProject: (projectId?: string | null) => Promise<void>;
|
||||||
|
updateTimerStartTime: (startTime: string) => Promise<void>;
|
||||||
|
cancelTimer: () => Promise<void>;
|
||||||
stopTimer: (projectId?: string) => Promise<TimeEntry | null>;
|
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
|
// Stop timer mutation
|
||||||
const stopMutation = useMutation({
|
const stopMutation = useMutation({
|
||||||
mutationFn: timerApi.stop,
|
mutationFn: timerApi.stop,
|
||||||
@@ -102,11 +112,22 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const updateTimerProject = useCallback(
|
const updateTimerProject = useCallback(
|
||||||
async (projectId?: string | null) => {
|
async (projectId?: string | null) => {
|
||||||
await updateMutation.mutateAsync(projectId);
|
await updateMutation.mutateAsync({ projectId });
|
||||||
},
|
},
|
||||||
[updateMutation],
|
[updateMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateTimerStartTime = useCallback(
|
||||||
|
async (startTime: string) => {
|
||||||
|
await updateMutation.mutateAsync({ startTime });
|
||||||
|
},
|
||||||
|
[updateMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelTimer = useCallback(async () => {
|
||||||
|
await cancelMutation.mutateAsync();
|
||||||
|
}, [cancelMutation]);
|
||||||
|
|
||||||
const stopTimer = useCallback(
|
const stopTimer = useCallback(
|
||||||
async (projectId?: string): Promise<TimeEntry | null> => {
|
async (projectId?: string): Promise<TimeEntry | null> => {
|
||||||
try {
|
try {
|
||||||
@@ -127,6 +148,8 @@ export function TimerProvider({ children }: { children: ReactNode }) {
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
startTimer,
|
startTimer,
|
||||||
updateTimerProject,
|
updateTimerProject,
|
||||||
|
updateTimerStartTime,
|
||||||
|
cancelTimer,
|
||||||
stopTimer,
|
stopTimer,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const totalTodaySeconds =
|
const totalTodaySeconds =
|
||||||
todayEntries?.entries.reduce((total, entry) => {
|
todayEntries?.entries.reduce((total, entry) => {
|
||||||
return total + calculateDuration(entry.startTime, entry.endTime);
|
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
|
||||||
}, 0) || 0;
|
}, 0) || 0;
|
||||||
|
|
||||||
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
|
||||||
@@ -216,7 +216,10 @@ export function DashboardPage() {
|
|||||||
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} – {formatTime(entry.endTime)}</div>
|
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} – {formatTime(entry.endTime)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
|
<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>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
<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>
|
<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';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
export function LoginPage() {
|
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="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="max-w-md w-full space-y-8 p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto h-16 w-16 bg-primary-100 rounded-full flex items-center justify-center">
|
<div className="mx-auto h-16 w-16 flex items-center justify-center drop-shadow-sm">
|
||||||
<Clock className="h-8 w-8 text-primary-600" />
|
<img src="/icon.svg" alt="TimeTracker Logo" className="h-16 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ export function TimeEntriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
|
<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>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
<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>
|
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface TimeEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
breakMinutes: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
project: Pick<Project, 'id' | 'name' | 'color'> & {
|
project: Pick<Project, 'id' | 'name' | 'color'> & {
|
||||||
@@ -129,6 +130,7 @@ export interface UpdateProjectInput {
|
|||||||
export interface CreateTimeEntryInput {
|
export interface CreateTimeEntryInput {
|
||||||
startTime: string;
|
startTime: string;
|
||||||
endTime: string;
|
endTime: string;
|
||||||
|
breakMinutes?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
@@ -136,6 +138,7 @@ export interface CreateTimeEntryInput {
|
|||||||
export interface UpdateTimeEntryInput {
|
export interface UpdateTimeEntryInput {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
breakMinutes?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ export function formatDurationHoursMinutes(totalSeconds: number): string {
|
|||||||
return `${hours}h ${minutes}m`;
|
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 start = parseISO(startTime);
|
||||||
const end = parseISO(endTime);
|
const end = parseISO(endTime);
|
||||||
return differenceInSeconds(end, start);
|
return differenceInSeconds(end, start);
|
||||||
@@ -52,16 +59,18 @@ export function calculateDuration(startTime: string, endTime: string): number {
|
|||||||
export function formatDurationFromDates(
|
export function formatDurationFromDates(
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
|
breakMinutes: number = 0,
|
||||||
): string {
|
): string {
|
||||||
const seconds = calculateDuration(startTime, endTime);
|
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||||
return formatDuration(seconds);
|
return formatDuration(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDurationFromDatesHoursMinutes(
|
export function formatDurationFromDatesHoursMinutes(
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
|
breakMinutes: number = 0,
|
||||||
): string {
|
): string {
|
||||||
const seconds = calculateDuration(startTime, endTime);
|
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||||
return formatDurationHoursMinutes(seconds);
|
return formatDurationHoursMinutes(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user