40 Commits

Author SHA1 Message Date
1964f76f74 fix: add bg-gray-50 back to Layout 2026-03-13 17:07:32 +00:00
1f4e12298e fix 2026-03-13 17:46:53 +01:00
simon.franken
1049410fee adaption 2026-03-09 11:20:53 +01:00
c9bd0abf18 feat: include ongoing timer in today's tracked time on Dashboard
The 'Today' stat card now adds the running timer's elapsed seconds to
the total, so the displayed duration ticks up live alongside the timer
widget. The timer is only counted when it started today (timers carried
over from the previous day are excluded).

A pulsing green indicator dot is shown on the stat card value while the
timer is active, consistent with the balance widget treatment. The dot
is implemented via a new optional 'indicator' prop on StatCard so it
can be reused elsewhere without changing existing call sites.
2026-03-09 11:14:21 +01:00
7ec76e3e8e feat: include ongoing timer in balance calculation
The balance now accounts for any active timer whose project belongs to
the tracked client. computeBalance() fetches the user's OngoingTimer,
computes its elapsed seconds, and adds them to the matching period's
tracked seconds before running the balance formula — so both
currentPeriodTrackedSeconds and totalBalanceSeconds reflect the live
timer without requiring a schema change.

On the frontend, useClientTargets polls every 30 s while a timer is
running, and a pulsing green dot is shown next to the balance figure on
the Dashboard and Clients pages to signal the live contribution.
2026-03-09 10:59:39 +01:00
784e71e187 fix: exclude soft-deleted entries from overlap conflict check 2026-03-05 12:18:48 +01:00
7677fdd73d revert 2026-02-24 21:57:21 +01:00
924b83eb4d fix: replace type=time with separate hours/minutes number inputs in correction form
type="time" renders a clock-time picker (with AM/PM), not a duration input.
Switch to two type="number" fields (h / m) so the intent is unambiguous.
2026-02-24 21:53:21 +01:00
91d13b19db fix: replace separate h/m number inputs with single HH:MM time input in correction form
- Remove stale corrHoursInt/corrMins state (leftover from previous refactor)
- Use corrDuration (HH:MM string) parsed once and reuse totalHours in submit handler
- Single type="time" input + +/− toggle button matches TimerWidget style
- flex-1 on Date and Duration columns for equal width and consistent height alignment
2026-02-24 21:49:54 +01:00
2a5e6d4a22 fix: display correction amounts as h/m and replace decimal input with h:m fields
- Correction list now shows '13h 32m' instead of '13.65h', using
  formatDurationHoursMinutes (same formatter used everywhere else)
- Sign shown as '−' (minus) for negative corrections instead of bare '-'
- Correction input replaced with separate hours + minutes integer fields
  and a +/− toggle button, removing the awkward decimal entry
2026-02-24 21:44:54 +01:00
b7bd875462 Merge pull request 'feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration)' (#8) from client-targets-v2 into main
Reviewed-on: #8
2026-02-24 20:29:22 +00:00
a58dfcfa4a fix: clamp ongoing-period corrections to today to prevent future corrections inflating balance
A correction dated in the future (within the current period) was being
added to the balance immediately, while the corresponding expected hours
were not yet counted (elapsed working days only go up to today).

Fix: in the ongoing-period branch, sum only corrections whose date is
<= today, matching the same window used for elapsed working days and
tracked time.
2026-02-24 21:27:03 +01:00
7101f38bc8 feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration)
- Add PeriodType enum and working_days column to ClientTarget schema
- Rename weekly_hours -> target_hours; remove Monday-only constraint
- Add migration 20260224000000_client_targets_v2
- Rewrite computeBalance() to support weekly/monthly periods, per-spec
  pro-ration for first period, ongoing vs completed period logic, and
  elapsed working-day counting (§4–§6 of requirements doc)
- Update Zod schemas and TypeScript input types for new fields
- Frontend: replace WeekBalance with PeriodBalance; update
  ClientTargetWithBalance to currentPeriod* fields
- ClientTargetPanel: period type radio, working-day toggles, free date
  picker, dynamic hours label
- DashboardPage: rename widget to Targets, dynamic This week/This month
  label
2026-02-24 19:02:32 +01:00
3850e2db06 docs: add client targets v2 feature requirements 2026-02-24 18:50:34 +01:00
5b7b8e47cb ui adaptions 2026-02-23 20:59:01 +01:00
7dd3873148 Merge branch 'main' into feature/soft-delete 2026-02-23 17:59:29 +01:00
850f12e09d Merge pull request 'feature/ios-time-entries-rework' (#2) from feature/ios-time-entries-rework into main
Reviewed-on: #2
2026-02-23 16:58:44 +00:00
74999ce265 Merge pull request 'ios-rebuild' (#3) from ios-rebuild into main
Reviewed-on: #3
2026-02-23 16:57:45 +00:00
0c0fbf42ef updates icons 2026-02-23 17:57:25 +01:00
0d116c8c26 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 17:29:21 +01:00
25b7371d08 Merge branch 'main' into ios-rebuild 2026-02-23 17:28:51 +01:00
simon.franken
ddb0926dba Implement soft-delete for client targets and balance corrections
Deleting a target or correction sets deletedAt instead of hard-deleting.
Creating a target for a user+client that has a soft-deleted record
reactivates it (clears deletedAt, applies new weeklyHours/startDate)
rather than failing the unique constraint. All reads filter deletedAt = null
on the target, its corrections, and the parent client.
2026-02-23 15:48:07 +01:00
simon.franken
1b0f5866a1 Restore onDelete: Cascade on Project->Client and TimeEntry->Project
Direct database deletes should still cascade to avoid orphaned records.
The migration now only adds the three deleted_at columns without touching
the existing FK constraints.
2026-02-23 15:32:31 +01:00
simon.franken
159022ef38 Exclude client targets for soft-deleted clients
findAll and findById filter on client.deletedAt = null so targets
belonging to a soft-deleted client are invisible. The create guard
also rejects soft-deleted clients. The raw SQL balance query now
excludes soft-deleted time entries and projects from tracked totals.
2026-02-23 15:24:58 +01:00
simon.franken
1a7d13d5b9 Implement soft-delete for clients, projects, and time entries
Replace hard deletes with deletedAt timestamp flags on all three entities.
Deleting a client or project only sets its own deletedAt; child records are
excluded implicitly by filtering on parent deletedAt in every read query.
Raw SQL statistics queries also filter out soft-deleted parents.
FK ON DELETE CASCADE removed from Project→Client and TimeEntry→Project.
2026-02-23 15:21:13 +01:00
simon.franken
685a311001 Add break time feature to time entries
- Add breakMinutes field to TimeEntry model and database migration
- Users can now add break duration (minutes) to time entries
- Break time is subtracted from total tracked duration
- Validation ensures break time cannot exceed total entry duration
- Statistics and client target balance calculations account for breaks
- Frontend UI includes break time input in TimeEntryFormModal
- Duration displays show break time deduction (e.g., '7h (−1h break)')
- Both project/client statistics and weekly balance calculations updated
2026-02-23 14:39:30 +01:00
d09247d2a5 Merge pull request 'Add Prisma session store for persistent sessions' (#5) from feature/prisma-session-store into main
Reviewed-on: #5
2026-02-23 13:35:21 +00:00
simon.franken
078dc8c304 Add Prisma session store for persistent sessions 2026-02-23 11:39:09 +01:00
simon.franken
59eda58ee6 update agents.md 2026-02-23 10:59:17 +01:00
d56eed8dde Merge pull request 'Add ability to manually adjust the running timer's start time' (#4) from feature/adjust-timer-start-time into main
Reviewed-on: #4
2026-02-23 09:57:23 +00:00
simon.franken
3fa13e1428 Use icon.svg in Navbar and LoginPage instead of Clock icon 2026-02-23 10:55:33 +01:00
simon.franken
2e629d8017 Merge branch 'main' into feature/adjust-timer-start-time 2026-02-23 10:53:54 +01:00
simon.franken
6e0567d021 icon update 2026-02-23 10:53:39 +01:00
simon.franken
3ab39643dd Disable Stop button when no project is selected 2026-02-23 10:47:07 +01:00
simon.franken
e01e5e59df Remove cancel confirmation — discard timer immediately on click 2026-02-23 10:44:34 +01:00
simon.franken
06596dcee9 Add cancel (discard) timer feature
Allows users to discard a running timer without creating a time entry.
A trash icon in the timer widget reveals a confirmation step ('Discard / Keep')
to prevent accidental data loss. Backend exposes a new DELETE /api/timer
endpoint that simply deletes the ongoingTimer row.
2026-02-23 10:41:50 +01:00
simon.franken
7358fa6256 Add ability to manually adjust the running timer's start time
Allows users to retroactively correct the start time of an ongoing timer
without stopping it. A pencil icon in the timer widget opens an inline
time input pre-filled with the current start time; confirming sends the
new time to the backend which validates it is in the past before persisting.
2026-02-23 10:32:38 +01:00
simon.franken
c99bdf56e6 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 10:12:12 +01:00
simon.franken
544b86c948 fix(ios): replace 2001-page TabView with 3-page recycling carousel
Eliminates the eager instantiation of 2001 view bodies by keeping only
three pages (previous, current, next) alive at all times. After each
swipe settles, dayOffset is shifted and tabSelection is silently reset
to the middle page, preserving the native paging animation.
2026-02-23 10:09:51 +01:00
simon.franken
b971569983 feat(ios): replace week strip with native DatePicker and TabView paging 2026-02-23 10:02:24 +01:00
40 changed files with 1700 additions and 705 deletions

226
AGENTS.md
View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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[];

View File

@@ -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")
@@ -30,6 +30,7 @@ model Client {
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")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -47,6 +48,7 @@ model Project {
color String? @db.VarChar(7) // Hex color code color String? @db.VarChar(7) // Hex color code
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")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -65,9 +67,11 @@ 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")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -86,7 +90,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)
@@ -95,12 +99,20 @@ model OngoingTimer {
@@map("ongoing_timers") @@map("ongoing_timers")
} }
enum PeriodType {
WEEKLY
MONTHLY
}
model ClientTarget { model ClientTarget {
id String @id @default(uuid()) id String @id @default(uuid())
weeklyHours Float @map("weekly_hours") targetHours Float @map("target_hours")
startDate DateTime @map("start_date") @db.Date // Always a Monday 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -122,6 +134,7 @@ model BalanceCorrection {
description String? @db.VarChar(255) description 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")
deletedAt DateTime? @map("deleted_at")
clientTargetId String @map("client_target_id") clientTargetId String @map("client_target_id")
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade) clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
@@ -129,3 +142,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")
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,20 +66,27 @@ 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({
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
}); });
const WorkingDayEnum = z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']);
export const CreateClientTargetSchema = z.object({ export const CreateClientTargetSchema = z.object({
clientId: z.string().uuid(), 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'), 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({ 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(), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be a date in YYYY-MM-DD format').optional(),
}); });

View File

@@ -5,14 +5,14 @@ import type { CreateClientInput, UpdateClientInput } from "../types";
export class ClientService { export class ClientService {
async findAll(userId: string) { async findAll(userId: string) {
return prisma.client.findMany({ return prisma.client.findMany({
where: { userId }, where: { userId, deletedAt: null },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
} }
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.client.findFirst({ 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"); throw new NotFoundError("Client not found");
} }
await prisma.client.delete({ await prisma.client.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types'; import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
// Returns the Monday of the week containing the given date // ---------------------------------------------------------------------------
function getMondayOfWeek(date: Date): Date { // Day-of-week helpers
const d = new Date(date); // ---------------------------------------------------------------------------
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
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; const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff); d.setUTCDate(d.getUTCDate() + diff);
d.setUTCHours(0, 0, 0, 0); return d.toISOString().split('T')[0];
return d;
} }
// Returns the Sunday (end of week) for a given Monday /** Returns the Sunday of the ISO week given its Monday date string. */
function getSundayOfWeek(monday: Date): Date { function getSundayOfWeek(monday: string): string {
const d = new Date(monday); return addDays(monday, 6);
d.setUTCDate(d.getUTCDate() + 6);
d.setUTCHours(23, 59, 59, 999);
return d;
} }
// Returns all Mondays from startDate up to and including the current week's Monday /** Returns the first day of the month for a given date string. */
function getWeekMondays(startDate: Date): Date[] { function getMonthStart(dateStr: string): string {
const mondays: Date[] = []; return dateStr.slice(0, 7) + '-01';
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);
}
return mondays;
} }
interface WeekBalance { /** Returns the last day of the month for a given date string. */
weekStart: string; // ISO date string (Monday) function getMonthEnd(dateStr: string): string {
weekEnd: string; // ISO date string (Sunday) 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: MonSun.
* For monthly: 1stlast 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) };
}
}
/**
* 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; trackedSeconds: number;
targetSeconds: number;
correctionHours: 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 { export interface ClientTargetWithBalance {
@@ -48,7 +195,9 @@ export interface ClientTargetWithBalance {
clientId: string; clientId: string;
clientName: string; clientName: string;
userId: string; userId: string;
weeklyHours: number; periodType: 'weekly' | 'monthly';
targetHours: number;
workingDays: string[];
startDate: string; startDate: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -59,53 +208,91 @@ export interface ClientTargetWithBalance {
description: string | null; description: string | null;
createdAt: string; createdAt: string;
}>; }>;
totalBalanceSeconds: number; // running total across all weeks totalBalanceSeconds: number;
currentWeekTrackedSeconds: number; currentPeriodTrackedSeconds: number;
currentWeekTargetSeconds: number; currentPeriodTargetSeconds: number;
weeks: WeekBalance[]; 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 { export class ClientTargetService {
async findAll(userId: string): Promise<ClientTargetWithBalance[]> { async findAll(userId: string): Promise<ClientTargetWithBalance[]> {
const targets = await prisma.clientTarget.findMany({ const targets = await prisma.clientTarget.findMany({
where: { userId }, where: { userId, deletedAt: null, client: { deletedAt: null } },
include: { include: {
client: { select: { id: true, name: true } }, client: { select: { id: true, name: true } },
corrections: { orderBy: { date: 'asc' } }, corrections: { where: { deletedAt: null }, orderBy: { date: 'asc' } },
}, },
orderBy: { client: { name: '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) { async findById(id: string, userId: string) {
return prisma.clientTarget.findFirst({ return prisma.clientTarget.findFirst({
where: { id, userId }, where: { id, userId, deletedAt: null, client: { deletedAt: null } },
include: { include: {
client: { select: { id: true, name: true } }, 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> { async create(userId: string, data: CreateClientTargetInput): Promise<ClientTargetWithBalance> {
// Validate startDate is a Monday // Ensure the client belongs to this user and is not soft-deleted
const startDate = new Date(data.startDate + 'T00:00:00Z'); const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
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 } });
if (!client) { if (!client) {
throw new NotFoundError('Client not found'); 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) // Check for existing target (unique per user+client)
const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } }); const existing = await prisma.clientTarget.findFirst({ where: { userId, clientId: data.clientId } });
if (existing) { 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.'); 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: { data: {
userId, userId,
clientId: data.clientId, clientId: data.clientId,
weeklyHours: data.weeklyHours, targetHours: data.targetHours,
periodType,
workingDays: data.workingDays,
startDate, startDate,
}, },
include: { include: {
client: { select: { id: true, name: true } }, 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> { async update(id: string, userId: string, data: UpdateClientTargetInput): Promise<ClientTargetWithBalance> {
const existing = await this.findById(id, userId); const existing = await this.findById(id, userId);
if (!existing) throw new NotFoundError('Client target not found'); 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) { if (data.targetHours !== undefined) updateData.targetHours = data.targetHours;
updateData.weeklyHours = data.weeklyHours; 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');
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;
}
const updated = await prisma.clientTarget.update({ const updated = await prisma.clientTarget.update({
where: { id }, where: { id },
data: updateData, data: updateData,
include: { include: {
client: { select: { id: true, name: true } }, 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> { async delete(id: string, userId: string): Promise<void> {
const existing = await this.findById(id, userId); const existing = await this.findById(id, userId);
if (!existing) throw new NotFoundError('Client target not found'); 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) { async addCorrection(targetId: string, userId: string, data: CreateCorrectionInput) {
@@ -188,99 +378,221 @@ export class ClientTargetService {
if (!target) throw new NotFoundError('Client target not found'); if (!target) throw new NotFoundError('Client target not found');
const correction = await prisma.balanceCorrection.findFirst({ 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'); 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; // Balance computation
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);
if (mondays.length === 0) { private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
return this.emptyBalance(target); 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 const overallStart = periods[0].start;
// covering startDate to end of current week const overallEnd = periods[periods.length - 1].end;
const periodStart = mondays[0]; const today = new Date().toISOString().split('T')[0];
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
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` // 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;
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;
}
// 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 SELECT
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, TO_CHAR(
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS tracked_seconds 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 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}
AND p.client_id = ${target.clientId} AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart} AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${periodEnd} 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') GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`); `);
} else {
// Index tracked seconds by week start (ISO Monday string) trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
const trackedByWeek = new Map<string, number>(); SELECT
for (const row of rows) { TO_CHAR(
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week) DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
const monday = getMondayOfWeek(new Date(row.week_start)); 'YYYY-MM-DD'
const key = monday.toISOString().split('T')[0]; ) AS period_start,
trackedByWeek.set(key, Number(row.tracked_seconds)); COALESCE(
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
0
)::bigint AS tracked_seconds
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId}
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')
`);
} }
// Index corrections by week // Map tracked seconds by period start date string
const correctionsByWeek = new Map<string, number>(); 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) { for (const c of target.corrections) {
const monday = getMondayOfWeek(new Date(c.date)); const corrDateStr = c.date.toISOString().split('T')[0];
const key = monday.toISOString().split('T')[0]; const period = getPeriodForDate(corrDateStr, periodType);
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours); const key = period.start;
correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours);
} }
const targetSecondsPerWeek = target.weeklyHours * 3600; const periodBalances: PeriodBalance[] = [];
const weeks: WeekBalance[] = [];
let totalBalanceSeconds = 0; 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; totalBalanceSeconds += balanceSeconds;
weeks.push({ periodBalances.push({
weekStart: key, periodStart: period.start,
weekEnd: sunday.toISOString().split('T')[0], periodEnd: period.end,
targetHours: periodTargetHours,
trackedSeconds, trackedSeconds,
targetSeconds: effectiveTargetSeconds,
correctionHours, correctionHours,
balanceSeconds, balanceSeconds,
isOngoing,
...extra,
}); });
} }
const currentWeek = weeks[weeks.length - 1]; const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.length - 1];
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
startDate: target.startDate.toISOString().split('T')[0], targetHours: target.targetHours,
workingDays,
startDate: startDateStr,
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: target.corrections.map(c => ({ corrections: target.corrections.map(c => ({
@@ -291,37 +603,33 @@ export class ClientTargetService {
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
})), })),
totalBalanceSeconds, totalBalanceSeconds,
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0,
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, currentPeriodTargetSeconds: currentPeriod
weeks, ? Math.round(currentPeriod.targetHours * 3600)
: Math.round(target.targetHours * 3600),
periods: periodBalances,
hasOngoingTimer: ongoingTimerSeconds > 0,
}; };
} }
private emptyBalance(target: { private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance {
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 {
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
targetHours: target.targetHours,
workingDays: target.workingDays,
startDate: target.startDate.toISOString().split('T')[0], startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: [], corrections: [],
totalBalanceSeconds: 0, totalBalanceSeconds: 0,
currentWeekTrackedSeconds: 0, currentPeriodTrackedSeconds: 0,
currentWeekTargetSeconds: target.weeklyHours * 3600, currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
weeks: [], periods: [],
hasOngoingTimer: false,
}; };
} }
} }

View File

@@ -7,6 +7,8 @@ export class ProjectService {
return prisma.project.findMany({ return prisma.project.findMany({
where: { where: {
userId, userId,
deletedAt: null,
client: { deletedAt: null },
...(clientId && { clientId }), ...(clientId && { clientId }),
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -23,7 +25,12 @@ export class ProjectService {
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.project.findFirst({ return prisma.project.findFirst({
where: { id, userId }, where: {
id,
userId,
deletedAt: null,
client: { deletedAt: null },
},
include: { include: {
client: { client: {
select: { select: {
@@ -36,9 +43,9 @@ export class ProjectService {
} }
async create(userId: string, data: CreateProjectInput) { 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({ const client = await prisma.client.findFirst({
where: { id: data.clientId, userId }, where: { id: data.clientId, userId, deletedAt: null },
}); });
if (!client) { if (!client) {
@@ -70,10 +77,10 @@ export class ProjectService {
throw new NotFoundError("Project not found"); 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) { if (data.clientId) {
const client = await prisma.client.findFirst({ const client = await prisma.client.findFirst({
where: { id: data.clientId, userId }, where: { id: data.clientId, userId, deletedAt: null },
}); });
if (!client) { if (!client) {
@@ -108,8 +115,9 @@ export class ProjectService {
throw new NotFoundError("Project not found"); throw new NotFoundError("Project not found");
} }
await prisma.project.delete({ await prisma.project.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -42,11 +42,15 @@ 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
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
GROUP BY p.id, p.name, p.color GROUP BY p.id, p.name, p.color
ORDER BY total_seconds DESC ORDER BY total_seconds DESC
@@ -63,12 +67,15 @@ 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
JOIN clients c ON c.id = p.client_id JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
GROUP BY c.id, c.name GROUP BY c.id, c.name
ORDER BY total_seconds DESC ORDER BY total_seconds DESC
@@ -77,11 +84,15 @@ 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
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
`, `,
), ),
@@ -125,10 +136,11 @@ export class TimeEntryService {
const where: { const where: {
userId: string; userId: string;
deletedAt: null;
startTime?: { gte?: Date; lte?: Date }; startTime?: { gte?: Date; lte?: Date };
projectId?: string; projectId?: string;
project?: { clientId?: string }; project?: { deletedAt: null; clientId?: string; client: { deletedAt: null } };
} = { userId }; } = { userId, deletedAt: null };
if (startDate || endDate) { if (startDate || endDate) {
where.startTime = {}; where.startTime = {};
@@ -140,9 +152,13 @@ export class TimeEntryService {
where.projectId = projectId; where.projectId = projectId;
} }
if (clientId) { // Always filter out entries whose project or client is soft-deleted,
where.project = { clientId }; // 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([ const [entries, total] = await Promise.all([
prisma.timeEntry.findMany({ prisma.timeEntry.findMany({
@@ -182,7 +198,12 @@ export class TimeEntryService {
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.timeEntry.findFirst({ return prisma.timeEntry.findFirst({
where: { id, userId }, where: {
id,
userId,
deletedAt: null,
project: { deletedAt: null, client: { deletedAt: null } },
},
include: { include: {
project: { project: {
select: { select: {
@@ -204,15 +225,22 @@ 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");
} }
// 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({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
}); });
if (!project) { if (!project) {
@@ -235,6 +263,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,16 +296,23 @@ 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");
} }
// 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) { if (data.projectId && data.projectId !== entry.projectId) {
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
}); });
if (!project) { if (!project) {
@@ -302,6 +338,7 @@ export class TimeEntryService {
data: { data: {
startTime, startTime,
endTime, endTime,
breakMinutes,
description: data.description, description: data.description,
projectId: data.projectId, projectId: data.projectId,
}, },
@@ -329,8 +366,9 @@ export class TimeEntryService {
throw new NotFoundError("Time entry not found"); throw new NotFoundError("Time entry not found");
} }
await prisma.timeEntry.delete({ await prisma.timeEntry.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -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) {

View File

@@ -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 {
@@ -79,13 +82,17 @@ export interface StopTimerInput {
export interface CreateClientTargetInput { export interface CreateClientTargetInput {
clientId: string; clientId: string;
weeklyHours: number; targetHours: number;
startDate: string; // YYYY-MM-DD, always a Monday periodType: 'weekly' | 'monthly';
workingDays: string[]; // e.g. ["MON","WED","FRI"]
startDate: string; // YYYY-MM-DD
} }
export interface UpdateClientTargetInput { export interface UpdateClientTargetInput {
weeklyHours?: number; targetHours?: number;
startDate?: string; // YYYY-MM-DD, always a Monday periodType?: 'weekly' | 'monthly';
workingDays?: string[];
startDate?: string; // YYYY-MM-DD
} }
export interface CreateCorrectionInput { export interface CreateCorrectionInput {

View File

@@ -19,6 +19,7 @@ export async function hasOverlappingEntries(
const count = await prisma.timeEntry.count({ const count = await prisma.timeEntry.count({
where: { where: {
userId, userId,
deletedAt: null,
...(excludeId ? { id: { not: excludeId } } : {}), ...(excludeId ? { id: { not: excludeId } } : {}),
// An entry overlaps when it starts before our end AND ends after our start. // An entry overlaps when it starts before our end AND ends after our start.
startTime: { lt: endTime }, startTime: { lt: endTime },

View 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 (WedSun)`, `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: MonSun, 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*`

View File

@@ -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>

View 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
View 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

View File

@@ -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');
},
}; };

View File

@@ -4,9 +4,9 @@ import { TimerWidget } from './TimerWidget';
export function Layout() { export function Layout() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="h-[100vh] w-[100vw] flex flex-col bg-gray-50">
<Navbar /> <Navbar />
<main className="pt-4 pb-24"> <main className="pt-4 pb-8 grow overflow-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Outlet /> <Outlet />
</div> </div>

View File

@@ -47,20 +47,27 @@ export function Navbar() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <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 justify-between h-16">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0 flex items-center"> <NavLink
<Clock className="h-8 w-8 text-primary-600" /> 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"> <span className="ml-2 text-xl font-bold text-gray-900">
TimeTracker TimeTracker
</span> </span>
</div> </NavLink>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4"> <div className="hidden sm:ml-8 sm:flex sm:space-x-4 items-center">
{/* Main Navigation Items */} {/* Main Navigation Items */}
{mainNavItems.map((item) => ( {mainNavItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
className={({ isActive }) => 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 isActive
? "text-primary-600 bg-primary-50" ? "text-primary-600 bg-primary-50"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50" : "text-gray-600 hover:text-gray-900 hover:bg-gray-50"

View File

@@ -3,27 +3,37 @@ interface StatCardProps {
label: string; label: string;
value: string; value: string;
color: 'blue' | 'green' | 'purple' | 'orange'; 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', blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600', green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600', purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-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 ( return (
<div className="card p-4"> <div className="card p-4">
<div className="flex items-center"> <div className="flex items-center">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}> <div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6" /> <Icon className="h-6 w-6" />
</div> </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-sm font-medium text-gray-600">{label}</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> <p className="text-2xl font-bold text-gray-900">{value}</p>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -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

View File

@@ -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,9 +109,45 @@ 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="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div> </div>
@@ -89,27 +156,75 @@ export function TimerWidget() {
} }
return ( return (
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg z-50"> <div className="bg-white border-t border-gray-200 py-4 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
{ongoingTimer ? ( {ongoingTimer ? (
<> <>
{/* 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">

View File

@@ -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,
}} }}
> >

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clientTargetsApi } from '@/api/clientTargets'; import { clientTargetsApi } from '@/api/clientTargets';
import { useTimer } from '@/contexts/TimerContext';
import type { import type {
CreateClientTargetInput, CreateClientTargetInput,
UpdateClientTargetInput, UpdateClientTargetInput,
@@ -8,10 +9,13 @@ import type {
export function useClientTargets() { export function useClientTargets() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { ongoingTimer } = useTimer();
const { data: targets, isLoading, error } = useQuery({ const { data: targets, isLoading, error } = useQuery({
queryKey: ['clientTargets'], queryKey: ['clientTargets'],
queryFn: clientTargetsApi.getAll, 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({ const createTarget = useMutation({

View File

@@ -14,33 +14,10 @@ import type {
CreateCorrectionInput, CreateCorrectionInput,
} from '@/types'; } from '@/types';
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16" const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] as const;
function weekInputToMonday(weekValue: string): string { const DAY_LABELS: Record<string, string> = {
const [yearStr, weekStr] = weekValue.split('-W'); MON: 'Mon', TUE: 'Tue', WED: 'Wed', THU: 'Thu', FRI: 'Fri', SAT: 'Sat', SUN: 'Sun',
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')}`;
}
function balanceLabel(seconds: number): { text: string; color: string } { function balanceLabel(seconds: number): { text: string; color: string } {
if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
@@ -58,7 +35,12 @@ function ClientTargetPanel({
}: { }: {
client: Client; client: Client;
target: ClientTargetWithBalance | undefined; 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>; onDeleted: () => Promise<void>;
}) { }) {
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
@@ -69,7 +51,9 @@ function ClientTargetPanel({
// Create/edit form state // Create/edit form state
const [formHours, setFormHours] = useState(''); 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 [formError, setFormError] = useState<string | null>(null);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
@@ -81,13 +65,13 @@ function ClientTargetPanel({
const [corrError, setCorrError] = useState<string | null>(null); const [corrError, setCorrError] = useState<string | null>(null);
const [corrSaving, setCorrSaving] = useState(false); const [corrSaving, setCorrSaving] = useState(false);
const todayIso = new Date().toISOString().split('T')[0];
const openCreate = () => { const openCreate = () => {
setFormHours(''); setFormHours('');
const today = new Date(); setFormPeriodType('weekly');
const day = today.getUTCDay() || 7; setFormWorkingDays(['MON', 'TUE', 'WED', 'THU', 'FRI']);
const monday = new Date(today); setFormStartDate(todayIso);
monday.setUTCDate(today.getUTCDate() - day + 1);
setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0]));
setFormError(null); setFormError(null);
setEditing(false); setEditing(false);
setShowForm(true); setShowForm(true);
@@ -95,32 +79,56 @@ function ClientTargetPanel({
const openEdit = () => { const openEdit = () => {
if (!target) return; if (!target) return;
setFormHours(String(target.weeklyHours)); setFormHours(String(target.targetHours));
setFormWeek(mondayToWeekInput(target.startDate)); setFormPeriodType(target.periodType);
setFormWorkingDays([...target.workingDays]);
setFormStartDate(target.startDate);
setFormError(null); setFormError(null);
setEditing(true); setEditing(true);
setShowForm(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) => { const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
const hours = parseFloat(formHours); const hours = parseFloat(formHours);
if (isNaN(hours) || hours <= 0 || hours > 168) { 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; return;
} }
if (!formWeek) { if (formWorkingDays.length === 0) {
setFormError('Please select a start week'); setFormError('Select at least one working day');
return;
}
if (!formStartDate) {
setFormError('Please select a start date');
return; return;
} }
const startDate = weekInputToMonday(formWeek);
setFormSaving(true); setFormSaving(true);
try { try {
if (editing && target) { 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 { } else {
await onCreated(hours, startDate); await onCreated({
targetHours: hours,
periodType: formPeriodType,
workingDays: formWorkingDays,
startDate: formStartDate,
});
} }
setShowForm(false); setShowForm(false);
} catch (err) { } 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" 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" /> <Target className="h-3.5 w-3.5" />
Set weekly target Set target
</button> </button>
</div> </div>
); );
} }
if (showForm) { if (showForm) {
const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month';
return ( return (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2"> <p className="text-xs font-medium text-gray-700 mb-2">
{editing ? 'Edit target' : 'Set weekly target'} {editing ? 'Edit target' : 'Set target'}
</p> </p>
<form onSubmit={handleFormSubmit} className="space-y-2"> <form onSubmit={handleFormSubmit} className="space-y-2">
{formError && <p className="text-xs text-red-600">{formError}</p>} {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 gap-2">
<div className="flex-1"> <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 <input
type="number" type="number"
value={formHours} value={formHours}
@@ -215,16 +246,41 @@ function ClientTargetPanel({
/> />
</div> </div>
<div className="flex-1"> <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 <input
type="week" type="date"
value={formWeek} value={formStartDate}
onChange={e => setFormWeek(e.target.value)} onChange={e => setFormStartDate(e.target.value)}
className="input text-sm py-1" className="input text-sm py-1"
required required
/> />
</div> </div>
</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"> <div className="flex gap-2 justify-end">
<button <button
type="button" type="button"
@@ -248,6 +304,7 @@ function ClientTargetPanel({
// Target exists — show summary + expandable details // Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds); const balance = balanceLabel(target!.totalBalanceSeconds);
const periodLabel = target!.periodType === 'weekly' ? 'week' : 'month';
return ( return (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
@@ -256,9 +313,15 @@ function ClientTargetPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" /> <Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-600"> <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>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</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>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@@ -531,8 +594,14 @@ export function ClientsPage() {
<ClientTargetPanel <ClientTargetPanel
client={client} client={client}
target={target} target={target}
onCreated={async (weeklyHours, startDate) => { onCreated={async ({ targetHours, periodType, workingDays, startDate }) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate }); await createTarget.mutateAsync({
clientId: client.id,
targetHours,
periodType,
workingDays,
startDate,
});
}} }}
onDeleted={async () => { onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id); if (target) await deleteTarget.mutateAsync(target.id);

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react"; import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries"; import { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets"; import { useClientTargets } from "@/hooks/useClientTargets";
import { useTimer } from "@/contexts/TimerContext";
import { ProjectColorDot } from "@/components/ProjectColorDot"; import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal"; import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
@@ -30,6 +31,7 @@ export function DashboardPage() {
}); });
const { targets } = useClientTargets(); const { targets } = useClientTargets();
const { ongoingTimer, elapsedSeconds } = useTimer();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null); const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
@@ -54,12 +56,19 @@ export function DashboardPage() {
} }
}; };
const totalTodaySeconds = const completedTodaySeconds =
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) ?? []; // 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,6 +87,7 @@ export function DashboardPage() {
label="Today" label="Today"
value={formatDurationHoursMinutes(totalTodaySeconds)} value={formatDurationHoursMinutes(totalTodaySeconds)}
color="blue" color="blue"
indicator={timerStartedToday}
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
@@ -108,7 +118,7 @@ export function DashboardPage() {
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" /> <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>
<div className="space-y-3"> <div className="space-y-3">
{targetsWithData.map(target => { {targetsWithData.map(target => {
@@ -116,8 +126,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance); const absBalance = Math.abs(balance);
const isOver = balance > 0; const isOver = balance > 0;
const isEven = balance === 0; const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds); const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds); const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
return ( return (
<div <div
@@ -127,10 +138,17 @@ export function DashboardPage() {
<div> <div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p> <p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget} {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<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 <p
className={`text-sm font-bold ${ className={`text-sm font-bold ${
isEven isEven
@@ -144,6 +162,7 @@ export function DashboardPage() {
? '±0' ? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)} : (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p> </p>
</div>
<p className="text-xs text-gray-400">running balance</p> <p className="text-xs text-gray-400">running balance</p>
</div> </div>
</div> </div>
@@ -216,7 +235,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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -11,6 +11,7 @@ export interface Client {
description: string | null; description: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export interface Project { export interface Project {
@@ -22,12 +23,14 @@ export interface Project {
client: Pick<Client, 'id' | 'name'>; client: Pick<Client, 'id' | 'name'>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export interface TimeEntry { 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'> & {
@@ -35,6 +38,7 @@ export interface TimeEntry {
}; };
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export interface OngoingTimer { export interface OngoingTimer {
@@ -129,6 +133,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 +141,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;
} }
@@ -146,15 +152,22 @@ export interface BalanceCorrection {
hours: number; hours: number;
description: string | null; description: string | null;
createdAt: string; createdAt: string;
deletedAt: string | null;
} }
export interface WeekBalance { export interface PeriodBalance {
weekStart: string; // YYYY-MM-DD (Monday) periodStart: string; // YYYY-MM-DD
weekEnd: string; // YYYY-MM-DD (Sunday) periodEnd: string; // YYYY-MM-DD
targetHours: number; // pro-rated for first period
trackedSeconds: number; trackedSeconds: number;
targetSeconds: number;
correctionHours: number; correctionHours: number;
balanceSeconds: number; balanceSeconds: number;
isOngoing: boolean;
// only present when isOngoing = true
dailyRateHours?: number;
workingDaysInPeriod?: number;
elapsedWorkingDays?: number;
expectedHours?: number;
} }
export interface ClientTargetWithBalance { export interface ClientTargetWithBalance {
@@ -162,26 +175,34 @@ export interface ClientTargetWithBalance {
clientId: string; clientId: string;
clientName: string; clientName: string;
userId: string; userId: string;
weeklyHours: number; periodType: "weekly" | "monthly";
targetHours: number;
workingDays: string[]; // e.g. ["MON","WED"]
startDate: string; // YYYY-MM-DD startDate: string; // YYYY-MM-DD
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
corrections: BalanceCorrection[]; corrections: BalanceCorrection[];
totalBalanceSeconds: number; totalBalanceSeconds: number;
currentWeekTrackedSeconds: number; currentPeriodTrackedSeconds: number;
currentWeekTargetSeconds: number; currentPeriodTargetSeconds: number;
weeks: WeekBalance[]; periods: PeriodBalance[];
/** True when an active timer for a project belonging to this client is running. */
hasOngoingTimer: boolean;
} }
export interface CreateClientTargetInput { export interface CreateClientTargetInput {
clientId: string; clientId: string;
weeklyHours: number; targetHours: number;
periodType: "weekly" | "monthly";
workingDays: string[]; // e.g. ["MON","WED","FRI"]
startDate: string; // YYYY-MM-DD startDate: string; // YYYY-MM-DD
} }
export interface UpdateClientTargetInput { export interface UpdateClientTargetInput {
weeklyHours?: number; targetHours?: number;
startDate?: string; periodType?: "weekly" | "monthly";
workingDays?: string[];
startDate?: string; // YYYY-MM-DD
} }
export interface CreateCorrectionInput { export interface CreateCorrectionInput {

View File

@@ -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);
} }

View File

@@ -2,27 +2,19 @@ import SwiftUI
struct TimeEntriesView: View { struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel() @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 showFilterSheet = false
@State private var showAddEntry = false @State private var showAddEntry = false
@State private var entryToEdit: TimeEntry? @State private var entryToEdit: TimeEntry?
@State private var entryToDelete: TimeEntry? @State private var entryToDelete: TimeEntry?
@State private var showDeleteConfirmation = false @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 { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
@@ -37,6 +29,7 @@ struct TimeEntriesView: View {
} }
} }
.navigationTitle("Entries") .navigationTitle("Entries")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent } .toolbar { toolbarContent }
.task { await viewModel.loadEntries() } .task { await viewModel.loadEntries() }
.refreshable { 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") 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 // MARK: - Main content
private var mainContent: some View { private var mainContent: some View {
VStack(spacing: 0) { // Only 3 pages exist at any time: previous, current, next.
WeekStripView( // After each swipe settles, we reset tabSelection to 1 and shift
weekDays: visibleWeekDays, // dayOffset, so the carousel appears infinite while staying cheap.
selectedDay: $selectedDay, TabView(selection: $tabSelection) {
daysWithEntries: viewModel.daysWithEntries, ForEach(0..<3, id: \.self) { page in
onSwipeLeft: { let offset = dayOffset + (page - 1)
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: visibleWeekStart) ?? visibleWeekStart let day = Calendar.current.date(
}, byAdding: .day,
onSwipeRight: { value: offset,
visibleWeekStart = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: visibleWeekStart) ?? visibleWeekStart to: Calendar.current.startOfDay(for: Date())
} ) ?? Date()
)
Divider()
ScrollView { ScrollView {
if let day = selectedDay {
dayEntriesSection(for: day) 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 { private func dayEntriesSection(for day: Date) -> some View {
let dayEntries = viewModel.entries(for: day) let dayEntries = viewModel.entries(for: day)
return VStack(alignment: .leading, spacing: 0) { return VStack(alignment: .leading, spacing: 0) {
// Section header
// Optional: A small summary header for the day
HStack { HStack {
Text(dayTitle(day)) Text(dayTitle(day))
.font(.subheadline) .font(.subheadline)
@@ -155,41 +181,7 @@ struct TimeEntriesView: View {
} }
} }
} }
} .padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
// 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)
}
}
}
} }
// MARK: - Helpers // MARK: - Helpers
@@ -198,6 +190,7 @@ struct TimeEntriesView: View {
let cal = Calendar.current let cal = Calendar.current
if cal.isDateInToday(date) { return "Today" } if cal.isDateInToday(date) { return "Today" }
if cal.isDateInYesterday(date) { return "Yesterday" } if cal.isDateInYesterday(date) { return "Yesterday" }
if cal.isDateInTomorrow(date) { return "Tomorrow" }
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d" formatter.dateFormat = "EEEE, MMM d"
return formatter.string(from: date) 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 // MARK: - Filter Sheet

View File

@@ -1,6 +1,29 @@
{ {
"images" : [ "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", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB