Compare commits
84 Commits
6b3d0c342e
...
feature/mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7ab55932f | ||
|
|
64211e6a49 | ||
| cd03d8751e | |||
| 1964f76f74 | |||
| 1f4e12298e | |||
|
|
1049410fee | ||
| c9bd0abf18 | |||
| 7ec76e3e8e | |||
| 784e71e187 | |||
| 7677fdd73d | |||
| 924b83eb4d | |||
| 91d13b19db | |||
| 2a5e6d4a22 | |||
| b7bd875462 | |||
| a58dfcfa4a | |||
| 7101f38bc8 | |||
| 3850e2db06 | |||
| 5b7b8e47cb | |||
| 7dd3873148 | |||
| 850f12e09d | |||
| 74999ce265 | |||
| 0c0fbf42ef | |||
| 0d116c8c26 | |||
| 25b7371d08 | |||
|
|
ddb0926dba | ||
|
|
1b0f5866a1 | ||
|
|
159022ef38 | ||
|
|
1a7d13d5b9 | ||
|
|
685a311001 | ||
| d09247d2a5 | |||
|
|
078dc8c304 | ||
|
|
59eda58ee6 | ||
| d56eed8dde | |||
|
|
3fa13e1428 | ||
|
|
2e629d8017 | ||
|
|
6e0567d021 | ||
|
|
3ab39643dd | ||
|
|
e01e5e59df | ||
|
|
06596dcee9 | ||
|
|
7358fa6256 | ||
|
|
c99bdf56e6 | ||
|
|
15abfe0511 | ||
|
|
c9f5bbb25a | ||
|
|
544b86c948 | ||
|
|
b971569983 | ||
| b613fe4edd | |||
| 30d5139ad8 | |||
| ef38578596 | |||
| ba4765b8a2 | |||
| d37170fc5d | |||
| fc3b7d7c2c | |||
| 32cb200408 | |||
| e0dd2f1fbc | |||
| a39f8b07df | |||
| ed8a160a49 | |||
| f42de3353c | |||
| da0cd302bf | |||
| f758aa2fcd | |||
| e51dd58a6b | |||
| 39d6ea00d9 | |||
| 48cd82ab4f | |||
| 062af3b2da | |||
| 1aac76af4a | |||
| f1f60ef685 | |||
| bb2e51cd0a | |||
| 946cd35832 | |||
| 1ca76b0fec | |||
| b3db7cbd7b | |||
| f218552d48 | |||
| 0d084cd546 | |||
| 5f23961f50 | |||
| 7e8e220e3b | |||
| ed180500a6 | |||
| 165b1b9c67 | |||
| 2534011506 | |||
| 4c0d8be018 | |||
| b66b433c08 | |||
| 7659b01614 | |||
| 4e49741dfa | |||
| 4b0cfaa699 | |||
| 51c003cb0d | |||
| 859420c5d6 | |||
| 8b45fffd6e | |||
| 01502122b2 |
@@ -1,5 +1,10 @@
|
|||||||
APP_URL=
|
APP_URL=
|
||||||
OIDC_ISSUER_URL=
|
OIDC_ISSUER_URL=
|
||||||
OIDC_CLIENT_ID=
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_REDIRECT_URI=
|
||||||
|
OIDC_IOS_REDIRECT_URI=timetracker://oauth/callback
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
# Optional: dedicated signing secret for backend-issued JWTs (iOS auth).
|
||||||
|
# Falls back to SESSION_SECRET if not set.
|
||||||
|
JWT_SECRET=
|
||||||
API_URL=
|
API_URL=
|
||||||
126
AGENTS.md
Normal file
126
AGENTS.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# AGENTS.md — Codebase Guide for AI Coding Agents
|
||||||
|
|
||||||
|
This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. Read it in full before making changes.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── frontend/ # React SPA (Vite + TypeScript + Tailwind)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── api/ # Axios API client modules
|
||||||
|
│ ├── components/# Shared UI components (PascalCase .tsx)
|
||||||
|
│ ├── contexts/ # React Context providers
|
||||||
|
│ ├── hooks/ # TanStack React Query hooks (useXxx.ts)
|
||||||
|
│ ├── pages/ # Route-level page components
|
||||||
|
│ ├── types/ # TypeScript interfaces (index.ts)
|
||||||
|
│ └── utils/ # Pure utility functions
|
||||||
|
├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── auth/ # OIDC + JWT logic
|
||||||
|
│ ├── errors/ # AppError subclasses
|
||||||
|
│ ├── middleware/# Express middlewares
|
||||||
|
│ ├── prisma/ # Prisma client singleton
|
||||||
|
│ ├── routes/ # Express routers (xxx.routes.ts)
|
||||||
|
│ ├── schemas/ # Zod validation schemas
|
||||||
|
│ └── services/ # Business logic classes (xxx.service.ts)
|
||||||
|
├── ios/ # Native iOS app (Swift/Xcode)
|
||||||
|
├── timetracker-chart/ # Helm chart for Kubernetes deployment
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build, Lint, and Dev Commands
|
||||||
|
|
||||||
|
### Frontend (`frontend/`)
|
||||||
|
- **Dev Server:** `npm run dev` (port 5173)
|
||||||
|
- **Build:** `npm run build` (tsc & vite build)
|
||||||
|
- **Lint:** `npm run lint` (ESLint, zero warnings allowed)
|
||||||
|
- **Preview:** `npm run preview`
|
||||||
|
|
||||||
|
### Backend (`backend/`)
|
||||||
|
- **Dev Server:** `npm run dev` (tsx watch)
|
||||||
|
- **Build:** `npm run build` (tsc to dist/)
|
||||||
|
- **Start:** `npm run start` (node dist/index.js)
|
||||||
|
- **Database:**
|
||||||
|
- `npm run db:migrate` (Run migrations)
|
||||||
|
- `npm run db:generate` (Regenerate client)
|
||||||
|
- `npm run db:seed` (Seed database)
|
||||||
|
|
||||||
|
### Full Stack (Root)
|
||||||
|
- **Run all:** `docker-compose up`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
**No test framework is currently configured.** No test runner (`jest`, `vitest`) is installed and no `.spec.ts` or `.test.ts` files exist.
|
||||||
|
- When adding tests, set up **Vitest** (aligned with Vite).
|
||||||
|
- Add a `test` script to `package.json`.
|
||||||
|
- **To run a single test file with Vitest once installed:**
|
||||||
|
```bash
|
||||||
|
npx vitest run src/path/to/file.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports & Exports
|
||||||
|
- Use `@/` for all internal frontend imports: `import { useAuth } from "@/contexts/AuthContext"`
|
||||||
|
- Use `import type { ... }` for type-only imports. Order external libraries first.
|
||||||
|
- Named exports are standard. Avoid default exports (except in `App.tsx`).
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
- 2-space indentation. No Prettier config exists; maintain consistency with surrounding code.
|
||||||
|
- Prefer double quotes. Trailing commas in multi-line objects/arrays.
|
||||||
|
|
||||||
|
### Types & Naming Conventions
|
||||||
|
- Define shared types as `interface` in `types/index.ts`.
|
||||||
|
- Suffix input types: `CreateClientInput`.
|
||||||
|
- Use `?` for optional fields, `string | null` for nullable fields (not `undefined`).
|
||||||
|
- **Components:** `PascalCase.tsx` (`DashboardPage.tsx`)
|
||||||
|
- **Hooks/Utils/API:** `camelCase.ts` (`useTimeEntries.ts`, `dateUtils.ts`)
|
||||||
|
- **Backend Routes/Services:** `camelCase.routes.ts`, `camelCase.service.ts`
|
||||||
|
- **Backend Schemas:** Zod schemas in `backend/src/schemas/index.ts` (e.g., `CreateClientSchema`).
|
||||||
|
|
||||||
|
### React Components
|
||||||
|
- Use named function declarations: `export function DashboardPage() { ... }`
|
||||||
|
- Context hooks throw an error if called outside their provider.
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- **Server state:** TanStack React Query. Never use `useState` for server data.
|
||||||
|
- Use `mutateAsync` so callers can await and handle errors.
|
||||||
|
- Invalidate related queries after mutations: `queryClient.invalidateQueries`.
|
||||||
|
- **Shared client state:** React Context.
|
||||||
|
- **Local UI state:** `useState`.
|
||||||
|
- **NO Redux or Zustand.**
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- **Frontend:**
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
await someAsyncOperation()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "An error occurred")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Store errors in local state and render inline as red text. No global error boundary exists.
|
||||||
|
- **Backend:** Throw `AppError` subclasses from services.
|
||||||
|
```typescript
|
||||||
|
router.get("/:id", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await service.getById(req.params.id))
|
||||||
|
} catch (error) {
|
||||||
|
next(error) // Always forward to errorHandler middleware
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
- **Tailwind CSS v3** only. No CSS modules or styled-components.
|
||||||
|
- Use `clsx` + `tailwind-merge` for class merging. Icons from `lucide-react` only.
|
||||||
|
|
||||||
|
### Backend Validation & Database
|
||||||
|
- Validate all incoming request data with Zod schemas in middleware.
|
||||||
|
- Prisma v6 with PostgreSQL. Use the Prisma client singleton from `backend/src/prisma/`.
|
||||||
|
- DB columns are `snake_case`, mapped to `camelCase` TypeScript via `@map`.
|
||||||
|
|
||||||
|
## Key Architectural Decisions
|
||||||
|
- Frontend communicates with Backend exclusively via typed Axios modules in `frontend/src/api/`.
|
||||||
|
- iOS app shares no code with the web frontend.
|
||||||
|
- Backend routes only handle HTTP concerns (parsing, validation, formatting); business logic belongs purely in services.
|
||||||
@@ -78,6 +78,7 @@ DATABASE_URL="postgresql://user:password@localhost:5432/timetracker"
|
|||||||
# OIDC Configuration
|
# OIDC Configuration
|
||||||
OIDC_ISSUER_URL="https://your-oidc-provider.com"
|
OIDC_ISSUER_URL="https://your-oidc-provider.com"
|
||||||
OIDC_CLIENT_ID="your-client-id"
|
OIDC_CLIENT_ID="your-client-id"
|
||||||
|
OIDC_REDIRECT_URI="http://localhost:3001/auth/callback"
|
||||||
|
|
||||||
# Session
|
# Session
|
||||||
SESSION_SECRET="your-secure-session-secret-min-32-chars"
|
SESSION_SECRET="your-secure-session-secret-min-32-chars"
|
||||||
|
|||||||
1302
backend/package-lock.json
generated
1302
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,14 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts"
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@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",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"openid-client": "^5.6.1",
|
"openid-client": "^5.6.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@@ -22,6 +25,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.17.10",
|
"@types/express-session": "^1.17.10",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sid" TEXT NOT NULL,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sessions_sid_key" ON "sessions"("sid");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "time_entries" ADD COLUMN "break_minutes" INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable: add deleted_at column to clients
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to projects
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to time_entries
|
||||||
|
ALTER TABLE "time_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable: add deleted_at column to client_targets
|
||||||
|
ALTER TABLE "client_targets" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable: add deleted_at column to balance_corrections
|
||||||
|
ALTER TABLE "balance_corrections" ADD COLUMN "deleted_at" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PeriodType" AS ENUM ('WEEKLY', 'MONTHLY');
|
||||||
|
|
||||||
|
-- AlterTable: rename weekly_hours -> target_hours, add period_type, add working_days
|
||||||
|
ALTER TABLE "client_targets"
|
||||||
|
RENAME COLUMN "weekly_hours" TO "target_hours";
|
||||||
|
|
||||||
|
ALTER TABLE "client_targets"
|
||||||
|
ADD COLUMN "period_type" "PeriodType" NOT NULL DEFAULT 'WEEKLY',
|
||||||
|
ADD COLUMN "working_days" TEXT[] NOT NULL DEFAULT ARRAY['MON','TUE','WED','THU','FRI']::TEXT[];
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "api_keys" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"key_hash" VARCHAR(64) NOT NULL,
|
||||||
|
"prefix" VARCHAR(16) NOT NULL,
|
||||||
|
"last_used_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"user_id" VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -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")
|
||||||
@@ -20,20 +20,22 @@ model User {
|
|||||||
timeEntries TimeEntry[]
|
timeEntries TimeEntry[]
|
||||||
ongoingTimer OngoingTimer?
|
ongoingTimer OngoingTimer?
|
||||||
clientTargets ClientTarget[]
|
clientTargets ClientTarget[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Client {
|
model Client {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
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)
|
||||||
projects Project[]
|
projects Project[]
|
||||||
clientTargets ClientTarget[]
|
clientTargets ClientTarget[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -41,19 +43,20 @@ model Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
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)
|
||||||
clientId String @map("client_id")
|
clientId String @map("client_id")
|
||||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
timeEntries TimeEntry[]
|
timeEntries TimeEntry[]
|
||||||
ongoingTimers OngoingTimer[]
|
ongoingTimers OngoingTimer[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -62,16 +65,18 @@ model Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TimeEntry {
|
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()
|
||||||
description String? @db.Text
|
breakMinutes Int @default(0) @map("break_minutes")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
description String? @db.Text
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
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)
|
||||||
projectId String @map("project_id")
|
projectId String @map("project_id")
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -86,21 +91,29 @@ 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)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@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")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"]
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
startDate DateTime @map("start_date") @db.Date
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
userId String @map("user_id") @db.VarChar(255)
|
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)
|
||||||
@@ -116,16 +129,41 @@ model ClientTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model BalanceCorrection {
|
model BalanceCorrection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
date DateTime @map("date") @db.Date
|
date DateTime @map("date") @db.Date
|
||||||
hours Float
|
hours Float
|
||||||
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)
|
||||||
|
|
||||||
@@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")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
keyHash String @unique @map("key_hash") @db.VarChar(64) // SHA-256 hex
|
||||||
|
prefix String @db.VarChar(16) // first chars of raw key for display
|
||||||
|
lastUsedAt DateTime? @map("last_used_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
userId String @map("user_id") @db.VarChar(255)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("api_keys")
|
||||||
|
}
|
||||||
|
|||||||
45
backend/src/auth/jwt.ts
Normal file
45
backend/src/auth/jwt.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { config } from '../config';
|
||||||
|
import type { AuthenticatedUser } from '../types';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a backend-signed JWT for a native (iOS) client.
|
||||||
|
* The token is self-contained — no IDP call is needed to verify it.
|
||||||
|
*/
|
||||||
|
export function signBackendJwt(user: AuthenticatedUser): string {
|
||||||
|
const payload: JwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
username: user.username,
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jwt.sign(payload, config.jwt.secret, {
|
||||||
|
expiresIn: config.jwt.expiresIn,
|
||||||
|
algorithm: 'HS256',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a backend-signed JWT and return the encoded user.
|
||||||
|
* Throws if the token is invalid or expired.
|
||||||
|
*/
|
||||||
|
export function verifyBackendJwt(token: string): AuthenticatedUser {
|
||||||
|
const payload = jwt.verify(token, config.jwt.secret, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
}) as JwtPayload;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: payload.sub,
|
||||||
|
username: payload.username,
|
||||||
|
fullName: payload.fullName,
|
||||||
|
email: payload.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,29 +1,38 @@
|
|||||||
import { Issuer, generators, Client, TokenSet } from "openid-client";
|
import { Issuer, generators, Client, TokenSet } from 'openid-client';
|
||||||
import { config } from "../config";
|
import { config } from '../config';
|
||||||
import type { AuthenticatedUser } from "../types";
|
import type { AuthenticatedUser } from '../types';
|
||||||
|
|
||||||
|
// Note: bearer-token (JWT) verification for native clients lives in auth/jwt.ts.
|
||||||
|
// This module is responsible solely for the OIDC protocol flows.
|
||||||
|
|
||||||
let oidcClient: Client | null = null;
|
let oidcClient: Client | null = null;
|
||||||
|
|
||||||
export async function initializeOIDC(): Promise<void> {
|
export async function initializeOIDC(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const issuer = await Issuer.discover(config.oidc.issuerUrl);
|
const issuer = await Issuer.discover(config.oidc.issuerUrl);
|
||||||
|
|
||||||
|
const redirectUris = [config.oidc.redirectUri];
|
||||||
|
if (config.oidc.iosRedirectUri) {
|
||||||
|
redirectUris.push(config.oidc.iosRedirectUri);
|
||||||
|
}
|
||||||
|
|
||||||
oidcClient = new issuer.Client({
|
oidcClient = new issuer.Client({
|
||||||
client_id: config.oidc.clientId,
|
client_id: config.oidc.clientId,
|
||||||
response_types: ["code"],
|
redirect_uris: redirectUris,
|
||||||
token_endpoint_auth_method: "none", // PKCE flow - no client secret
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none', // PKCE flow - no client secret
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("OIDC client initialized");
|
console.log('OIDC client initialized');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to initialize OIDC client:", error);
|
console.error('Failed to initialize OIDC client:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOIDCClient(): Client {
|
export function getOIDCClient(): Client {
|
||||||
if (!oidcClient) {
|
if (!oidcClient) {
|
||||||
throw new Error("OIDC client not initialized");
|
throw new Error('OIDC client not initialized');
|
||||||
}
|
}
|
||||||
return oidcClient;
|
return oidcClient;
|
||||||
}
|
}
|
||||||
@@ -31,14 +40,21 @@ export function getOIDCClient(): Client {
|
|||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
codeVerifier: string;
|
codeVerifier: string;
|
||||||
state: string;
|
state: string;
|
||||||
nonce: string;
|
nonce: string | undefined;
|
||||||
|
redirectUri: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAuthSession(): AuthSession {
|
export function createAuthSession(redirectUri: string): AuthSession {
|
||||||
|
const isNative = redirectUri.startsWith('timetracker://');
|
||||||
return {
|
return {
|
||||||
codeVerifier: generators.codeVerifier(),
|
codeVerifier: generators.codeVerifier(),
|
||||||
state: generators.state(),
|
state: generators.state(),
|
||||||
nonce: generators.nonce(),
|
// Nonce is omitted for native/PKCE-only flows. PKCE itself binds the code
|
||||||
|
// exchange so nonce provides no additional security. Some providers also
|
||||||
|
// don't echo the nonce back in the ID token for public clients, which
|
||||||
|
// causes openid-client to throw a nonce mismatch error.
|
||||||
|
nonce: isNative ? undefined : generators.nonce(),
|
||||||
|
redirectUri,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,74 +62,99 @@ export function getAuthorizationUrl(session: AuthSession): string {
|
|||||||
const client = getOIDCClient();
|
const client = getOIDCClient();
|
||||||
const codeChallenge = generators.codeChallenge(session.codeVerifier);
|
const codeChallenge = generators.codeChallenge(session.codeVerifier);
|
||||||
|
|
||||||
return client.authorizationUrl({
|
const params: Record<string, string> = {
|
||||||
scope: "openid profile email",
|
scope: 'openid profile email',
|
||||||
state: session.state,
|
state: session.state,
|
||||||
nonce: session.nonce,
|
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: 'S256',
|
||||||
});
|
redirect_uri: session.redirectUri,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (session.nonce) {
|
||||||
|
params.nonce = session.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.authorizationUrl(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCallback(
|
export async function handleCallback(
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
session: AuthSession,
|
session: AuthSession
|
||||||
): Promise<TokenSet> {
|
): Promise<TokenSet> {
|
||||||
const client = getOIDCClient();
|
const client = getOIDCClient();
|
||||||
|
|
||||||
const tokenSet = await client.callback(undefined, params, {
|
const checks: Record<string, string | undefined> = {
|
||||||
code_verifier: session.codeVerifier,
|
code_verifier: session.codeVerifier,
|
||||||
state: session.state,
|
state: session.state,
|
||||||
nonce: session.nonce,
|
};
|
||||||
|
|
||||||
|
if (session.nonce) {
|
||||||
|
checks.nonce = session.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenSet = await client.callback(
|
||||||
|
session.redirectUri,
|
||||||
|
params,
|
||||||
|
checks,
|
||||||
|
);
|
||||||
|
|
||||||
|
return tokenSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For native app flows the provider may return only an access token (no ID token)
|
||||||
|
// when the redirect_uri uses a custom scheme. client.grant() calls the token
|
||||||
|
// endpoint directly and does not attempt ID token validation.
|
||||||
|
export async function exchangeNativeCode(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
const client = getOIDCClient();
|
||||||
|
|
||||||
|
const tokenSet = await client.grant({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
return tokenSet;
|
return tokenSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserInfo(
|
export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser> {
|
||||||
tokenSet: TokenSet,
|
|
||||||
): Promise<AuthenticatedUser> {
|
|
||||||
const client = getOIDCClient();
|
const client = getOIDCClient();
|
||||||
|
|
||||||
const claims = tokenSet.claims();
|
// ID token claims (only available in web/full OIDC flow)
|
||||||
|
const idTokenClaims = tokenSet.id_token ? tokenSet.claims() : undefined;
|
||||||
|
|
||||||
// Try to get more detailed userinfo if available
|
// Always attempt userinfo; for native flows this is the sole source of claims.
|
||||||
let userInfo: Record<string, unknown> = {};
|
let userInfo: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
userInfo = await client.userinfo(tokenSet);
|
userInfo = await client.userinfo(tokenSet);
|
||||||
} catch {
|
} catch {
|
||||||
// Some providers don't support userinfo endpoint
|
if (!idTokenClaims) {
|
||||||
// We'll use the claims from the ID token
|
// No ID token and no userinfo — nothing to work with.
|
||||||
|
throw new Error('Unable to retrieve user info: userinfo endpoint failed and no ID token present');
|
||||||
|
}
|
||||||
|
// Web flow: fall back to ID token claims only
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = String(claims.sub);
|
const sub = String(userInfo.sub || idTokenClaims?.sub);
|
||||||
|
const id = sub;
|
||||||
const username = String(
|
const username = String(
|
||||||
userInfo.preferred_username ||
|
userInfo.preferred_username ||
|
||||||
claims.preferred_username ||
|
idTokenClaims?.preferred_username ||
|
||||||
claims.name ||
|
userInfo.name ||
|
||||||
id,
|
idTokenClaims?.name ||
|
||||||
|
id
|
||||||
);
|
);
|
||||||
const email = String(userInfo.email || claims.email || "");
|
const email = String(userInfo.email || idTokenClaims?.email || '');
|
||||||
const fullName = String(userInfo.name || claims.name || "") || null;
|
const fullName = String(userInfo.name || idTokenClaims?.name || '') || null;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new Error("Email not provided by OIDC provider");
|
throw new Error('Email not provided by OIDC provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { id, username, fullName, email };
|
||||||
id,
|
|
||||||
username,
|
|
||||||
fullName,
|
|
||||||
email,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyToken(tokenSet: TokenSet): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const client = getOIDCClient();
|
|
||||||
await client.userinfo(tokenSet);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export const config = {
|
|||||||
oidc: {
|
oidc: {
|
||||||
issuerUrl: process.env.OIDC_ISSUER_URL || "",
|
issuerUrl: process.env.OIDC_ISSUER_URL || "",
|
||||||
clientId: process.env.OIDC_CLIENT_ID || "",
|
clientId: process.env.OIDC_CLIENT_ID || "",
|
||||||
|
redirectUri:
|
||||||
|
process.env.OIDC_REDIRECT_URI ||
|
||||||
|
"http://localhost:3001/api/auth/callback",
|
||||||
|
iosRedirectUri: process.env.OIDC_IOS_REDIRECT_URI || "timetracker://oauth/callback",
|
||||||
},
|
},
|
||||||
|
|
||||||
session: {
|
session: {
|
||||||
@@ -21,6 +25,13 @@ export const config = {
|
|||||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
},
|
},
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
// Dedicated secret for backend-issued JWTs. Falls back to SESSION_SECRET so
|
||||||
|
// existing single-secret deployments work without any config change.
|
||||||
|
secret: process.env.JWT_SECRET || process.env.SESSION_SECRET || "default-secret-change-in-production",
|
||||||
|
expiresIn: 30 * 24 * 60 * 60, // 30 days in seconds
|
||||||
|
},
|
||||||
|
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.APP_URL || "http://localhost:5173",
|
origin: process.env.APP_URL || "http://localhost:5173",
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,6 +13,8 @@ import projectRoutes from "./routes/project.routes";
|
|||||||
import timeEntryRoutes from "./routes/timeEntry.routes";
|
import timeEntryRoutes from "./routes/timeEntry.routes";
|
||||||
import timerRoutes from "./routes/timer.routes";
|
import timerRoutes from "./routes/timer.routes";
|
||||||
import clientTargetRoutes from "./routes/clientTarget.routes";
|
import clientTargetRoutes from "./routes/clientTarget.routes";
|
||||||
|
import apiKeyRoutes from "./routes/apiKey.routes";
|
||||||
|
import mcpRoutes from "./routes/mcp.routes";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
@@ -22,6 +25,8 @@ async function main() {
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
@@ -41,6 +46,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,
|
||||||
@@ -62,6 +72,8 @@ async function main() {
|
|||||||
app.use("/time-entries", timeEntryRoutes);
|
app.use("/time-entries", timeEntryRoutes);
|
||||||
app.use("/timer", timerRoutes);
|
app.use("/timer", timerRoutes);
|
||||||
app.use("/client-targets", clientTargetRoutes);
|
app.use("/client-targets", clientTargetRoutes);
|
||||||
|
app.use("/api-keys", apiKeyRoutes);
|
||||||
|
app.use("/mcp", mcpRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -1,19 +1,70 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { prisma } from '../prisma/client';
|
import { prisma } from '../prisma/client';
|
||||||
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||||
|
import { verifyBackendJwt } from '../auth/jwt';
|
||||||
|
import { ApiKeyService } from '../services/apiKey.service';
|
||||||
|
|
||||||
export function requireAuth(
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
|
export async function requireAuth(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!req.session?.user) {
|
const tag = `[requireAuth] ${req.method} ${req.path}`;
|
||||||
res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
return;
|
// 1. Session-based auth (web frontend)
|
||||||
|
if (req.session?.user) {
|
||||||
|
console.log(`${tag} -> session auth OK (user: ${req.session.user.id})`);
|
||||||
|
req.user = req.session.user as AuthenticatedUser;
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = req.session.user as AuthenticatedUser;
|
// 2. Bearer token auth (JWT or API key)
|
||||||
next();
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`);
|
||||||
|
|
||||||
|
// 2a. API key — detected by the "sk_" prefix
|
||||||
|
if (token.startsWith('sk_')) {
|
||||||
|
try {
|
||||||
|
const user = await apiKeyService.verify(token);
|
||||||
|
if (!user) {
|
||||||
|
console.warn(`${tag} -> API key verification failed: key not found`);
|
||||||
|
res.status(401).json({ error: 'Unauthorized: invalid API key' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
console.log(`${tag} -> API key auth OK (user: ${req.user.id})`);
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`${tag} -> API key verification error: ${message}`);
|
||||||
|
res.status(401).json({ error: `Unauthorized: ${message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. JWT (iOS / native clients)
|
||||||
|
try {
|
||||||
|
req.user = verifyBackendJwt(token);
|
||||||
|
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`${tag} -> JWT verification failed: ${message}`);
|
||||||
|
res.status(401).json({ error: `Unauthorized: ${message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader) {
|
||||||
|
console.warn(`${tag} -> Authorization header present but not a Bearer token: "${authHeader.slice(0, 30)}…"`);
|
||||||
|
} else {
|
||||||
|
console.warn(`${tag} -> No session and no Authorization header`);
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionalAuth(
|
export function optionalAuth(
|
||||||
@@ -42,4 +93,4 @@ export async function syncUser(user: AuthenticatedUser): Promise<void> {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
51
backend/src/routes/apiKey.routes.ts
Normal file
51
backend/src/routes/apiKey.routes.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
import { validateBody, validateParams } from '../middleware/validation';
|
||||||
|
import { ApiKeyService } from '../services/apiKey.service';
|
||||||
|
import { CreateApiKeySchema, IdSchema } from '../schemas';
|
||||||
|
import type { AuthenticatedRequest } from '../types';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const apiKeyService = new ApiKeyService();
|
||||||
|
|
||||||
|
// GET /api-keys - List user's API keys
|
||||||
|
router.get('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await apiKeyService.list(req.user!.id);
|
||||||
|
res.json(keys);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api-keys - Create a new API key
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireAuth,
|
||||||
|
validateBody(CreateApiKeySchema),
|
||||||
|
async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
try {
|
||||||
|
const created = await apiKeyService.create(req.user!.id, req.body.name);
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api-keys/:id - Revoke an API key
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
requireAuth,
|
||||||
|
validateParams(IdSchema),
|
||||||
|
async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
try {
|
||||||
|
await apiKeyService.delete(req.params.id, req.user!.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -4,10 +4,14 @@ import {
|
|||||||
createAuthSession,
|
createAuthSession,
|
||||||
getAuthorizationUrl,
|
getAuthorizationUrl,
|
||||||
handleCallback,
|
handleCallback,
|
||||||
|
exchangeNativeCode,
|
||||||
getUserInfo,
|
getUserInfo,
|
||||||
} from "../auth/oidc";
|
} from "../auth/oidc";
|
||||||
|
import { signBackendJwt } from "../auth/jwt";
|
||||||
import { requireAuth, syncUser } from "../middleware/auth";
|
import { requireAuth, syncUser } from "../middleware/auth";
|
||||||
|
import { config } from "../config";
|
||||||
import type { AuthenticatedRequest } from "../types";
|
import type { AuthenticatedRequest } from "../types";
|
||||||
|
import type { AuthSession } from "../auth/oidc";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -21,23 +25,62 @@ async function ensureOIDC() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short-lived store for native app OIDC sessions, keyed by state.
|
||||||
|
// Entries are cleaned up after 10 minutes regardless of use.
|
||||||
|
const nativeOidcSessions = new Map<string, { session: AuthSession; expiresAt: number }>();
|
||||||
|
const NATIVE_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function storeNativeSession(session: AuthSession): void {
|
||||||
|
nativeOidcSessions.set(session.state, {
|
||||||
|
session,
|
||||||
|
expiresAt: Date.now() + NATIVE_SESSION_TTL_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function popNativeSession(state: string): AuthSession | null {
|
||||||
|
const entry = nativeOidcSessions.get(state);
|
||||||
|
if (!entry) return null;
|
||||||
|
nativeOidcSessions.delete(state);
|
||||||
|
if (Date.now() > entry.expiresAt) return null;
|
||||||
|
return entry.session;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /auth/login - Initiate OIDC login flow
|
// GET /auth/login - Initiate OIDC login flow
|
||||||
router.get("/login", async (req, res) => {
|
router.get("/login", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureOIDC();
|
await ensureOIDC();
|
||||||
|
|
||||||
const session = createAuthSession();
|
const isNativeFlow = !!req.query.redirect_uri;
|
||||||
req.session.oidc = session;
|
// For the web flow no redirect_uri is supplied in the request — use the
|
||||||
|
// backend-configured value so the IDP always receives an explicit URI
|
||||||
|
// rather than relying on the openid-client library to pick a default.
|
||||||
|
const redirectUri = isNativeFlow
|
||||||
|
? (req.query.redirect_uri as string)
|
||||||
|
: config.oidc.redirectUri;
|
||||||
|
|
||||||
|
console.log(`[auth/login] initiated (redirect_uri: ${redirectUri})`);
|
||||||
|
const session = createAuthSession(redirectUri);
|
||||||
|
|
||||||
|
if (isNativeFlow) {
|
||||||
|
// Native app flow: store session by state so /auth/token can retrieve it
|
||||||
|
// without relying on the browser cookie jar.
|
||||||
|
storeNativeSession(session);
|
||||||
|
console.log(`[auth/login] native session stored (state: ${session.state})`);
|
||||||
|
} else {
|
||||||
|
// Web flow: store session in the cookie-backed server session as before.
|
||||||
|
req.session.oidc = session;
|
||||||
|
}
|
||||||
|
|
||||||
const authorizationUrl = getAuthorizationUrl(session);
|
const authorizationUrl = getAuthorizationUrl(session);
|
||||||
|
console.log(`[auth/login] redirecting to IDP`);
|
||||||
res.redirect(authorizationUrl);
|
res.redirect(authorizationUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error);
|
console.error("[auth/login] error:", error);
|
||||||
res.status(500).json({ error: "Failed to initiate login" });
|
res.status(500).json({ error: "Failed to initiate login" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /auth/callback - OIDC callback handler
|
// GET /auth/callback - OIDC callback handler (web frontend only)
|
||||||
router.get("/callback", async (req, res) => {
|
router.get("/callback", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensureOIDC();
|
await ensureOIDC();
|
||||||
@@ -88,4 +131,54 @@ router.get("/me", requireAuth, (req: AuthenticatedRequest, res) => {
|
|||||||
res.json(req.user);
|
res.json(req.user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /auth/token - Exchange OIDC authorization code for a backend JWT (native app flow).
|
||||||
|
// The iOS app calls this after the OIDC redirect; it receives a backend-signed JWT which
|
||||||
|
// it then uses as a Bearer token for all subsequent API requests. The backend verifies
|
||||||
|
// this JWT locally — no per-request IDP call is needed.
|
||||||
|
router.post("/token", async (req, res) => {
|
||||||
|
try {
|
||||||
|
await ensureOIDC();
|
||||||
|
|
||||||
|
const { code, state, redirect_uri } = req.body;
|
||||||
|
console.log(`[auth/token] received (state: ${state}, redirect_uri: ${redirect_uri}, code present: ${!!code})`);
|
||||||
|
|
||||||
|
if (!code || !state || !redirect_uri) {
|
||||||
|
const missing = ['code', 'state', 'redirect_uri'].filter(k => !req.body[k]);
|
||||||
|
console.warn(`[auth/token] missing parameters: ${missing.join(', ')}`);
|
||||||
|
res.status(400).json({ error: `Missing required parameters: ${missing.join(', ')}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcSession = popNativeSession(state);
|
||||||
|
if (!oidcSession) {
|
||||||
|
console.warn(`[auth/token] no session found for state "${state}" — known states: [${[...nativeOidcSessions.keys()].join(', ')}]`);
|
||||||
|
res.status(400).json({ error: "OIDC session not found or expired. Initiate login again." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[auth/token] session found, exchanging code with IDP`);
|
||||||
|
|
||||||
|
const tokenSet = await exchangeNativeCode(code, oidcSession.codeVerifier, redirect_uri);
|
||||||
|
console.log(`[auth/token] IDP code exchange OK (access_token present: ${!!tokenSet.access_token}, id_token present: ${!!tokenSet.id_token})`);
|
||||||
|
|
||||||
|
const user = await getUserInfo(tokenSet);
|
||||||
|
console.log(`[auth/token] user resolved (id: ${user.id}, email: ${user.email})`);
|
||||||
|
await syncUser(user);
|
||||||
|
|
||||||
|
// Mint a backend JWT. The iOS app stores this and sends it as Bearer <token>.
|
||||||
|
const backendJwt = signBackendJwt(user);
|
||||||
|
console.log(`[auth/token] backend JWT minted for user ${user.id}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
access_token: backendJwt,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("[auth/token] error:", error);
|
||||||
|
res.status(500).json({ error: `Failed to exchange token: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
455
backend/src/routes/mcp.routes.ts
Normal file
455
backend/src/routes/mcp.routes.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { requireAuth } from '../middleware/auth';
|
||||||
|
import type { AuthenticatedRequest, AuthenticatedUser } from '../types';
|
||||||
|
import { ClientService } from '../services/client.service';
|
||||||
|
import { ProjectService } from '../services/project.service';
|
||||||
|
import { TimeEntryService } from '../services/timeEntry.service';
|
||||||
|
import { TimerService } from '../services/timer.service';
|
||||||
|
import { ClientTargetService } from '../services/clientTarget.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Service instances — shared, stateless
|
||||||
|
const clientService = new ClientService();
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
const timeEntryService = new TimeEntryService();
|
||||||
|
const timerService = new TimerService();
|
||||||
|
const clientTargetService = new ClientTargetService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and return a fresh stateless McpServer pre-populated with all tools
|
||||||
|
* scoped to the given authenticated user.
|
||||||
|
*/
|
||||||
|
function buildMcpServer(user: AuthenticatedUser): McpServer {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: 'timetracker',
|
||||||
|
version: '1.0.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = user.id;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Clients
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_clients',
|
||||||
|
{
|
||||||
|
description: 'List all clients for the authenticated user.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const clients = await clientService.findAll(userId);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(clients, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'create_client',
|
||||||
|
{
|
||||||
|
description: 'Create a new client.',
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).max(255).describe('Client name'),
|
||||||
|
description: z.string().max(1000).optional().describe('Optional description'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name, description }) => {
|
||||||
|
const client = await clientService.create(userId, { name, description });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'update_client',
|
||||||
|
{
|
||||||
|
description: 'Update an existing client.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Client ID'),
|
||||||
|
name: z.string().min(1).max(255).optional().describe('New name'),
|
||||||
|
description: z.string().max(1000).optional().describe('New description'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, name, description }) => {
|
||||||
|
const client = await clientService.update(id, userId, { name, description });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(client, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'delete_client',
|
||||||
|
{
|
||||||
|
description: 'Soft-delete a client (and its projects).',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Client ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
await clientService.delete(id, userId);
|
||||||
|
return { content: [{ type: 'text', text: `Client ${id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Projects
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_projects',
|
||||||
|
{
|
||||||
|
description: 'List all projects, optionally filtered by clientId.',
|
||||||
|
inputSchema: {
|
||||||
|
clientId: z.string().uuid().optional().describe('Filter by client ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ clientId }) => {
|
||||||
|
const projects = await projectService.findAll(userId, clientId);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(projects, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'create_project',
|
||||||
|
{
|
||||||
|
description: 'Create a new project under a client.',
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1).max(255).describe('Project name'),
|
||||||
|
clientId: z.string().uuid().describe('Client ID the project belongs to'),
|
||||||
|
description: z.string().max(1000).optional().describe('Optional description'),
|
||||||
|
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional().describe('Hex color code, e.g. #FF5733'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ name, clientId, description, color }) => {
|
||||||
|
const project = await projectService.create(userId, { name, clientId, description, color });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'update_project',
|
||||||
|
{
|
||||||
|
description: 'Update an existing project.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Project ID'),
|
||||||
|
name: z.string().min(1).max(255).optional().describe('New name'),
|
||||||
|
description: z.string().max(1000).optional().describe('New description'),
|
||||||
|
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).nullable().optional().describe('Hex color or null to clear'),
|
||||||
|
clientId: z.string().uuid().optional().describe('Move project to a different client'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, name, description, color, clientId }) => {
|
||||||
|
const project = await projectService.update(id, userId, { name, description, color, clientId });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(project, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'delete_project',
|
||||||
|
{
|
||||||
|
description: 'Soft-delete a project.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Project ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
await projectService.delete(id, userId);
|
||||||
|
return { content: [{ type: 'text', text: `Project ${id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Time entries
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_time_entries',
|
||||||
|
{
|
||||||
|
description: 'List time entries with optional filters. Returns paginated results.',
|
||||||
|
inputSchema: {
|
||||||
|
startDate: z.string().datetime().optional().describe('Filter entries starting at or after this ISO datetime'),
|
||||||
|
endDate: z.string().datetime().optional().describe('Filter entries starting at or before this ISO datetime'),
|
||||||
|
projectId: z.string().uuid().optional().describe('Filter by project ID'),
|
||||||
|
clientId: z.string().uuid().optional().describe('Filter by client ID'),
|
||||||
|
page: z.number().int().min(1).optional().default(1).describe('Page number (default 1)'),
|
||||||
|
limit: z.number().int().min(1).max(100).optional().default(50).describe('Results per page (max 100, default 50)'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (filters) => {
|
||||||
|
const result = await timeEntryService.findAll(userId, filters);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'create_time_entry',
|
||||||
|
{
|
||||||
|
description: 'Create a manual time entry.',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.string().uuid().describe('Project ID'),
|
||||||
|
startTime: z.string().datetime().describe('Start time as ISO datetime string'),
|
||||||
|
endTime: z.string().datetime().describe('End time as ISO datetime string'),
|
||||||
|
breakMinutes: z.number().int().min(0).optional().describe('Break duration in minutes (default 0)'),
|
||||||
|
description: z.string().max(1000).optional().describe('Optional description'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ projectId, startTime, endTime, breakMinutes, description }) => {
|
||||||
|
const entry = await timeEntryService.create(userId, { projectId, startTime, endTime, breakMinutes, description });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'update_time_entry',
|
||||||
|
{
|
||||||
|
description: 'Update an existing time entry.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Time entry ID'),
|
||||||
|
startTime: z.string().datetime().optional().describe('New start time'),
|
||||||
|
endTime: z.string().datetime().optional().describe('New end time'),
|
||||||
|
breakMinutes: z.number().int().min(0).optional().describe('New break duration in minutes'),
|
||||||
|
description: z.string().max(1000).optional().describe('New description'),
|
||||||
|
projectId: z.string().uuid().optional().describe('Move to a different project'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, ...data }) => {
|
||||||
|
const entry = await timeEntryService.update(id, userId, data);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'delete_time_entry',
|
||||||
|
{
|
||||||
|
description: 'Delete a time entry.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Time entry ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
await timeEntryService.delete(id, userId);
|
||||||
|
return { content: [{ type: 'text', text: `Time entry ${id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_statistics',
|
||||||
|
{
|
||||||
|
description: 'Get aggregated time-tracking statistics, grouped by project and client.',
|
||||||
|
inputSchema: {
|
||||||
|
startDate: z.string().datetime().optional().describe('Filter from this ISO datetime'),
|
||||||
|
endDate: z.string().datetime().optional().describe('Filter until this ISO datetime'),
|
||||||
|
projectId: z.string().uuid().optional().describe('Filter by project ID'),
|
||||||
|
clientId: z.string().uuid().optional().describe('Filter by client ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (filters) => {
|
||||||
|
const stats = await timeEntryService.getStatistics(userId, filters);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Timer
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'get_timer',
|
||||||
|
{
|
||||||
|
description: 'Get the current running timer, or null if none is active.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const timer = await timerService.getOngoingTimer(userId);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'start_timer',
|
||||||
|
{
|
||||||
|
description: 'Start a new timer. Fails if a timer is already running.',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.string().uuid().optional().describe('Assign the timer to a project (can be set later)'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ projectId }) => {
|
||||||
|
const timer = await timerService.start(userId, { projectId });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(timer, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'stop_timer',
|
||||||
|
{
|
||||||
|
description: 'Stop the running timer and save it as a time entry. A project must be assigned.',
|
||||||
|
inputSchema: {
|
||||||
|
projectId: z.string().uuid().optional().describe('Assign/override the project before stopping'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ projectId }) => {
|
||||||
|
const entry = await timerService.stop(userId, { projectId });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'cancel_timer',
|
||||||
|
{
|
||||||
|
description: 'Cancel the running timer without saving a time entry.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await timerService.cancel(userId);
|
||||||
|
return { content: [{ type: 'text', text: 'Timer cancelled.' }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Client targets
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'list_client_targets',
|
||||||
|
{
|
||||||
|
description: 'List all client hour targets with computed balance for each period.',
|
||||||
|
inputSchema: {},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const targets = await clientTargetService.findAll(userId);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(targets, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'create_client_target',
|
||||||
|
{
|
||||||
|
description: 'Create a new hour target for a client.',
|
||||||
|
inputSchema: {
|
||||||
|
clientId: z.string().uuid().describe('Client ID'),
|
||||||
|
targetHours: z.number().positive().max(168).describe('Target hours per period'),
|
||||||
|
periodType: z.enum(['weekly', 'monthly']).describe('Period type: weekly or monthly'),
|
||||||
|
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).describe('Working days, e.g. ["MON","TUE","WED","THU","FRI"]'),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date in YYYY-MM-DD format'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (data) => {
|
||||||
|
const target = await clientTargetService.create(userId, data);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'update_client_target',
|
||||||
|
{
|
||||||
|
description: 'Update an existing client hour target.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Target ID'),
|
||||||
|
targetHours: z.number().positive().max(168).optional().describe('New target hours per period'),
|
||||||
|
periodType: z.enum(['weekly', 'monthly']).optional().describe('New period type'),
|
||||||
|
workingDays: z.array(z.enum(['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'])).min(1).optional().describe('New working days'),
|
||||||
|
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('New start date in YYYY-MM-DD'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id, ...data }) => {
|
||||||
|
const target = await clientTargetService.update(id, userId, data);
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(target, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'delete_client_target',
|
||||||
|
{
|
||||||
|
description: 'Delete a client hour target.',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.string().uuid().describe('Target ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
await clientTargetService.delete(id, userId);
|
||||||
|
return { content: [{ type: 'text', text: `Client target ${id} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'add_target_correction',
|
||||||
|
{
|
||||||
|
description: 'Add a manual hour correction to a client target (e.g. for holidays or overtime carry-over).',
|
||||||
|
inputSchema: {
|
||||||
|
targetId: z.string().uuid().describe('Client target ID'),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Date of correction in YYYY-MM-DD format'),
|
||||||
|
hours: z.number().min(-1000).max(1000).describe('Hours to add (negative to deduct)'),
|
||||||
|
description: z.string().max(255).optional().describe('Optional reason for the correction'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ targetId, date, hours, description }) => {
|
||||||
|
const correction = await clientTargetService.addCorrection(targetId, userId, { date, hours, description });
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(correction, null, 2) }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'delete_target_correction',
|
||||||
|
{
|
||||||
|
description: 'Delete a manual hour correction from a client target.',
|
||||||
|
inputSchema: {
|
||||||
|
targetId: z.string().uuid().describe('Client target ID'),
|
||||||
|
correctionId: z.string().uuid().describe('Correction ID'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ targetId, correctionId }) => {
|
||||||
|
await clientTargetService.deleteCorrection(targetId, correctionId, userId);
|
||||||
|
return { content: [{ type: 'text', text: `Correction ${correctionId} deleted.` }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route handler — one fresh McpServer + transport per request (stateless)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleMcpRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
const user = req.user!;
|
||||||
|
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
const mcpServer = buildMcpServer(user);
|
||||||
|
|
||||||
|
// Ensure the server is cleaned up when the response finishes
|
||||||
|
res.on('close', () => {
|
||||||
|
transport.close().catch(() => undefined);
|
||||||
|
mcpServer.close().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
await mcpServer.connect(transport);
|
||||||
|
await transport.handleRequest(req as unknown as Request, res, req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /mcp — SSE stream for server-initiated messages
|
||||||
|
router.get('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
handleMcpRequest(req, res).catch((err) => {
|
||||||
|
console.error('[MCP] GET error:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /mcp — JSON-RPC requests
|
||||||
|
router.post('/', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
handleMcpRequest(req, res).catch((err) => {
|
||||||
|
console.error('[MCP] POST error:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /mcp — session termination (stateless: always 405)
|
||||||
|
router.delete('/', (_req, res: Response) => {
|
||||||
|
res.status(405).json({ error: 'Sessions are not supported (stateless mode)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -48,6 +48,16 @@ router.put(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DELETE /api/timer - Cancel (discard) the ongoing timer without creating a time entry
|
||||||
|
router.delete('/', requireAuth, async (req: AuthenticatedRequest, res, next) => {
|
||||||
|
try {
|
||||||
|
await timerService.cancel(req.user!.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/timer/stop - Stop timer
|
// POST /api/timer/stop - Stop timer
|
||||||
router.post(
|
router.post(
|
||||||
'/stop',
|
'/stop',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const UpdateProjectSchema = z.object({
|
|||||||
export const CreateTimeEntrySchema = z.object({
|
export const CreateTimeEntrySchema = z.object({
|
||||||
startTime: z.string().datetime(),
|
startTime: z.string().datetime(),
|
||||||
endTime: z.string().datetime(),
|
endTime: z.string().datetime(),
|
||||||
|
breakMinutes: z.number().int().min(0).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
projectId: z.string().uuid(),
|
projectId: z.string().uuid(),
|
||||||
});
|
});
|
||||||
@@ -38,6 +39,7 @@ export const CreateTimeEntrySchema = z.object({
|
|||||||
export const UpdateTimeEntrySchema = z.object({
|
export const UpdateTimeEntrySchema = z.object({
|
||||||
startTime: z.string().datetime().optional(),
|
startTime: z.string().datetime().optional(),
|
||||||
endTime: z.string().datetime().optional(),
|
endTime: z.string().datetime().optional(),
|
||||||
|
breakMinutes: z.number().int().min(0).optional(),
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
projectId: z.string().uuid().optional(),
|
projectId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
@@ -64,25 +66,36 @@ 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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateCorrectionSchema = z.object({
|
export const CreateCorrectionSchema = z.object({
|
||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be in YYYY-MM-DD format'),
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be in YYYY-MM-DD format'),
|
||||||
hours: z.number().min(-24).max(24),
|
hours: z.number().min(-1000).max(1000),
|
||||||
description: z.string().max(255).optional(),
|
description: z.string().max(255).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CreateApiKeySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
});
|
||||||
|
|||||||
99
backend/src/services/apiKey.service.ts
Normal file
99
backend/src/services/apiKey.service.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
import { prisma } from '../prisma/client';
|
||||||
|
import { NotFoundError } from '../errors/AppError';
|
||||||
|
import type { AuthenticatedUser } from '../types';
|
||||||
|
|
||||||
|
const KEY_PREFIX_LENGTH = 12; // chars shown in UI
|
||||||
|
|
||||||
|
function hashKey(rawKey: string): string {
|
||||||
|
return createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRawKey(): string {
|
||||||
|
return `sk_${randomUUID().replace(/-/g, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
rawKey: string; // returned once only
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiKeyService {
|
||||||
|
async create(userId: string, name: string): Promise<CreatedApiKey> {
|
||||||
|
const rawKey = generateRawKey();
|
||||||
|
const keyHash = hashKey(rawKey);
|
||||||
|
const prefix = rawKey.slice(0, KEY_PREFIX_LENGTH);
|
||||||
|
|
||||||
|
const record = await prisma.apiKey.create({
|
||||||
|
data: { userId, name, keyHash, prefix },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
prefix: record.prefix,
|
||||||
|
rawKey,
|
||||||
|
createdAt: record.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(userId: string): Promise<ApiKeyListItem[]> {
|
||||||
|
const keys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys.map((k) => ({
|
||||||
|
id: k.id,
|
||||||
|
name: k.name,
|
||||||
|
prefix: k.prefix,
|
||||||
|
createdAt: k.createdAt.toISOString(),
|
||||||
|
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string): Promise<void> {
|
||||||
|
const existing = await prisma.apiKey.findFirst({ where: { id, userId } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key not found');
|
||||||
|
}
|
||||||
|
await prisma.apiKey.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a raw API key string. Returns the owning user or null.
|
||||||
|
* Updates lastUsedAt on success.
|
||||||
|
*/
|
||||||
|
async verify(rawKey: string): Promise<AuthenticatedUser | null> {
|
||||||
|
const keyHash = hashKey(rawKey);
|
||||||
|
const record = await prisma.apiKey.findUnique({
|
||||||
|
where: { keyHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
// Update lastUsedAt in the background — don't await to keep latency low
|
||||||
|
prisma.apiKey
|
||||||
|
.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: record.user.id,
|
||||||
|
username: record.user.username,
|
||||||
|
fullName: record.user.fullName,
|
||||||
|
email: record.user.email,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
/** Returns the last day of the month for a given date string. */
|
||||||
while (cursor <= currentMonday) {
|
function getMonthEnd(dateStr: string): string {
|
||||||
mondays.push(new Date(cursor));
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
cursor.setUTCDate(cursor.getUTCDate() + 7);
|
// Set to first day of next month then subtract 1 day
|
||||||
|
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
|
||||||
|
return last.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total calendar days in the month containing dateStr. */
|
||||||
|
function daysInMonth(dateStr: string): number {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare two YYYY-MM-DD strings. Returns negative, 0, or positive. */
|
||||||
|
function cmpDate(a: string, b: string): number {
|
||||||
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Period enumeration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Period {
|
||||||
|
start: string; // YYYY-MM-DD
|
||||||
|
end: string; // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the period (start + end) that contains the given date.
|
||||||
|
* For weekly: Mon–Sun.
|
||||||
|
* For monthly: 1st–last day of month.
|
||||||
|
*/
|
||||||
|
function getPeriodForDate(dateStr: string, periodType: 'weekly' | 'monthly'): Period {
|
||||||
|
if (periodType === 'weekly') {
|
||||||
|
const monday = getMondayOfWeek(dateStr);
|
||||||
|
return { start: monday, end: getSundayOfWeek(monday) };
|
||||||
|
} else {
|
||||||
|
return { start: getMonthStart(dateStr), end: getMonthEnd(dateStr) };
|
||||||
}
|
}
|
||||||
return mondays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WeekBalance {
|
/**
|
||||||
weekStart: string; // ISO date string (Monday)
|
* Returns the start of the NEXT period after `currentPeriodEnd`.
|
||||||
weekEnd: string; // ISO date string (Sunday)
|
*/
|
||||||
|
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.
|
||||||
SELECT
|
// We only count it if the timer has a project assigned and that project
|
||||||
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start,
|
// belongs to the same client as this target.
|
||||||
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS tracked_seconds
|
let ongoingTimerSeconds = 0;
|
||||||
FROM time_entries te
|
let ongoingTimerPeriodStart: string | null = null;
|
||||||
JOIN projects p ON p.id = te.project_id
|
|
||||||
WHERE te.user_id = ${target.userId}
|
|
||||||
AND p.client_id = ${target.clientId}
|
|
||||||
AND te.start_time >= ${periodStart}
|
|
||||||
AND te.start_time <= ${periodEnd}
|
|
||||||
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index tracked seconds by week start (ISO Monday string)
|
if (
|
||||||
const trackedByWeek = new Map<string, number>();
|
ongoingTimer &&
|
||||||
for (const row of rows) {
|
ongoingTimer.projectId !== null &&
|
||||||
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week)
|
ongoingTimer.project?.clientId === target.clientId
|
||||||
const monday = getMondayOfWeek(new Date(row.week_start));
|
) {
|
||||||
const key = monday.toISOString().split('T')[0];
|
ongoingTimerSeconds = Math.floor(
|
||||||
trackedByWeek.set(key, Number(row.tracked_seconds));
|
(Date.now() - ongoingTimer.startTime.getTime()) / 1000,
|
||||||
|
);
|
||||||
|
// Determine which period the timer's start time falls into
|
||||||
|
const timerDateStr = ongoingTimer.startTime.toISOString().split('T')[0];
|
||||||
|
const timerPeriod = getPeriodForDate(timerDateStr, periodType);
|
||||||
|
ongoingTimerPeriodStart = timerPeriod.start;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index corrections by week
|
// Fetch all time tracked for this client across the full range in one query
|
||||||
const correctionsByWeek = new Map<string, number>();
|
type TrackedRow = { period_start: string; tracked_seconds: bigint };
|
||||||
|
|
||||||
|
let trackedRows: TrackedRow[];
|
||||||
|
if (periodType === 'weekly') {
|
||||||
|
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(
|
||||||
|
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'),
|
||||||
|
'YYYY-MM-DD'
|
||||||
|
) AS period_start,
|
||||||
|
COALESCE(
|
||||||
|
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
|
||||||
|
0
|
||||||
|
)::bigint AS tracked_seconds
|
||||||
|
FROM time_entries te
|
||||||
|
JOIN projects p ON p.id = te.project_id
|
||||||
|
WHERE te.user_id = ${target.userId}
|
||||||
|
AND p.client_id = ${target.clientId}
|
||||||
|
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
|
||||||
|
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(
|
||||||
|
DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
|
||||||
|
'YYYY-MM-DD'
|
||||||
|
) AS period_start,
|
||||||
|
COALESCE(
|
||||||
|
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
|
||||||
|
0
|
||||||
|
)::bigint AS tracked_seconds
|
||||||
|
FROM time_entries te
|
||||||
|
JOIN projects p ON p.id = te.project_id
|
||||||
|
WHERE te.user_id = ${target.userId}
|
||||||
|
AND p.client_id = ${target.clientId}
|
||||||
|
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
|
||||||
|
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
|
||||||
|
AND te.deleted_at IS NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
GROUP BY DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC')
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map tracked seconds by period start date string
|
||||||
|
const trackedByPeriod = new Map<string, number>();
|
||||||
|
for (const row of trackedRows) {
|
||||||
|
// Normalise: for weekly, Postgres DATE_TRUNC('week') already gives Monday
|
||||||
|
const key = typeof row.period_start === 'string'
|
||||||
|
? row.period_start
|
||||||
|
: (row.period_start as Date).toISOString().split('T')[0];
|
||||||
|
trackedByPeriod.set(key, Number(row.tracked_seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index corrections by period start date
|
||||||
|
const correctionsByPeriod = new Map<string, number>();
|
||||||
for (const c of target.corrections) {
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -31,13 +31,14 @@ export interface CreateProjectInput {
|
|||||||
export interface UpdateProjectInput {
|
export interface UpdateProjectInput {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string | null;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ services:
|
|||||||
DATABASE_URL: "postgresql://timetracker:timetracker_password@db:5432/timetracker"
|
DATABASE_URL: "postgresql://timetracker:timetracker_password@db:5432/timetracker"
|
||||||
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
OIDC_ISSUER_URL: ${OIDC_ISSUER_URL}
|
||||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||||
|
OIDC_REDIRECT_URI: "${API_URL}/auth/callback"
|
||||||
|
OIDC_IOS_REDIRECT_URI: ${OIDC_IOS_REDIRECT_URI:-timetracker://oauth/callback}
|
||||||
SESSION_SECRET: ${SESSION_SECRET}
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
APP_URL: "${APP_URL}"
|
APP_URL: "${APP_URL}"
|
||||||
|
|||||||
285
docs/client-targets-v2-requirements.md
Normal file
285
docs/client-targets-v2-requirements.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Client Targets v2 — Feature Requirements
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document defines the requirements for the second iteration of the Client Targets feature. The main additions are:
|
||||||
|
|
||||||
|
- Targets can be set on a **weekly or monthly** period.
|
||||||
|
- Each target defines a **fixed weekly working-day pattern** (e.g. Mon + Wed).
|
||||||
|
- The balance for the **current period** is calculated proportionally based on elapsed working days, so the user can see at any point in time whether they are ahead or behind.
|
||||||
|
- The **start date** can be any calendar day (no longer restricted to Mondays).
|
||||||
|
- Manual **balance corrections** are preserved and continue to work as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Target Configuration
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `periodType` | `WEEKLY \| MONTHLY` | Required |
|
||||||
|
| `weeklyOrMonthlyHours` | positive float, ≤ 168 | Required; represents hours per week or per month |
|
||||||
|
| `workingDays` | array of day names | At least one of `MON TUE WED THU FRI SAT SUN`; fixed repeating pattern |
|
||||||
|
| `startDate` | `YYYY-MM-DD` | Any calendar day; no longer restricted to Mondays |
|
||||||
|
| `clientId` | UUID | Must belong to the authenticated user |
|
||||||
|
|
||||||
|
**One active target per client** — the unique `(userId, clientId)` constraint is preserved. To change period type, hours, or working days the user creates a new target with a new `startDate`; the old target is soft-deleted. History from the old target is retained as-is and is no longer recalculated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Period Definitions
|
||||||
|
|
||||||
|
| `periodType` | Period start | Period end |
|
||||||
|
|---|---|---|
|
||||||
|
| `WEEKLY` | Monday 00:00 of the calendar week | Sunday 23:59 of that same calendar week |
|
||||||
|
| `MONTHLY` | 1st of the calendar month 00:00 | Last day of the calendar month 23:59 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Balance Calculation — Overview
|
||||||
|
|
||||||
|
The total balance is the **sum of individual period balances** from the period containing `startDate` up to and including the **current period** (the period that contains today).
|
||||||
|
|
||||||
|
Each period is classified as either **completed** or **ongoing**.
|
||||||
|
|
||||||
|
```
|
||||||
|
total_balance_seconds = SUM( balance_seconds ) over all periods
|
||||||
|
```
|
||||||
|
|
||||||
|
Positive = overtime. Negative = undertime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Completed Period Balance
|
||||||
|
|
||||||
|
A period is **completed** when its end date is strictly before today.
|
||||||
|
|
||||||
|
```
|
||||||
|
balance = tracked_hours + correction_hours - period_target_hours
|
||||||
|
```
|
||||||
|
|
||||||
|
- `period_target_hours` — see §5 (pro-ration) for the first period; full `weeklyOrMonthlyHours` for all subsequent periods.
|
||||||
|
- `tracked_hours` — sum of all time entries for this client whose date falls within `[period_start, period_end]`.
|
||||||
|
- `correction_hours` — sum of manual corrections whose `date` falls within `[period_start, period_end]`.
|
||||||
|
|
||||||
|
No working-day logic is applied to completed periods. The target is simply the (optionally pro-rated) hours for that period.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. First Period Pro-ration
|
||||||
|
|
||||||
|
If `startDate` does not fall on the natural first day of a period (Monday for weekly, 1st for monthly), the target hours for that first period are pro-rated by calendar days.
|
||||||
|
|
||||||
|
### Monthly
|
||||||
|
|
||||||
|
```
|
||||||
|
full_period_days = total calendar days in that month
|
||||||
|
remaining_days = (last day of month) − startDate + 1 // inclusive
|
||||||
|
period_target_hours = (remaining_days / full_period_days) × weeklyOrMonthlyHours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** startDate = Jan 25, target = 40 h/month, January has 31 days.
|
||||||
|
`remaining_days = 7`, `period_target_hours = (7 / 31) × 40 = 9.032 h`
|
||||||
|
|
||||||
|
### Weekly
|
||||||
|
|
||||||
|
```
|
||||||
|
full_period_days = 7
|
||||||
|
remaining_days = Sunday of that calendar week − startDate + 1 // inclusive
|
||||||
|
period_target_hours = (remaining_days / 7) × weeklyOrMonthlyHours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** startDate = Wednesday, target = 40 h/week.
|
||||||
|
`remaining_days = 5 (Wed–Sun)`, `period_target_hours = (5 / 7) × 40 = 28.571 h`
|
||||||
|
|
||||||
|
All periods after the first use the full `weeklyOrMonthlyHours`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ongoing Period Balance (Current Period)
|
||||||
|
|
||||||
|
The current period is **ongoing** when today falls within it. The balance reflects how the user is doing *so far* — future working days within the current period are not considered.
|
||||||
|
|
||||||
|
### Step 1 — Period target hours
|
||||||
|
|
||||||
|
Apply §5 if this is the first period; otherwise use full `weeklyOrMonthlyHours`.
|
||||||
|
|
||||||
|
### Step 2 — Daily rate
|
||||||
|
|
||||||
|
```
|
||||||
|
working_days_in_period = COUNT of days in [period_start, period_end]
|
||||||
|
that match the working day pattern
|
||||||
|
daily_rate_hours = period_target_hours / working_days_in_period
|
||||||
|
```
|
||||||
|
|
||||||
|
The rate is fixed at the start of the period and does not change as time passes.
|
||||||
|
|
||||||
|
### Step 3 — Elapsed working days
|
||||||
|
|
||||||
|
```
|
||||||
|
elapsed_working_days = COUNT of days in [period_start, TODAY] (both inclusive)
|
||||||
|
that match the working day pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
- If today matches the working day pattern, it is counted as a **full** elapsed working day.
|
||||||
|
- If today does not match the working day pattern, it is not counted.
|
||||||
|
|
||||||
|
### Step 4 — Expected hours so far
|
||||||
|
|
||||||
|
```
|
||||||
|
expected_hours = elapsed_working_days × daily_rate_hours
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — Balance
|
||||||
|
|
||||||
|
```
|
||||||
|
tracked_hours = SUM of time entries for this client in [period_start, today]
|
||||||
|
correction_hours = SUM of manual corrections whose date ∈ [period_start, today]
|
||||||
|
balance = tracked_hours + correction_hours − expected_hours
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worked example
|
||||||
|
|
||||||
|
> Target: 40 h/month. Working days: Mon + Wed.
|
||||||
|
> Current month has 4 Mondays and 4 Wednesdays → `working_days_in_period = 8`.
|
||||||
|
> `daily_rate_hours = 40 / 8 = 5 h`.
|
||||||
|
> 3 working days have elapsed → `expected_hours = 15 h`.
|
||||||
|
> Tracked so far: 13 h, no corrections.
|
||||||
|
> `balance = 13 − 15 = −2 h` (2 hours behind).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Manual Balance Corrections
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|---|---|---|
|
||||||
|
| `date` | `YYYY-MM-DD` | Must be ≥ `startDate`; not more than one period in the future |
|
||||||
|
| `hours` | signed float | Positive = extra credit (reduces deficit). Negative = reduces tracked credit |
|
||||||
|
| `description` | string | Optional, max 255 chars |
|
||||||
|
|
||||||
|
- The system automatically assigns a correction to the period that contains its `date`.
|
||||||
|
- Corrections in **completed periods** are included in the completed period formula (§4).
|
||||||
|
- Corrections in the **ongoing period** are included in the ongoing balance formula (§6).
|
||||||
|
- Corrections in a **future period** (not yet started) are stored and will be applied when that period becomes active.
|
||||||
|
- A correction whose `date` is before `startDate` is rejected with a validation error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `startDate` = 1st of month / Monday | No pro-ration; `period_target_hours = weeklyOrMonthlyHours` |
|
||||||
|
| `startDate` = last day of period | `remaining_days = 1`; target is heavily reduced (e.g. 1/31 × hours) |
|
||||||
|
| Working pattern has no matches in the partial first period | `elapsed_working_days = 0`; `expected_hours = 0`; balance = `tracked + corrections` |
|
||||||
|
| Current period has zero elapsed working days | `expected_hours = 0`; balance = `tracked + corrections` (cannot divide by zero — guard required) |
|
||||||
|
| `working_days_in_period = 0` | Impossible by validation (at least one day required), but system must guard: treat as `daily_rate_hours = 0` |
|
||||||
|
| Today is not a working day | `elapsed_working_days` does not include today |
|
||||||
|
| Correction date before `startDate` | Rejected with a validation error |
|
||||||
|
| Correction date in future period | Accepted and stored; applied when that period is ongoing or completed |
|
||||||
|
| User changes working days or period type | Must create a new target with a new `startDate`; old target history is frozen |
|
||||||
|
| Two periods with the same client exist (old soft-deleted, new active) | Only the active target's periods contribute to the displayed balance |
|
||||||
|
| A month with only partial working day coverage (e.g. all Mondays are public holidays) | No automatic holiday handling; user adds manual corrections to compensate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Data Model Changes
|
||||||
|
|
||||||
|
### `ClientTarget` table — additions / changes
|
||||||
|
|
||||||
|
| Column | Change | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `period_type` | **Add** | Enum: `WEEKLY`, `MONTHLY` |
|
||||||
|
| `working_days` | **Add** | Array/bitmask of day names: `MON TUE WED THU FRI SAT SUN` |
|
||||||
|
| `start_date` | **Modify** | Remove "must be Monday" validation constraint |
|
||||||
|
| `weekly_hours` | **Rename** | → `target_hours` (represents hours per week or per month depending on `period_type`) |
|
||||||
|
|
||||||
|
### `BalanceCorrection` table — no structural changes
|
||||||
|
|
||||||
|
Date-to-period assignment is computed at query time, not stored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. API Changes
|
||||||
|
|
||||||
|
### `ClientTargetWithBalance` response shape
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ClientTargetWithBalance {
|
||||||
|
id: string
|
||||||
|
clientId: string
|
||||||
|
clientName: string
|
||||||
|
userId: string
|
||||||
|
periodType: "weekly" | "monthly"
|
||||||
|
targetHours: number // renamed from weeklyHours
|
||||||
|
workingDays: string[] // e.g. ["MON", "WED"]
|
||||||
|
startDate: string // YYYY-MM-DD
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
corrections: BalanceCorrection[]
|
||||||
|
totalBalanceSeconds: number // running total across all periods
|
||||||
|
currentPeriodTrackedSeconds: number // replaces currentWeekTrackedSeconds
|
||||||
|
currentPeriodTargetSeconds: number // replaces currentWeekTargetSeconds
|
||||||
|
periods: PeriodBalance[] // replaces weeks[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodBalance {
|
||||||
|
periodStart: string // YYYY-MM-DD (Monday or 1st of month)
|
||||||
|
periodEnd: string // YYYY-MM-DD (Sunday or last of month)
|
||||||
|
targetHours: number // pro-rated for first period
|
||||||
|
trackedSeconds: number
|
||||||
|
correctionHours: number
|
||||||
|
balanceSeconds: number
|
||||||
|
isOngoing: boolean
|
||||||
|
// only present when isOngoing = true
|
||||||
|
dailyRateHours?: number
|
||||||
|
workingDaysInPeriod?: number
|
||||||
|
elapsedWorkingDays?: number
|
||||||
|
expectedHours?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoint changes
|
||||||
|
|
||||||
|
| Method | Path | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /client-targets` | Create | Accepts `periodType`, `workingDays`, `targetHours`; `startDate` unconstrained |
|
||||||
|
| `PUT /client-targets/:id` | Update | Accepts same new fields |
|
||||||
|
| `GET /client-targets` | List | Returns updated `ClientTargetWithBalance` shape |
|
||||||
|
| `POST /client-targets/:id/corrections` | Add correction | No change to signature |
|
||||||
|
| `DELETE /client-targets/:id/corrections/:corrId` | Delete correction | No change |
|
||||||
|
|
||||||
|
### Zod schema changes
|
||||||
|
|
||||||
|
- `CreateClientTargetSchema` / `UpdateClientTargetSchema`:
|
||||||
|
- Add `periodType: z.enum(["weekly", "monthly"])`
|
||||||
|
- Add `workingDays: z.array(z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"])).min(1)`
|
||||||
|
- Rename `weeklyHours` → `targetHours`
|
||||||
|
- Remove Monday-only regex constraint from `startDate`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Frontend Changes
|
||||||
|
|
||||||
|
### Types (`frontend/src/types/index.ts`)
|
||||||
|
- `ClientTargetWithBalance` — add `periodType`, `workingDays`, `targetHours`; replace `weeks` → `periods: PeriodBalance[]`; replace `currentWeek*` → `currentPeriod*`
|
||||||
|
- Add `PeriodBalance` interface
|
||||||
|
- `CreateClientTargetInput` / `UpdateClientTargetInput` — same field additions
|
||||||
|
|
||||||
|
### Hook (`frontend/src/hooks/useClientTargets.ts`)
|
||||||
|
- No structural changes; mutations pass through new fields
|
||||||
|
|
||||||
|
### API client (`frontend/src/api/clientTargets.ts`)
|
||||||
|
- No structural changes; payload shapes updated
|
||||||
|
|
||||||
|
### `ClientsPage` — `ClientTargetPanel`
|
||||||
|
- Working day selector (checkboxes: Mon–Sun, at least one required)
|
||||||
|
- Period type selector (Weekly / Monthly)
|
||||||
|
- Label for hours input updates dynamically: "Hours/week" or "Hours/month"
|
||||||
|
- Start date picker: free date input (no week-picker)
|
||||||
|
- Balance display: label changes from "this week" to "this week" or "this month" based on `periodType`
|
||||||
|
- Expanded period list replaces the expanded week list
|
||||||
|
|
||||||
|
### `DashboardPage`
|
||||||
|
- "Weekly Targets" widget renamed to "Targets"
|
||||||
|
- "This week" label becomes "This week" / "This month" dynamically
|
||||||
|
- `currentWeek*` fields replaced with `currentPeriod*`
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<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>
|
||||||
|
|||||||
23
frontend/public/favicon.svg
Normal file
23
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||||
|
<svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<path id="Path" fill="url(#linearGradient1)" stroke="none" d="M 96 0 L 320 0 C 373.019348 0 416 42.980652 416 96 L 416 320 C 416 373.019348 373.019348 416 320 416 L 96 416 C 42.980667 416 0 373.019348 0 320 L 0 96 C 0 42.980652 42.980667 0 96 0 Z"/>
|
||||||
|
<g id="Group">
|
||||||
|
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
|
||||||
|
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 176 40 L 240 40"/>
|
||||||
|
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 304 128 L 328 104"/>
|
||||||
|
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
|
||||||
|
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 340 224 C 340 296.901581 280.901581 356 208 356 C 135.098419 356 76 296.901581 76 224 C 76 151.098419 135.098419 92 208 92 C 280.901581 92 340 151.098419 340 224 Z"/>
|
||||||
|
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
|
||||||
|
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
|
||||||
|
<g id="g1" opacity="0.6">
|
||||||
|
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 208 308 L 208 324"/>
|
||||||
|
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
|
||||||
|
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
33
frontend/public/icon.svg
Normal file
33
frontend/public/icon.svg
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Generated by Pixelmator Pro 4.0 -->
|
||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feGaussianBlur stdDeviation="16"/>
|
||||||
|
<feOffset dx="0" dy="12" result="offsetblur"/>
|
||||||
|
<feFlood flood-color="#4f46e5" flood-opacity="0.4"/>
|
||||||
|
<feComposite in2="offsetblur" operator="in"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<path id="Path" fill="url(#linearGradient1)" stroke="none" filter="url(#filter1)" d="M 144 48 L 368 48 C 421.019348 48 464 90.980652 464 144 L 464 368 C 464 421.019348 421.019348 464 368 464 L 144 464 C 90.980667 464 48 421.019348 48 368 L 48 144 C 48 90.980652 90.980667 48 144 48 Z"/>
|
||||||
|
<g id="Group">
|
||||||
|
<path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
|
||||||
|
<path id="path2" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 224 88 L 288 88"/>
|
||||||
|
<path id="path3" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 352 176 L 376 152"/>
|
||||||
|
<path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
|
||||||
|
<path id="path5" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 388 272 C 388 344.901581 328.901581 404 256 404 C 183.098419 404 124 344.901581 124 272 C 124 199.098419 183.098419 140 256 140 C 328.901581 140 388 199.098419 388 272 Z"/>
|
||||||
|
<path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
|
||||||
|
<path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
|
||||||
|
<g id="g1" opacity="0.6">
|
||||||
|
<path id="path8" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 256 356 L 256 372"/>
|
||||||
|
<path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
|
||||||
|
<path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -10,6 +10,7 @@ import { TimeEntriesPage } from "./pages/TimeEntriesPage";
|
|||||||
import { ClientsPage } from "./pages/ClientsPage";
|
import { ClientsPage } from "./pages/ClientsPage";
|
||||||
import { ProjectsPage } from "./pages/ProjectsPage";
|
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||||
import { StatisticsPage } from "./pages/StatisticsPage";
|
import { StatisticsPage } from "./pages/StatisticsPage";
|
||||||
|
import { ApiKeysPage } from "./pages/ApiKeysPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +34,7 @@ function App() {
|
|||||||
<Route path="clients" element={<ClientsPage />} />
|
<Route path="clients" element={<ClientsPage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="statistics" element={<StatisticsPage />} />
|
<Route path="statistics" element={<StatisticsPage />} />
|
||||||
|
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
18
frontend/src/api/apiKeys.ts
Normal file
18
frontend/src/api/apiKeys.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import type { ApiKey, CreatedApiKey, CreateApiKeyInput } from '@/types';
|
||||||
|
|
||||||
|
export const apiKeysApi = {
|
||||||
|
getAll: async (): Promise<ApiKey[]> => {
|
||||||
|
const { data } = await apiClient.get<ApiKey[]>('/api-keys');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
|
||||||
|
const { data } = await apiClient.post<CreatedApiKey>('/api-keys', input);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/api-keys/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings,
|
Settings,
|
||||||
|
Key,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
@@ -40,6 +41,7 @@ export function Navbar() {
|
|||||||
const managementItems = [
|
const managementItems = [
|
||||||
{ to: "/clients", label: "Clients", icon: Briefcase },
|
{ to: "/clients", label: "Clients", icon: Briefcase },
|
||||||
{ to: "/projects", label: "Projects", icon: FolderOpen },
|
{ to: "/projects", label: "Projects", icon: FolderOpen },
|
||||||
|
{ to: "/api-keys", label: "API Keys", icon: Key },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,20 +49,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"
|
||||||
|
|||||||
@@ -3,25 +3,35 @@ 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>
|
||||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
{indicator && (
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
|
||||||
|
title="Timer running"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
|
|||||||
return {
|
return {
|
||||||
startTime: getLocalISOString(new Date(entry.startTime)),
|
startTime: getLocalISOString(new Date(entry.startTime)),
|
||||||
endTime: getLocalISOString(new Date(entry.endTime)),
|
endTime: getLocalISOString(new Date(entry.endTime)),
|
||||||
|
breakMinutes: entry.breakMinutes,
|
||||||
description: entry.description || '',
|
description: entry.description || '',
|
||||||
projectId: entry.projectId,
|
projectId: entry.projectId,
|
||||||
};
|
};
|
||||||
@@ -29,6 +30,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
|
|||||||
return {
|
return {
|
||||||
startTime: getLocalISOString(oneHourAgo),
|
startTime: getLocalISOString(oneHourAgo),
|
||||||
endTime: getLocalISOString(now),
|
endTime: getLocalISOString(now),
|
||||||
|
breakMinutes: 0,
|
||||||
description: '',
|
description: '',
|
||||||
projectId: projects?.[0]?.id || '',
|
projectId: projects?.[0]?.id || '',
|
||||||
};
|
};
|
||||||
@@ -97,6 +99,16 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Break (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.breakMinutes ?? 0}
|
||||||
|
onChange={(e) => setFormData({ ...formData, breakMinutes: parseInt(e.target.value) || 0 })}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Description</label>
|
<label className="label">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Play, Square, ChevronDown } from "lucide-react";
|
import { Play, Square, ChevronDown, Pencil, Check, X, Trash2 } from "lucide-react";
|
||||||
import { useTimer } from "@/contexts/TimerContext";
|
import { useTimer } from "@/contexts/TimerContext";
|
||||||
import { useProjects } from "@/hooks/useProjects";
|
import { useProjects } from "@/hooks/useProjects";
|
||||||
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
import { ProjectColorDot } from "@/components/ProjectColorDot";
|
||||||
@@ -27,6 +27,21 @@ function TimerDisplay({ totalSeconds }: { totalSeconds: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converts a HH:mm string to an ISO datetime, inferring the correct date.
|
||||||
|
* If the resulting time would be in the future, it is assumed to belong to the previous day.
|
||||||
|
*/
|
||||||
|
function timeInputToIso(timeValue: string): string {
|
||||||
|
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||||
|
const now = new Date();
|
||||||
|
const candidate = new Date(now);
|
||||||
|
candidate.setHours(hours, minutes, 0, 0);
|
||||||
|
// If the candidate is in the future, roll back one day
|
||||||
|
if (candidate > now) {
|
||||||
|
candidate.setDate(candidate.getDate() - 1);
|
||||||
|
}
|
||||||
|
return candidate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export function TimerWidget() {
|
export function TimerWidget() {
|
||||||
const {
|
const {
|
||||||
ongoingTimer,
|
ongoingTimer,
|
||||||
@@ -34,12 +49,19 @@ export function TimerWidget() {
|
|||||||
elapsedSeconds,
|
elapsedSeconds,
|
||||||
startTimer,
|
startTimer,
|
||||||
stopTimer,
|
stopTimer,
|
||||||
|
cancelTimer,
|
||||||
updateTimerProject,
|
updateTimerProject,
|
||||||
|
updateTimerStartTime,
|
||||||
} = useTimer();
|
} = useTimer();
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
const [showProjectSelect, setShowProjectSelect] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Start time editing state
|
||||||
|
const [editingStartTime, setEditingStartTime] = useState(false);
|
||||||
|
const [startTimeInput, setStartTimeInput] = useState("");
|
||||||
|
const startTimeInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +80,15 @@ export function TimerWidget() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelTimer = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await cancelTimer();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to cancel timer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleProjectChange = async (projectId: string) => {
|
const handleProjectChange = async (projectId: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -78,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,26 +156,74 @@ 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>
|
||||||
<TimerDisplay totalSeconds={elapsedSeconds} />
|
{editingStartTime ? (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-xs text-gray-500 mr-1">Started at</span>
|
||||||
|
<input
|
||||||
|
ref={startTimeInputRef}
|
||||||
|
type="time"
|
||||||
|
value={startTimeInput}
|
||||||
|
onChange={(e) => setStartTimeInput(e.target.value)}
|
||||||
|
onKeyDown={handleStartTimeKeyDown}
|
||||||
|
className="font-mono text-lg font-bold text-gray-900 border border-primary-400 rounded px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-primary-500 w-28"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void handleConfirmStartTime()}
|
||||||
|
title="Confirm"
|
||||||
|
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 rounded"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditStartTime}
|
||||||
|
title="Cancel"
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<TimerDisplay totalSeconds={elapsedSeconds} />
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditStartTime}
|
||||||
|
title="Adjust start time"
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stop Button */}
|
{/* Stop + Cancel Buttons */}
|
||||||
<button
|
<div className="flex items-center space-x-2 shrink-0 sm:order-last">
|
||||||
onClick={handleStop}
|
<button
|
||||||
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"
|
onClick={() => void handleCancelTimer()}
|
||||||
>
|
title="Discard timer"
|
||||||
<Square className="h-5 w-5 fill-current" />
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
<span>Stop</span>
|
>
|
||||||
</button>
|
<Trash2 className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!ongoingTimer.project}
|
||||||
|
title={!ongoingTimer.project ? "Select a project to stop the timer" : undefined}
|
||||||
|
className="flex items-center space-x-2 px-6 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-red-600"
|
||||||
|
>
|
||||||
|
<Square className="h-5 w-5 fill-current" />
|
||||||
|
<span>Stop</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Selector — full width on mobile, auto on desktop */}
|
{/* Project Selector — full width on mobile, auto on desktop */}
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
34
frontend/src/hooks/useApiKeys.ts
Normal file
34
frontend/src/hooks/useApiKeys.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiKeysApi } from '@/api/apiKeys';
|
||||||
|
import type { CreateApiKeyInput } from '@/types';
|
||||||
|
|
||||||
|
export function useApiKeys() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: apiKeys, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['apiKeys'],
|
||||||
|
queryFn: apiKeysApi.getAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createApiKey = useMutation({
|
||||||
|
mutationFn: (input: CreateApiKeyInput) => apiKeysApi.create(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteApiKey = useMutation({
|
||||||
|
mutationFn: (id: string) => apiKeysApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKeys,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
createApiKey,
|
||||||
|
deleteApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
243
frontend/src/pages/ApiKeysPage.tsx
Normal file
243
frontend/src/pages/ApiKeysPage.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Key, Plus, Trash2, Copy, Check, AlertTriangle } from "lucide-react";
|
||||||
|
import { useApiKeys } from "@/hooks/useApiKeys";
|
||||||
|
import type { CreatedApiKey } from "@/types";
|
||||||
|
|
||||||
|
export function ApiKeysPage() {
|
||||||
|
const { apiKeys, isLoading, error, createApiKey, deleteApiKey } = useApiKeys();
|
||||||
|
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
||||||
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return "Never";
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newKeyName.trim()) return;
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const key = await createApiKey.mutateAsync({ name: newKeyName.trim() });
|
||||||
|
setCreatedKey(key);
|
||||||
|
setNewKeyName("");
|
||||||
|
} catch (err) {
|
||||||
|
setCreateError(err instanceof Error ? err.message : "An error occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyKey() {
|
||||||
|
if (!createdKey) return;
|
||||||
|
await navigator.clipboard.writeText(createdKey.rawKey);
|
||||||
|
setCopiedKey(true);
|
||||||
|
setTimeout(() => setCopiedKey(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseCreateModal() {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setCreatedKey(null);
|
||||||
|
setNewKeyName("");
|
||||||
|
setCreateError(null);
|
||||||
|
setCopiedKey(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteApiKey.mutateAsync(id);
|
||||||
|
setRevokeConfirmId(null);
|
||||||
|
} catch (_err) {
|
||||||
|
// error rendered below the table row via deleteApiKey.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Key className="h-6 w-6 text-gray-600" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">API Keys</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
API keys allow agents and external tools to authenticate with the TimeTracker API and MCP endpoint.
|
||||||
|
The raw key is only shown once at creation time — store it securely.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{error instanceof Error ? error.message : "Failed to load API keys"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteApiKey.isError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||||
|
{deleteApiKey.error instanceof Error
|
||||||
|
? deleteApiKey.error.message
|
||||||
|
: "Failed to revoke API key"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-12 text-gray-400 text-sm">Loading...</div>
|
||||||
|
) : !apiKeys || apiKeys.length === 0 ? (
|
||||||
|
<div className="text-center py-12 border border-dashed border-gray-300 rounded-lg">
|
||||||
|
<Key className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500 text-sm">No API keys yet. Create one to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Prefix</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Last Used</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{apiKeys.map((key) => (
|
||||||
|
<tr key={key.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900">{key.name}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono text-gray-700">
|
||||||
|
{key.prefix}…
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{formatDate(key.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{formatDate(key.lastUsedAt)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{revokeConfirmId === key.id ? (
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className="text-xs text-red-600">Revoke?</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(key.id)}
|
||||||
|
disabled={deleteApiKey.isPending}
|
||||||
|
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setRevokeConfirmId(null)}
|
||||||
|
className="text-xs px-2 py-1 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setRevokeConfirmId(key.id)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded transition-colors"
|
||||||
|
title="Revoke key"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
<span>Revoke</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create API Key Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Create API Key</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
{createdKey ? (
|
||||||
|
/* One-time key reveal */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
Copy this key now. <strong>It will not be shown again.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Your new API key</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs bg-gray-100 border border-gray-200 rounded-lg px-3 py-2 font-mono text-gray-900 break-all">
|
||||||
|
{createdKey.rawKey}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyKey}
|
||||||
|
className="flex-shrink-0 p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copiedKey ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Name input form */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="key-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Key name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="key-name"
|
||||||
|
type="text"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
|
placeholder="e.g. My Claude Agent"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createError && (
|
||||||
|
<p className="text-red-600 text-sm">{createError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleCloseCreateModal}
|
||||||
|
className="px-4 py-2 text-sm font-medium border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{createdKey ? "Done" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
{!createdKey && (
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newKeyName.trim() || createApiKey.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{createApiKey.isPending ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
@@ -145,8 +153,8 @@ function ClientTargetPanel({
|
|||||||
setCorrError(null);
|
setCorrError(null);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const hours = parseFloat(corrHours);
|
const hours = parseFloat(corrHours);
|
||||||
if (isNaN(hours) || hours < -24 || hours > 24) {
|
if (isNaN(hours) || hours < -1000 || hours > 1000) {
|
||||||
setCorrError('Hours must be between -24 and 24');
|
setCorrError('Hours must be between -1000 and 1000');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!corrDate) {
|
if (!corrDate) {
|
||||||
@@ -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
|
||||||
@@ -336,8 +399,6 @@ function ClientTargetPanel({
|
|||||||
onChange={e => setCorrHours(e.target.value)}
|
onChange={e => setCorrHours(e.target.value)}
|
||||||
className="input text-xs py-1"
|
className="input text-xs py-1"
|
||||||
placeholder="+8 / -4"
|
placeholder="+8 / -4"
|
||||||
min="-24"
|
|
||||||
max="24"
|
|
||||||
step="0.5"
|
step="0.5"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -533,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);
|
||||||
|
|||||||
@@ -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,23 +138,31 @@ 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">
|
||||||
<p
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
className={`text-sm font-bold ${
|
{target.hasOngoingTimer && (
|
||||||
isEven
|
<span
|
||||||
? 'text-gray-500'
|
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
|
||||||
: isOver
|
title="Timer running — balance updates every 30 s"
|
||||||
? 'text-green-600'
|
/>
|
||||||
: 'text-red-600'
|
)}
|
||||||
}`}
|
<p
|
||||||
>
|
className={`text-sm font-bold ${
|
||||||
{isEven
|
isEven
|
||||||
? '±0'
|
? 'text-gray-500'
|
||||||
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
: isOver
|
||||||
</p>
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEven
|
||||||
|
? '±0'
|
||||||
|
: (isOver ? '+' : '−') + formatDurationHoursMinutes(absBalance)}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Clock } from 'lucide-react';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
@@ -8,8 +7,8 @@ export function LoginPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="max-w-md w-full space-y-8 p-8">
|
<div className="max-w-md w-full space-y-8 p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto h-16 w-16 bg-primary-100 rounded-full flex items-center justify-center">
|
<div className="mx-auto h-16 w-16 flex items-center justify-center drop-shadow-sm">
|
||||||
<Clock className="h-8 w-8 text-primary-600" />
|
<img src="/icon.svg" alt="TimeTracker Logo" className="h-16 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
<h2 className="mt-6 text-3xl font-bold text-gray-900">TimeTracker</h2>
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
|||||||
@@ -78,7 +78,10 @@ export function TimeEntriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
|
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||||
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)}
|
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
|
||||||
|
{entry.breakMinutes > 0 && (
|
||||||
|
<span className="text-xs text-gray-400 ml-1">(−{entry.breakMinutes}m)</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||||
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
|
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>
|
||||||
|
|||||||
@@ -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";
|
||||||
startDate: string; // YYYY-MM-DD
|
targetHours: number;
|
||||||
|
workingDays: string[]; // e.g. ["MON","WED"]
|
||||||
|
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;
|
||||||
startDate: string; // YYYY-MM-DD
|
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;
|
periodType?: "weekly" | "monthly";
|
||||||
|
workingDays?: string[];
|
||||||
|
startDate?: string; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCorrectionInput {
|
export interface CreateCorrectionInput {
|
||||||
@@ -189,3 +210,19 @@ export interface CreateCorrectionInput {
|
|||||||
hours: number;
|
hours: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedApiKey extends ApiKey {
|
||||||
|
rawKey: string; // returned only on creation
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyInput {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ export function formatDurationHoursMinutes(totalSeconds: number): string {
|
|||||||
return `${hours}h ${minutes}m`;
|
return `${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateDuration(startTime: string, endTime: string): number {
|
export function calculateDuration(startTime: string, endTime: string, breakMinutes: number = 0): number {
|
||||||
|
const start = parseISO(startTime);
|
||||||
|
const end = parseISO(endTime);
|
||||||
|
const totalSeconds = differenceInSeconds(end, start);
|
||||||
|
return totalSeconds - (breakMinutes * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateGrossDuration(startTime: string, endTime: string): number {
|
||||||
const start = parseISO(startTime);
|
const start = parseISO(startTime);
|
||||||
const end = parseISO(endTime);
|
const end = parseISO(endTime);
|
||||||
return differenceInSeconds(end, start);
|
return differenceInSeconds(end, start);
|
||||||
@@ -52,16 +59,18 @@ export function calculateDuration(startTime: string, endTime: string): number {
|
|||||||
export function formatDurationFromDates(
|
export function formatDurationFromDates(
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
|
breakMinutes: number = 0,
|
||||||
): string {
|
): string {
|
||||||
const seconds = calculateDuration(startTime, endTime);
|
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||||
return formatDuration(seconds);
|
return formatDuration(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDurationFromDatesHoursMinutes(
|
export function formatDurationFromDatesHoursMinutes(
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
|
breakMinutes: number = 0,
|
||||||
): string {
|
): string {
|
||||||
const seconds = calculateDuration(startTime, endTime);
|
const seconds = calculateDuration(startTime, endTime, breakMinutes);
|
||||||
return formatDurationHoursMinutes(seconds);
|
return formatDurationHoursMinutes(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
ios/TimeTracker/.gitignore
vendored
Normal file
28
ios/TimeTracker/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
*.xcodeproj
|
||||||
|
*.xcworkspace
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM/
|
||||||
|
|
||||||
|
# XcodeGen (regenerate after clean)
|
||||||
|
# project.yml is the source of truth
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
# (not used, we use SPM)
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Build logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
89
ios/TimeTracker/README.md
Normal file
89
ios/TimeTracker/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# TimeTracker iOS App
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. **XcodeGen** - Required to generate the Xcode project
|
||||||
|
```bash
|
||||||
|
# On macOS:
|
||||||
|
brew install xcodegen
|
||||||
|
|
||||||
|
# Or via npm:
|
||||||
|
npm install -g xcodegen
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Xcode** - For building the iOS app (macOS only)
|
||||||
|
|
||||||
|
### Project Generation
|
||||||
|
|
||||||
|
After installing XcodeGen, generate the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ios/TimeTracker
|
||||||
|
xcodegen generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create `TimeTracker.xcodeproj` in the `ios/TimeTracker` directory.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Before building, configure the API base URL:
|
||||||
|
|
||||||
|
1. Open `TimeTracker.xcodeproj` in Xcode
|
||||||
|
2. Select the TimeTracker target
|
||||||
|
3. Go to Info.plist
|
||||||
|
4. Add or modify `API_BASE_URL` with your backend URL:
|
||||||
|
- For development: `http://localhost:3001`
|
||||||
|
- For production: Your actual API URL
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
Open the project in Xcode and build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open ios/TimeTracker/TimeTracker.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Then select your target device/simulator and press Cmd+B to build.
|
||||||
|
|
||||||
|
### Authentication Setup
|
||||||
|
|
||||||
|
1. Configure your OIDC provider settings in the backend
|
||||||
|
2. The iOS app uses ASWebAuthenticationSession for OAuth
|
||||||
|
3. The callback URL scheme is `timetracker://oauth/callback`
|
||||||
|
|
||||||
|
### App Groups
|
||||||
|
|
||||||
|
For the widget to work with the main app, configure the App Group:
|
||||||
|
- Identifier: `group.com.timetracker.app`
|
||||||
|
- This is already configured in the project.yml
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
The project uses Swift Package Manager for dependencies:
|
||||||
|
- [SQLite.swift](https://github.com/stephencelis/SQLite.swift) - Local database
|
||||||
|
- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess) - Secure storage
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
TimeTracker/
|
||||||
|
├── TimeTrackerApp/ # App entry point
|
||||||
|
├── Core/
|
||||||
|
│ ├── Network/ # API client
|
||||||
|
│ ├── Auth/ # Authentication
|
||||||
|
│ └── Persistence/ # SQLite + sync
|
||||||
|
├── Features/
|
||||||
|
│ ├── Auth/ # Login
|
||||||
|
│ ├── Timer/ # Timer (core feature)
|
||||||
|
│ ├── TimeEntries/ # Time entries CRUD
|
||||||
|
│ ├── Projects/ # Projects CRUD
|
||||||
|
│ ├── Clients/ # Clients CRUD
|
||||||
|
│ └── Dashboard/ # Dashboard
|
||||||
|
├── Models/ # Data models
|
||||||
|
├── Shared/ # Extensions & components
|
||||||
|
└── Resources/ # Assets
|
||||||
|
|
||||||
|
TimeTrackerWidget/ # iOS Widget Extension
|
||||||
|
```
|
||||||
119
ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift
Normal file
119
ios/TimeTracker/TimeTracker/Core/Auth/AuthManager.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import KeychainAccess
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.timetracker.app", category: "AuthManager")
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AuthManager: ObservableObject {
|
||||||
|
static let shared = AuthManager()
|
||||||
|
|
||||||
|
@Published private(set) var isAuthenticated = false
|
||||||
|
@Published private(set) var currentUser: User?
|
||||||
|
|
||||||
|
private let keychain: Keychain
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
/// In-memory cache so the token is always available within the current session,
|
||||||
|
/// even if the keychain write fails (e.g. missing entitlement on simulator).
|
||||||
|
private var _accessToken: String?
|
||||||
|
|
||||||
|
/// The backend-issued JWT. Sent as `Authorization: Bearer <token>` on every API call.
|
||||||
|
var accessToken: String? {
|
||||||
|
get {
|
||||||
|
// Return the in-memory value first; fall back to keychain for persistence
|
||||||
|
// across app launches.
|
||||||
|
if let cached = _accessToken { return cached }
|
||||||
|
let stored = try? keychain.get(AppConstants.KeychainKeys.accessToken)
|
||||||
|
_accessToken = stored
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
_accessToken = newValue
|
||||||
|
if let value = newValue {
|
||||||
|
do {
|
||||||
|
try keychain.set(value, key: AppConstants.KeychainKeys.accessToken)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Keychain write failed (token still available in-memory): \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try keychain.remove(AppConstants.KeychainKeys.accessToken)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Keychain remove failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
self.keychain = Keychain(service: "com.timetracker.app")
|
||||||
|
.accessibility(.whenUnlockedThisDeviceOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAuthState() async {
|
||||||
|
guard let token = accessToken else {
|
||||||
|
logger.info("checkAuthState — no token in keychain, not authenticated")
|
||||||
|
isAuthenticated = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.info("checkAuthState — token found (first 20 chars: \(token.prefix(20))…), calling /auth/me")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let user: User = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.me,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
logger.info("checkAuthState — /auth/me OK, user: \(user.id)")
|
||||||
|
currentUser = user
|
||||||
|
isAuthenticated = true
|
||||||
|
} catch {
|
||||||
|
logger.error("checkAuthState — /auth/me failed: \(error.localizedDescription) — clearing auth")
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentUser() async throws -> User {
|
||||||
|
let user: User = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.me,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
currentUser = user
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async throws {
|
||||||
|
// Best-effort server-side logout; the backend JWT is stateless so the
|
||||||
|
// real security comes from clearing the local token.
|
||||||
|
try? await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.logout,
|
||||||
|
method: .post,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAuth() {
|
||||||
|
logger.info("clearAuth — wiping token and user")
|
||||||
|
_accessToken = nil
|
||||||
|
accessToken = nil
|
||||||
|
currentUser = nil
|
||||||
|
isAuthenticated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTokenResponse(_ response: TokenResponse) async {
|
||||||
|
logger.info("handleTokenResponse — storing JWT for user \(response.user.id)")
|
||||||
|
accessToken = response.accessToken
|
||||||
|
currentUser = response.user
|
||||||
|
isAuthenticated = true
|
||||||
|
logger.info("handleTokenResponse — isAuthenticated = true, token stored: \(self.accessToken != nil)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginURL: URL {
|
||||||
|
APIEndpoints.url(for: APIEndpoint.login)
|
||||||
|
}
|
||||||
|
|
||||||
|
var callbackURL: String {
|
||||||
|
AppConfig.authCallbackURL
|
||||||
|
}
|
||||||
|
}
|
||||||
228
ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift
Normal file
228
ios/TimeTracker/TimeTracker/Core/Auth/AuthService.swift
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import Foundation
|
||||||
|
import AuthenticationServices
|
||||||
|
import CryptoKit
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.timetracker.app", category: "AuthService")
|
||||||
|
|
||||||
|
final class AuthService: NSObject {
|
||||||
|
static let shared = AuthService()
|
||||||
|
|
||||||
|
private var authSession: ASWebAuthenticationSession?
|
||||||
|
private var presentationAnchor: ASPresentationAnchor?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(presentationAnchor: ASPresentationAnchor?) async throws {
|
||||||
|
self.presentationAnchor = presentationAnchor
|
||||||
|
|
||||||
|
// Only the redirect_uri is needed — the backend owns PKCE generation.
|
||||||
|
var components = URLComponents(
|
||||||
|
url: AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.login),
|
||||||
|
resolvingAgainstBaseURL: true
|
||||||
|
)
|
||||||
|
|
||||||
|
components?.queryItems = [
|
||||||
|
URLQueryItem(name: "redirect_uri", value: AppConfig.authCallbackURL)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let authURL = components?.url else {
|
||||||
|
throw AuthError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Starting login — auth URL: \(authURL.absoluteString)")
|
||||||
|
logger.info("Callback URL scheme: \(AppConfig.authCallbackURL)")
|
||||||
|
|
||||||
|
let callbackScheme = URL(string: AppConfig.authCallbackURL)?.scheme ?? "timetracker"
|
||||||
|
|
||||||
|
// Use an ephemeral session — we only need the redirect URL back with the
|
||||||
|
// authorization code; no cookies or shared state are needed.
|
||||||
|
let webAuthSession = ASWebAuthenticationSession(
|
||||||
|
url: authURL,
|
||||||
|
callbackURLScheme: callbackScheme
|
||||||
|
) { [weak self] callbackURL, error in
|
||||||
|
if let error = error {
|
||||||
|
let authError: AuthError
|
||||||
|
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
|
||||||
|
logger.info("Login cancelled by user")
|
||||||
|
authError = .cancelled
|
||||||
|
} else {
|
||||||
|
logger.error("ASWebAuthenticationSession error: \(error)")
|
||||||
|
authError = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": authError]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let callbackURL = callbackURL else {
|
||||||
|
logger.error("ASWebAuthenticationSession returned nil callbackURL")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.noCallback]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.handleCallback(url: callbackURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
webAuthSession.presentationContextProvider = self
|
||||||
|
// Ephemeral session: no shared cookies or browsing data with Safari.
|
||||||
|
webAuthSession.prefersEphemeralWebBrowserSession = true
|
||||||
|
|
||||||
|
self.authSession = webAuthSession
|
||||||
|
|
||||||
|
let started = webAuthSession.start()
|
||||||
|
if !started {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.failed("Failed to start auth session")]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCallback(url: URL) {
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||||
|
let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
|
||||||
|
let state = components.queryItems?.first(where: { $0.name == "state" })?.value
|
||||||
|
else {
|
||||||
|
logger.error("Callback URL missing code or state: \(url.absoluteString)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.noCallback]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
logger.info("Exchanging code for tokens (state: \(state), redirect_uri: \(AppConfig.authCallbackURL))")
|
||||||
|
let tokenResponse = try await exchangeCodeForTokens(
|
||||||
|
code: code,
|
||||||
|
state: state,
|
||||||
|
redirectUri: AppConfig.authCallbackURL
|
||||||
|
)
|
||||||
|
logger.info("Token exchange succeeded for user: \(tokenResponse.user.id)")
|
||||||
|
|
||||||
|
await AuthManager.shared.handleTokenResponse(tokenResponse)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(name: .authCallbackReceived, object: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Token exchange failed: \(error)")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .authError,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["error": AuthError.failed(error.localizedDescription)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exchangeCodeForTokens(
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
redirectUri: String
|
||||||
|
) async throws -> TokenResponse {
|
||||||
|
let url = AppConfig.apiBaseURL.appendingPathComponent(APIEndpoint.token)
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
// state is sent so the backend can look up the original PKCE session.
|
||||||
|
// code_verifier is NOT sent — the backend holds it in the in-memory session.
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"code": code,
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": redirectUri
|
||||||
|
]
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw AuthError.failed("Invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyString = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)"
|
||||||
|
logger.debug("POST /auth/token — status \(httpResponse.statusCode), body: \(bodyString)")
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
if let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let errorMessage = errorJson["error"] as? String {
|
||||||
|
logger.error("POST /auth/token — server error: \(errorMessage)")
|
||||||
|
throw AuthError.failed(errorMessage)
|
||||||
|
}
|
||||||
|
logger.error("POST /auth/token — unexpected status \(httpResponse.statusCode): \(bodyString)")
|
||||||
|
throw AuthError.failed("Token exchange failed with status \(httpResponse.statusCode): \(bodyString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AuthService: ASWebAuthenticationPresentationContextProviding {
|
||||||
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
presentationAnchor ?? ASPresentationAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case cancelled
|
||||||
|
case noCallback
|
||||||
|
case failed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid authentication URL"
|
||||||
|
case .cancelled:
|
||||||
|
return "Login was cancelled"
|
||||||
|
case .noCallback:
|
||||||
|
return "No callback received"
|
||||||
|
case .failed(let message):
|
||||||
|
return "Authentication failed: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let authCallbackReceived = Notification.Name("authCallbackReceived")
|
||||||
|
static let authError = Notification.Name("authError")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TokenResponse: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let expiresIn: Int?
|
||||||
|
let user: User
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case tokenType = "token_type"
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case user
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/TimeTracker/TimeTracker/Core/Constants.swift
Normal file
31
ios/TimeTracker/TimeTracker/Core/Constants.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppConstants {
|
||||||
|
static let appGroupIdentifier = "group.com.timetracker.app"
|
||||||
|
static let authCallbackScheme = "timetracker"
|
||||||
|
static let authCallbackHost = "oauth"
|
||||||
|
|
||||||
|
enum UserDefaultsKeys {
|
||||||
|
static let hasSeenOnboarding = "hasSeenOnboarding"
|
||||||
|
static let cachedTimer = "cachedTimer"
|
||||||
|
static let lastSyncDate = "lastSyncDate"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainKeys {
|
||||||
|
static let accessToken = "accessToken"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppConfig {
|
||||||
|
static var apiBaseURL: URL {
|
||||||
|
if let url = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String,
|
||||||
|
let baseURL = URL(string: url) {
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
return URL(string: "http://localhost:3001")!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var authCallbackURL: String {
|
||||||
|
"\(AppConstants.authCallbackScheme)://\(AppConstants.authCallbackHost)/callback"
|
||||||
|
}
|
||||||
|
}
|
||||||
179
ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift
Normal file
179
ios/TimeTracker/TimeTracker/Core/Network/APIClient.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.timetracker.app", category: "APIClient")
|
||||||
|
|
||||||
|
actor APIClient {
|
||||||
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
config.timeoutIntervalForResource = 60
|
||||||
|
self.session = URLSession(configuration: config)
|
||||||
|
|
||||||
|
self.decoder = JSONDecoder()
|
||||||
|
self.encoder = JSONEncoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
func request<T: Decodable>(
|
||||||
|
endpoint: String,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
body: Encodable? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil,
|
||||||
|
authenticated: Bool = true
|
||||||
|
) async throws -> T {
|
||||||
|
var urlComponents = URLComponents(
|
||||||
|
url: APIEndpoints.url(for: endpoint),
|
||||||
|
resolvingAgainstBaseURL: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if let queryItems = queryItems, !queryItems.isEmpty {
|
||||||
|
urlComponents?.queryItems = queryItems
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = urlComponents?.url else {
|
||||||
|
throw NetworkError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if authenticated {
|
||||||
|
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||||
|
guard let token = token else {
|
||||||
|
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
|
||||||
|
throw NetworkError.unauthorized
|
||||||
|
}
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let body = body {
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
|
||||||
|
await MainActor.run { AuthManager.shared.clearAuth() }
|
||||||
|
throw NetworkError.httpError(statusCode: 401, message: serverMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
|
||||||
|
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.isEmpty {
|
||||||
|
return try decoder.decode(T.self, from: "{}".data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try decoder.decode(T.self, from: data)
|
||||||
|
} catch let error as NetworkError {
|
||||||
|
throw error
|
||||||
|
} catch let error as DecodingError {
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — decoding error: \(error)")
|
||||||
|
throw NetworkError.decodingError(error)
|
||||||
|
} catch {
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
|
||||||
|
throw NetworkError.networkError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestVoid(
|
||||||
|
endpoint: String,
|
||||||
|
method: HTTPMethod = .get,
|
||||||
|
body: Encodable? = nil,
|
||||||
|
queryItems: [URLQueryItem]? = nil,
|
||||||
|
authenticated: Bool = true
|
||||||
|
) async throws {
|
||||||
|
var urlComponents = URLComponents(
|
||||||
|
url: APIEndpoints.url(for: endpoint),
|
||||||
|
resolvingAgainstBaseURL: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if let queryItems = queryItems, !queryItems.isEmpty {
|
||||||
|
urlComponents?.queryItems = queryItems
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = urlComponents?.url else {
|
||||||
|
throw NetworkError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if authenticated {
|
||||||
|
let token = await MainActor.run { AuthManager.shared.accessToken }
|
||||||
|
guard let token = token else {
|
||||||
|
logger.warning("\(method.rawValue) \(endpoint) — no access token in keychain, throwing .unauthorized")
|
||||||
|
throw NetworkError.unauthorized
|
||||||
|
}
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — Authorization header set (token: \(token.prefix(20))…)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let body = body {
|
||||||
|
request.httpBody = try encoder.encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
logger.debug("\(method.rawValue) \(url.absoluteString) — sending request")
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("\(method.rawValue) \(endpoint) — status \(httpResponse.statusCode)")
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — 401 Unauthorized. Server: \(serverMessage ?? "(no message)")")
|
||||||
|
await MainActor.run { AuthManager.shared.clearAuth() }
|
||||||
|
throw NetworkError.httpError(statusCode: 401, message: serverMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — HTTP \(httpResponse.statusCode). Server: \(serverMessage ?? "(no message)")")
|
||||||
|
throw NetworkError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
||||||
|
}
|
||||||
|
} catch let error as NetworkError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
logger.error("\(method.rawValue) \(endpoint) — network error: \(error)")
|
||||||
|
throw NetworkError.networkError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HTTPMethod: String {
|
||||||
|
case get = "GET"
|
||||||
|
case post = "POST"
|
||||||
|
case put = "PUT"
|
||||||
|
case delete = "DELETE"
|
||||||
|
case patch = "PATCH"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorResponse: Codable {
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
49
ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift
Normal file
49
ios/TimeTracker/TimeTracker/Core/Network/APIEndpoints.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIEndpoint {
|
||||||
|
// Auth
|
||||||
|
static let login = "/auth/login"
|
||||||
|
static let callback = "/auth/callback"
|
||||||
|
static let token = "/auth/token"
|
||||||
|
static let logout = "/auth/logout"
|
||||||
|
static let me = "/auth/me"
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
static let clients = "/clients"
|
||||||
|
static func client(id: String) -> String { "/clients/\(id)" }
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
static let projects = "/projects"
|
||||||
|
static func project(id: String) -> String { "/projects/\(id)" }
|
||||||
|
|
||||||
|
// Time Entries
|
||||||
|
static let timeEntries = "/time-entries"
|
||||||
|
static let timeEntriesStatistics = "/time-entries/statistics"
|
||||||
|
static func timeEntry(id: String) -> String { "/time-entries/\(id)" }
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
static let timer = "/timer"
|
||||||
|
static let timerStart = "/timer/start"
|
||||||
|
static let timerStop = "/timer/stop"
|
||||||
|
|
||||||
|
// Client Targets
|
||||||
|
static let clientTargets = "/client-targets"
|
||||||
|
static func clientTarget(id: String) -> String { "/client-targets/\(id)" }
|
||||||
|
static func clientTargetCorrections(targetId: String) -> String { "/client-targets/\(targetId)/corrections" }
|
||||||
|
static func clientTargetCorrection(targetId: String, correctionId: String) -> String {
|
||||||
|
"/client-targets/\(targetId)/corrections/\(correctionId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIEndpoints {
|
||||||
|
static func url(for endpoint: String) -> URL {
|
||||||
|
// Use URL(string:relativeTo:) rather than appendingPathComponent so that
|
||||||
|
// leading slashes in endpoint strings are handled correctly and don't
|
||||||
|
// accidentally replace or duplicate the base URL path.
|
||||||
|
let base = AppConfig.apiBaseURL.absoluteString.hasSuffix("/")
|
||||||
|
? AppConfig.apiBaseURL
|
||||||
|
: URL(string: AppConfig.apiBaseURL.absoluteString + "/")!
|
||||||
|
let relative = endpoint.hasPrefix("/") ? String(endpoint.dropFirst()) : endpoint
|
||||||
|
return URL(string: relative, relativeTo: base)!.absoluteURL
|
||||||
|
}
|
||||||
|
}
|
||||||
30
ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift
Normal file
30
ios/TimeTracker/TimeTracker/Core/Network/NetworkError.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NetworkError: LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case invalidResponse
|
||||||
|
case httpError(statusCode: Int, message: String?)
|
||||||
|
case decodingError(Error)
|
||||||
|
case networkError(Error)
|
||||||
|
case unauthorized
|
||||||
|
case offline
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid URL"
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Invalid response from server"
|
||||||
|
case .httpError(let statusCode, let message):
|
||||||
|
return message ?? "HTTP Error: \(statusCode)"
|
||||||
|
case .decodingError(let error):
|
||||||
|
return "Failed to decode response: \(error.localizedDescription)"
|
||||||
|
case .networkError(let error):
|
||||||
|
return "Network error: \(error.localizedDescription)"
|
||||||
|
case .unauthorized:
|
||||||
|
return "Unauthorized. Please log in again."
|
||||||
|
case .offline:
|
||||||
|
return "No internet connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import Foundation
|
||||||
|
import SQLite
|
||||||
|
|
||||||
|
actor DatabaseService {
|
||||||
|
static let shared = DatabaseService()
|
||||||
|
|
||||||
|
private var db: Connection?
|
||||||
|
|
||||||
|
private let clients = Table("clients")
|
||||||
|
private let projects = Table("projects")
|
||||||
|
private let timeEntries = Table("time_entries")
|
||||||
|
private let pendingSync = Table("pending_sync")
|
||||||
|
|
||||||
|
// Clients columns
|
||||||
|
private let id = SQLite.Expression<String>("id")
|
||||||
|
private let name = SQLite.Expression<String>("name")
|
||||||
|
private let description = SQLite.Expression<String?>("description")
|
||||||
|
private let createdAt = SQLite.Expression<String>("created_at")
|
||||||
|
private let updatedAt = SQLite.Expression<String>("updated_at")
|
||||||
|
|
||||||
|
// Projects columns
|
||||||
|
private let projectClientId = SQLite.Expression<String>("client_id")
|
||||||
|
private let clientName = SQLite.Expression<String>("client_name")
|
||||||
|
private let color = SQLite.Expression<String?>("color")
|
||||||
|
|
||||||
|
// Time entries columns
|
||||||
|
private let startTime = SQLite.Expression<String>("start_time")
|
||||||
|
private let endTime = SQLite.Expression<String>("end_time")
|
||||||
|
private let projectId = SQLite.Expression<String>("project_id")
|
||||||
|
private let projectName = SQLite.Expression<String>("project_name")
|
||||||
|
private let projectColor = SQLite.Expression<String?>("project_color")
|
||||||
|
private let entryDescription = SQLite.Expression<String?>("description")
|
||||||
|
|
||||||
|
// Pending sync columns
|
||||||
|
private let syncId = SQLite.Expression<String>("id")
|
||||||
|
private let syncType = SQLite.Expression<String>("type")
|
||||||
|
private let syncAction = SQLite.Expression<String>("action")
|
||||||
|
private let syncPayload = SQLite.Expression<String>("payload")
|
||||||
|
private let syncCreatedAt = SQLite.Expression<String>("created_at")
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
setupDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupDatabase() {
|
||||||
|
do {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let appGroupURL = fileManager.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: AppConstants.appGroupIdentifier
|
||||||
|
)
|
||||||
|
let dbURL = appGroupURL?.appendingPathComponent("timetracker.sqlite3")
|
||||||
|
?? URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("timetracker.sqlite3")
|
||||||
|
|
||||||
|
db = try Connection(dbURL.path)
|
||||||
|
|
||||||
|
try createTables()
|
||||||
|
} catch {
|
||||||
|
print("Database setup error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTables() throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(clients.create(ifNotExists: true) { t in
|
||||||
|
t.column(id, primaryKey: true)
|
||||||
|
t.column(name)
|
||||||
|
t.column(description)
|
||||||
|
t.column(createdAt)
|
||||||
|
t.column(updatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
try db.run(projects.create(ifNotExists: true) { t in
|
||||||
|
t.column(id, primaryKey: true)
|
||||||
|
t.column(name)
|
||||||
|
t.column(description)
|
||||||
|
t.column(color)
|
||||||
|
t.column(projectClientId)
|
||||||
|
t.column(clientName)
|
||||||
|
t.column(createdAt)
|
||||||
|
t.column(updatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
try db.run(timeEntries.create(ifNotExists: true) { t in
|
||||||
|
t.column(id, primaryKey: true)
|
||||||
|
t.column(startTime)
|
||||||
|
t.column(endTime)
|
||||||
|
t.column(entryDescription)
|
||||||
|
t.column(projectId)
|
||||||
|
t.column(projectName)
|
||||||
|
t.column(projectColor)
|
||||||
|
t.column(createdAt)
|
||||||
|
t.column(updatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
try db.run(pendingSync.create(ifNotExists: true) { t in
|
||||||
|
t.column(syncId, primaryKey: true)
|
||||||
|
t.column(syncType)
|
||||||
|
t.column(syncAction)
|
||||||
|
t.column(syncPayload)
|
||||||
|
t.column(syncCreatedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clients
|
||||||
|
|
||||||
|
func saveClients(_ clientList: [Client]) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(clients.delete())
|
||||||
|
|
||||||
|
for client in clientList {
|
||||||
|
try db.run(clients.insert(
|
||||||
|
id <- client.id,
|
||||||
|
name <- client.name,
|
||||||
|
description <- client.description,
|
||||||
|
createdAt <- client.createdAt,
|
||||||
|
updatedAt <- client.updatedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchClients() throws -> [Client] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(clients).map { row in
|
||||||
|
Client(
|
||||||
|
id: row[id],
|
||||||
|
name: row[name],
|
||||||
|
description: row[description],
|
||||||
|
createdAt: row[createdAt],
|
||||||
|
updatedAt: row[updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Projects
|
||||||
|
|
||||||
|
func saveProjects(_ projectList: [Project]) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(projects.delete())
|
||||||
|
|
||||||
|
for project in projectList {
|
||||||
|
try db.run(projects.insert(
|
||||||
|
id <- project.id,
|
||||||
|
name <- project.name,
|
||||||
|
description <- project.description,
|
||||||
|
color <- project.color,
|
||||||
|
projectClientId <- project.clientId,
|
||||||
|
clientName <- project.client.name,
|
||||||
|
createdAt <- project.createdAt,
|
||||||
|
updatedAt <- project.updatedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchProjects() throws -> [Project] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(projects).map { row in
|
||||||
|
let client = ClientReference(id: row[projectClientId], name: row[clientName])
|
||||||
|
let projectRef = ProjectReference(id: row[id], name: row[name], color: row[color], client: client)
|
||||||
|
|
||||||
|
return Project(
|
||||||
|
id: row[id],
|
||||||
|
name: row[name],
|
||||||
|
description: row[description],
|
||||||
|
color: row[color],
|
||||||
|
clientId: row[projectClientId],
|
||||||
|
client: client,
|
||||||
|
createdAt: row[createdAt],
|
||||||
|
updatedAt: row[updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Time Entries
|
||||||
|
|
||||||
|
func saveTimeEntries(_ entries: [TimeEntry]) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(timeEntries.delete())
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
try db.run(timeEntries.insert(
|
||||||
|
id <- entry.id,
|
||||||
|
startTime <- entry.startTime,
|
||||||
|
endTime <- entry.endTime,
|
||||||
|
entryDescription <- entry.description,
|
||||||
|
projectId <- entry.projectId,
|
||||||
|
projectName <- entry.project.name,
|
||||||
|
projectColor <- entry.project.color,
|
||||||
|
createdAt <- entry.createdAt,
|
||||||
|
updatedAt <- entry.updatedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchTimeEntries() throws -> [TimeEntry] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(timeEntries).map { row in
|
||||||
|
let client = ClientReference(id: "", name: "")
|
||||||
|
let projectRef = ProjectReference(
|
||||||
|
id: row[projectId],
|
||||||
|
name: row[projectName],
|
||||||
|
color: row[projectColor],
|
||||||
|
client: client
|
||||||
|
)
|
||||||
|
|
||||||
|
return TimeEntry(
|
||||||
|
id: row[id],
|
||||||
|
startTime: row[startTime],
|
||||||
|
endTime: row[endTime],
|
||||||
|
description: row[entryDescription],
|
||||||
|
projectId: row[projectId],
|
||||||
|
project: projectRef,
|
||||||
|
createdAt: row[createdAt],
|
||||||
|
updatedAt: row[updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Sync
|
||||||
|
|
||||||
|
func addPendingSync(type: String, action: String, payload: String) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(pendingSync.insert(
|
||||||
|
syncId <- UUID().uuidString,
|
||||||
|
syncType <- type,
|
||||||
|
syncAction <- action,
|
||||||
|
syncPayload <- payload,
|
||||||
|
syncCreatedAt <- ISO8601DateFormatter().string(from: Date())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchPendingSync() throws -> [(id: String, type: String, action: String, payload: String)] {
|
||||||
|
guard let db = db else { return [] }
|
||||||
|
|
||||||
|
return try db.prepare(pendingSync).map { row in
|
||||||
|
(row[syncId], row[syncType], row[syncAction], row[syncPayload])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePendingSync(id: String) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
try db.run(pendingSync.filter(syncId == id).delete())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer Cache
|
||||||
|
|
||||||
|
func cacheTimer(_ timer: OngoingTimer?) throws {
|
||||||
|
guard let db = db else { return }
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
if let timer = timer {
|
||||||
|
let data = try encoder.encode(timer)
|
||||||
|
UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.set(data, forKey: AppConstants.UserDefaultsKeys.cachedTimer)
|
||||||
|
} else {
|
||||||
|
UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.removeObject(forKey: AppConstants.UserDefaultsKeys.cachedTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedTimer() throws -> OngoingTimer? {
|
||||||
|
let data = UserDefaults(suiteName: AppConstants.appGroupIdentifier)?.data(forKey: AppConstants.UserDefaultsKeys.cachedTimer)
|
||||||
|
|
||||||
|
guard let data = data else { return nil }
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
return try decoder.decode(OngoingTimer.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift
Normal file
163
ios/TimeTracker/TimeTracker/Core/Persistence/SyncManager.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SyncManager: ObservableObject {
|
||||||
|
static let shared = SyncManager()
|
||||||
|
|
||||||
|
@Published private(set) var isOnline = true
|
||||||
|
@Published private(set) var isSyncing = false
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let monitorQueue = DispatchQueue(label: "com.timetracker.networkmonitor")
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
startNetworkMonitoring()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startNetworkMonitoring() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.isOnline = path.status == .satisfied
|
||||||
|
if path.status == .satisfied {
|
||||||
|
Task { await self?.syncPendingChanges() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: monitorQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncPendingChanges() async {
|
||||||
|
guard isOnline, !isSyncing else { return }
|
||||||
|
|
||||||
|
isSyncing = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let pending = try await database.fetchPendingSync()
|
||||||
|
|
||||||
|
for item in pending {
|
||||||
|
do {
|
||||||
|
try await processPendingItem(item)
|
||||||
|
try await database.removePendingSync(id: item.id)
|
||||||
|
} catch {
|
||||||
|
print("Failed to sync item \(item.id): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to fetch pending sync: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processPendingItem(_ item: (id: String, type: String, action: String, payload: String)) async throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
switch item.type {
|
||||||
|
case "timeEntry":
|
||||||
|
let data = item.payload.data(using: .utf8)!
|
||||||
|
|
||||||
|
switch item.action {
|
||||||
|
case "create":
|
||||||
|
let input = try decoder.decode(CreateTimeEntryInput.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
struct UpdateRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let input: UpdateTimeEntryInput
|
||||||
|
}
|
||||||
|
let request = try decoder.decode(UpdateRequest.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: request.id),
|
||||||
|
method: .put,
|
||||||
|
body: request.input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "delete":
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: item.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "client":
|
||||||
|
let data = item.payload.data(using: .utf8)!
|
||||||
|
|
||||||
|
switch item.action {
|
||||||
|
case "create":
|
||||||
|
let input = try decoder.decode(CreateClientInput.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
struct UpdateRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let input: UpdateClientInput
|
||||||
|
}
|
||||||
|
let request = try decoder.decode(UpdateRequest.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: request.id),
|
||||||
|
method: .put,
|
||||||
|
body: request.input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "delete":
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: item.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "project":
|
||||||
|
let data = item.payload.data(using: .utf8)!
|
||||||
|
|
||||||
|
switch item.action {
|
||||||
|
case "create":
|
||||||
|
let input = try decoder.decode(CreateProjectInput.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "update":
|
||||||
|
struct UpdateRequest: Codable {
|
||||||
|
let id: String
|
||||||
|
let input: UpdateProjectInput
|
||||||
|
}
|
||||||
|
let request = try decoder.decode(UpdateRequest.self, from: data)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: request.id),
|
||||||
|
method: .put,
|
||||||
|
body: request.input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
case "delete":
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: item.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift
Normal file
89
ios/TimeTracker/TimeTracker/Features/Auth/LoginView.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AuthenticationServices
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
|
||||||
|
Text("TimeTracker")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
Text("Track your time spent on projects")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let error = errorMessage {
|
||||||
|
Text(error)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
login()
|
||||||
|
} label: {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("Sign In")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(isLoading)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 40)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .authCallbackReceived)) { notification in
|
||||||
|
handleAuthCallback(notification.userInfo)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .authError)) { notification in
|
||||||
|
if let authError = notification.userInfo?["error"] as? AuthError {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = authError.errorDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func login() {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let authService = AuthService.shared
|
||||||
|
do {
|
||||||
|
try await authService.login(presentationAnchor: nil)
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleAuthCallback(_ userInfo: [AnyHashable: Any]?) {
|
||||||
|
// AuthManager.handleTokenResponse() already set isAuthenticated = true
|
||||||
|
// and populated currentUser during the token exchange in AuthService.
|
||||||
|
// No further network call is needed here.
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// ClientsView.swift — replaced by Features/Settings/ClientsListView.swift
|
||||||
|
// This file is intentionally empty; ClientsViewModel is no longer used directly.
|
||||||
|
import Foundation
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ClientsViewModel: ObservableObject {
|
||||||
|
@Published var clients: [Client] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
|
||||||
|
func loadClients() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
clients = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.saveClients(clients)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Load from cache
|
||||||
|
clients = (try? await database.fetchClients()) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClient(name: String, description: String?) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = CreateClientInput(name: name, description: description)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadClients()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateClient(id: String, name: String, description: String?) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = UpdateClientInput(name: name, description: description)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadClients()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteClient(_ client: Client) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.client(id: client.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
clients.removeAll { $0.id == client.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@StateObject private var viewModel = DashboardViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.statistics == nil && viewModel.recentEntries.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else {
|
||||||
|
scrollContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Dashboard")
|
||||||
|
.refreshable { await viewModel.loadData() }
|
||||||
|
.task { await viewModel.loadData() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main scroll content
|
||||||
|
|
||||||
|
private var scrollContent: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
timerCard
|
||||||
|
if let stats = viewModel.statistics { weeklyStatsSection(stats) }
|
||||||
|
if !viewModel.clientTargets.isEmpty { workBalanceSection }
|
||||||
|
recentEntriesSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Active Timer Card
|
||||||
|
|
||||||
|
private var timerCard: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if let timer = viewModel.activeTimer {
|
||||||
|
Text("Timer Running")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(viewModel.elapsedTime.formattedDuration)
|
||||||
|
.font(.system(size: 32, weight: .medium, design: .monospaced))
|
||||||
|
if let project = timer.project {
|
||||||
|
ProjectColorBadge(color: project.color, name: project.name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No Active Timer")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Start tracking to see your time")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.title)
|
||||||
|
.foregroundStyle(viewModel.activeTimer != nil ? .green : .secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weekly Stats
|
||||||
|
|
||||||
|
private func weeklyStatsSection(_ stats: TimeStatistics) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("This Week")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
StatCard(
|
||||||
|
title: "Hours Tracked",
|
||||||
|
value: TimeInterval(stats.totalSeconds).formattedShortDuration,
|
||||||
|
icon: "clock.fill",
|
||||||
|
color: .blue
|
||||||
|
)
|
||||||
|
StatCard(
|
||||||
|
title: "Entries",
|
||||||
|
value: "\(stats.entryCount)",
|
||||||
|
icon: "list.bullet",
|
||||||
|
color: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stats.byProject.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("By Project")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ForEach(stats.byProject.prefix(5)) { projectStat in
|
||||||
|
HStack {
|
||||||
|
if let color = projectStat.projectColor {
|
||||||
|
ProjectColorDot(color: color)
|
||||||
|
}
|
||||||
|
Text(projectStat.projectName)
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text(TimeInterval(projectStat.totalSeconds).formattedShortDuration)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Work Balance Section
|
||||||
|
|
||||||
|
private var workBalanceSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Work Time Balance")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
ForEach(viewModel.clientTargets) { target in
|
||||||
|
WorkBalanceCard(target: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Entries
|
||||||
|
|
||||||
|
private var recentEntriesSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Recent Entries")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if viewModel.recentEntries.isEmpty {
|
||||||
|
Text("No entries yet")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(viewModel.recentEntries.enumerated()), id: \.element.id) { index, entry in
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: entry.project.color, size: 10)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.project.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(formatDate(entry.startTime))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(entry.duration.formattedShortDuration)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
if index < viewModel.recentEntries.count - 1 {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ isoString: String) -> String {
|
||||||
|
guard let date = Date.fromISO8601(isoString) else { return "" }
|
||||||
|
return date.formattedDateTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Work Balance Card
|
||||||
|
|
||||||
|
struct WorkBalanceCard: View {
|
||||||
|
let target: ClientTarget
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
|
private var totalBalance: TimeInterval { TimeInterval(target.totalBalanceSeconds) }
|
||||||
|
private var currentWeekTracked: TimeInterval { TimeInterval(target.currentWeekTrackedSeconds) }
|
||||||
|
private var currentWeekTarget: TimeInterval { TimeInterval(target.currentWeekTargetSeconds) }
|
||||||
|
|
||||||
|
private var balanceColor: Color {
|
||||||
|
if target.totalBalanceSeconds >= 0 { return .green }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
|
||||||
|
private var balanceLabel: String {
|
||||||
|
let abs = abs(totalBalance)
|
||||||
|
return target.totalBalanceSeconds >= 0
|
||||||
|
? "+\(abs.formattedShortDuration) overtime"
|
||||||
|
: "-\(abs.formattedShortDuration) undertime"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Header row
|
||||||
|
HStack {
|
||||||
|
Text(target.clientName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Spacer()
|
||||||
|
Text(balanceLabel)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(balanceColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This-week progress
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text("This week")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(currentWeekTracked.formattedShortDuration) / \(currentWeekTarget.formattedShortDuration)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if currentWeekTarget > 0 {
|
||||||
|
ProgressView(value: min(currentWeekTracked / currentWeekTarget, 1.0))
|
||||||
|
.tint(currentWeekTracked >= currentWeekTarget ? .green : .blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly breakdown (expandable)
|
||||||
|
if !target.weeks.isEmpty {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(expanded ? "Hide weeks" : "Show weeks")
|
||||||
|
.font(.caption)
|
||||||
|
Image(systemName: expanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if expanded {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(target.weeks.suffix(8).reversed()) { week in
|
||||||
|
WeekBalanceRow(week: week)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Week Balance Row
|
||||||
|
|
||||||
|
struct WeekBalanceRow: View {
|
||||||
|
let week: WeekBalance
|
||||||
|
|
||||||
|
private var balance: TimeInterval { TimeInterval(week.balanceSeconds) }
|
||||||
|
private var tracked: TimeInterval { TimeInterval(week.trackedSeconds) }
|
||||||
|
private var target: TimeInterval { TimeInterval(week.targetSeconds) }
|
||||||
|
private var balanceColor: Color { week.balanceSeconds >= 0 ? .green : .red }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(weekLabel)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(tracked.formattedShortDuration)
|
||||||
|
.font(.caption)
|
||||||
|
Text("/")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(target.formattedShortDuration)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(balanceText)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(balanceColor)
|
||||||
|
.frame(width: 70, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekLabel: String {
|
||||||
|
guard let date = parseDate(week.weekStart) else { return week.weekStart }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMM d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var balanceText: String {
|
||||||
|
let abs = Swift.abs(balance)
|
||||||
|
return week.balanceSeconds >= 0 ? "+\(abs.formattedShortDuration)" : "-\(abs.formattedShortDuration)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseDate(_ string: String) -> Date? {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class DashboardViewModel: ObservableObject {
|
||||||
|
@Published var activeTimer: OngoingTimer?
|
||||||
|
@Published var statistics: TimeStatistics?
|
||||||
|
@Published var recentEntries: [TimeEntry] = []
|
||||||
|
@Published var clientTargets: [ClientTarget] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var elapsedTime: TimeInterval = 0
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
private var timerTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
startElapsedTimeUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timerTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch active timer
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timer,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Statistics for this week
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let today = Date()
|
||||||
|
let startOfWeek = calendar.date(
|
||||||
|
from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today)
|
||||||
|
)!
|
||||||
|
let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)!
|
||||||
|
|
||||||
|
statistics = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntriesStatistics,
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "startDate", value: startOfWeek.iso8601FullDate),
|
||||||
|
URLQueryItem(name: "endDate", value: endOfWeek.iso8601FullDate)
|
||||||
|
],
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recent entries (last 5)
|
||||||
|
let entriesResponse: TimeEntryListResponse = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
queryItems: [URLQueryItem(name: "limit", value: "5")],
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
recentEntries = entriesResponse.entries
|
||||||
|
|
||||||
|
// Client targets (for overtime/undertime)
|
||||||
|
clientTargets = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clientTargets,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if let timer = activeTimer {
|
||||||
|
elapsedTime = timer.elapsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Fallback to cached timer
|
||||||
|
if let cachedTimer = try? await database.getCachedTimer() {
|
||||||
|
activeTimer = cachedTimer
|
||||||
|
elapsedTime = cachedTimer.elapsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startElapsedTimeUpdater() {
|
||||||
|
timerTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
guard let self = self, self.activeTimer != nil else { continue }
|
||||||
|
await MainActor.run {
|
||||||
|
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// ProjectsView.swift — replaced by Features/Settings/ProjectsListView.swift
|
||||||
|
// This file is intentionally empty; ProjectsViewModel is no longer used directly.
|
||||||
|
import Foundation
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ProjectsViewModel: ObservableObject {
|
||||||
|
@Published var projects: [Project] = []
|
||||||
|
@Published var clients: [Client] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
|
||||||
|
func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
clients = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
projects = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.saveProjects(projects)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Load from cache
|
||||||
|
projects = (try? await database.fetchProjects()) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createProject(name: String, description: String?, color: String?, clientId: String) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = CreateProjectInput(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
clientId: clientId
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProject(id: String, name: String, description: String?, color: String?, clientId: String) async {
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = UpdateProjectInput(
|
||||||
|
name: name,
|
||||||
|
description: description,
|
||||||
|
color: color,
|
||||||
|
clientId: clientId
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
await loadData()
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteProject(_ project: Project) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.project(id: project.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
projects.removeAll { $0.id == project.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Clients List
|
||||||
|
|
||||||
|
struct ClientsListView: View {
|
||||||
|
@State private var clients: [Client] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var showAddClient = false
|
||||||
|
@State private var clientToDelete: Client?
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading && clients.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else if let err = error, clients.isEmpty {
|
||||||
|
ErrorView(message: err) { Task { await loadClients() } }
|
||||||
|
} else if clients.isEmpty {
|
||||||
|
EmptyView(icon: "person.2", title: "No Clients",
|
||||||
|
message: "Create a client to organise your projects.")
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(clients) { client in
|
||||||
|
NavigationLink {
|
||||||
|
ClientDetailView(client: client, onUpdate: { Task { await loadClients() } })
|
||||||
|
} label: {
|
||||||
|
ClientRow(client: client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
if let i = indexSet.first {
|
||||||
|
clientToDelete = clients[i]
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await loadClients() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Clients")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showAddClient = true } label: { Image(systemName: "plus") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await loadClients() }
|
||||||
|
.sheet(isPresented: $showAddClient) {
|
||||||
|
ClientFormSheet(mode: .create) { Task { await loadClients() } }
|
||||||
|
}
|
||||||
|
.alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Delete", role: .destructive) { Task { await deleteClient(client) } }
|
||||||
|
} message: { client in
|
||||||
|
Text("Deleting '\(client.name)' will also delete all its projects and time entries. This cannot be undone.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadClients() async {
|
||||||
|
isLoading = true; error = nil
|
||||||
|
do {
|
||||||
|
clients = try await apiClient.request(endpoint: APIEndpoint.clients, authenticated: true)
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteClient(_ client: Client) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(endpoint: APIEndpoint.client(id: client.id),
|
||||||
|
method: .delete, authenticated: true)
|
||||||
|
clients.removeAll { $0.id == client.id }
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientRow: View {
|
||||||
|
let client: Client
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(client.name).font(.headline)
|
||||||
|
if let desc = client.description {
|
||||||
|
Text(desc).font(.subheadline).foregroundStyle(.secondary).lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Detail / Edit + Target Management
|
||||||
|
|
||||||
|
struct ClientDetailView: View {
|
||||||
|
let client: Client
|
||||||
|
let onUpdate: () -> Void
|
||||||
|
|
||||||
|
// Edit client fields
|
||||||
|
@State private var name: String
|
||||||
|
@State private var clientDescription: String
|
||||||
|
@State private var isSavingClient = false
|
||||||
|
@State private var clientSaveError: String?
|
||||||
|
@State private var clientSaveSuccess = false
|
||||||
|
|
||||||
|
// Client targets
|
||||||
|
@State private var target: ClientTarget?
|
||||||
|
@State private var isLoadingTarget = false
|
||||||
|
@State private var targetError: String?
|
||||||
|
|
||||||
|
// New target form
|
||||||
|
@State private var showNewTargetForm = false
|
||||||
|
@State private var newWeeklyHours = 40.0
|
||||||
|
@State private var newStartDate = Date().nextMonday()
|
||||||
|
@State private var isSavingTarget = false
|
||||||
|
|
||||||
|
// Edit target inline
|
||||||
|
@State private var editingWeeklyHours: Double?
|
||||||
|
@State private var editingStartDate: Date?
|
||||||
|
@State private var isEditingTarget = false
|
||||||
|
|
||||||
|
// Balance correction
|
||||||
|
@State private var showAddCorrection = false
|
||||||
|
@State private var correctionDate = Date()
|
||||||
|
@State private var correctionHours = 0.0
|
||||||
|
@State private var correctionDescription = ""
|
||||||
|
@State private var isSavingCorrection = false
|
||||||
|
@State private var correctionToDelete: BalanceCorrection?
|
||||||
|
@State private var showDeleteCorrection = false
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
init(client: Client, onUpdate: @escaping () -> Void) {
|
||||||
|
self.client = client
|
||||||
|
self.onUpdate = onUpdate
|
||||||
|
_name = State(initialValue: client.name)
|
||||||
|
_clientDescription = State(initialValue: client.description ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
clientEditSection
|
||||||
|
targetSection
|
||||||
|
if let target { correctionsSection(target) }
|
||||||
|
}
|
||||||
|
.navigationTitle(client.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task { await loadTarget() }
|
||||||
|
.sheet(isPresented: $showAddCorrection) {
|
||||||
|
addCorrectionSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client edit
|
||||||
|
|
||||||
|
private var clientEditSection: some View {
|
||||||
|
Section("Client Details") {
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
TextField("Description (optional)", text: $clientDescription, axis: .vertical)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
|
||||||
|
if let err = clientSaveError {
|
||||||
|
Text(err).font(.caption).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
if clientSaveSuccess {
|
||||||
|
Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green).font(.caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(isSavingClient ? "Saving…" : "Save Client Details") {
|
||||||
|
Task { await saveClient() }
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty || isSavingClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveClient() async {
|
||||||
|
isSavingClient = true; clientSaveError = nil; clientSaveSuccess = false
|
||||||
|
do {
|
||||||
|
let input = UpdateClientInput(
|
||||||
|
name: name,
|
||||||
|
description: clientDescription.isEmpty ? nil : clientDescription
|
||||||
|
)
|
||||||
|
let _: Client = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.client(id: client.id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
clientSaveSuccess = true
|
||||||
|
onUpdate()
|
||||||
|
} catch { clientSaveError = error.localizedDescription }
|
||||||
|
isSavingClient = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Target section
|
||||||
|
|
||||||
|
private var targetSection: some View {
|
||||||
|
Section {
|
||||||
|
if isLoadingTarget {
|
||||||
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
} else if let err = targetError {
|
||||||
|
Text(err).font(.caption).foregroundStyle(.red)
|
||||||
|
} else if let target {
|
||||||
|
// Show existing target + balance
|
||||||
|
targetSummaryRows(target)
|
||||||
|
if isEditingTarget {
|
||||||
|
targetEditRows(target)
|
||||||
|
} else {
|
||||||
|
Button("Edit Target") { startEditingTarget(target) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No target yet
|
||||||
|
if showNewTargetForm {
|
||||||
|
newTargetFormRows
|
||||||
|
} else {
|
||||||
|
Button("Set Up Work Time Target") { showNewTargetForm = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Work Time Target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func targetSummaryRows(_ t: ClientTarget) -> some View {
|
||||||
|
Group {
|
||||||
|
HStack {
|
||||||
|
Text("Weekly hours")
|
||||||
|
Spacer()
|
||||||
|
Text("\(t.weeklyHours, specifier: "%.1f") h/week")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Tracking since")
|
||||||
|
Spacer()
|
||||||
|
Text(formatDate(t.startDate))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("This week")
|
||||||
|
Spacer()
|
||||||
|
Text("\(TimeInterval(t.currentWeekTrackedSeconds).formattedShortDuration) / \(TimeInterval(t.currentWeekTargetSeconds).formattedShortDuration)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Total balance")
|
||||||
|
Spacer()
|
||||||
|
let balance = TimeInterval(abs(t.totalBalanceSeconds))
|
||||||
|
Text(t.totalBalanceSeconds >= 0 ? "+\(balance.formattedShortDuration)" : "-\(balance.formattedShortDuration)")
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(t.totalBalanceSeconds >= 0 ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func targetEditRows(_ t: ClientTarget) -> some View {
|
||||||
|
Group {
|
||||||
|
HStack {
|
||||||
|
Text("Weekly hours")
|
||||||
|
Spacer()
|
||||||
|
TextField("Hours", value: Binding(
|
||||||
|
get: { editingWeeklyHours ?? t.weeklyHours },
|
||||||
|
set: { editingWeeklyHours = $0 }
|
||||||
|
), format: .number)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
DatePicker("Start date (Monday)",
|
||||||
|
selection: Binding(
|
||||||
|
get: { editingStartDate ?? parseDate(t.startDate) ?? Date() },
|
||||||
|
set: { editingStartDate = $0 }
|
||||||
|
),
|
||||||
|
displayedComponents: .date)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { isEditingTarget = false; editingWeeklyHours = nil; editingStartDate = nil }
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button(isSavingTarget ? "Saving…" : "Save Target") {
|
||||||
|
Task { await saveTarget(existingId: t.id) }
|
||||||
|
}
|
||||||
|
.disabled(isSavingTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var newTargetFormRows: some View {
|
||||||
|
Group {
|
||||||
|
HStack {
|
||||||
|
Text("Weekly hours")
|
||||||
|
Spacer()
|
||||||
|
TextField("Hours", value: $newWeeklyHours, format: .number)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
}
|
||||||
|
DatePicker("Start date (Monday)", selection: $newStartDate, displayedComponents: .date)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { showNewTargetForm = false }
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button(isSavingTarget ? "Saving…" : "Create Target") {
|
||||||
|
Task { await createTarget() }
|
||||||
|
}
|
||||||
|
.disabled(newWeeklyHours <= 0 || isSavingTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startEditingTarget(_ t: ClientTarget) {
|
||||||
|
editingWeeklyHours = t.weeklyHours
|
||||||
|
editingStartDate = parseDate(t.startDate)
|
||||||
|
isEditingTarget = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corrections section
|
||||||
|
|
||||||
|
private func correctionsSection(_ t: ClientTarget) -> some View {
|
||||||
|
Section {
|
||||||
|
if t.corrections.isEmpty {
|
||||||
|
Text("No corrections")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
} else {
|
||||||
|
ForEach(t.corrections) { correction in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(formatDate(correction.date))
|
||||||
|
.font(.subheadline)
|
||||||
|
if let desc = correction.description {
|
||||||
|
Text(desc).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(correction.hours >= 0 ? "+\(correction.hours, specifier: "%.1f")h" : "\(correction.hours, specifier: "%.1f")h")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(correction.hours >= 0 ? .green : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
if let i = indexSet.first {
|
||||||
|
correctionToDelete = t.corrections[i]
|
||||||
|
showDeleteCorrection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Add Correction") { showAddCorrection = true }
|
||||||
|
} header: {
|
||||||
|
Text("Balance Corrections")
|
||||||
|
}
|
||||||
|
.alert("Delete Correction?", isPresented: $showDeleteCorrection, presenting: correctionToDelete) { correction in
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
Task { await deleteCorrection(correction) }
|
||||||
|
}
|
||||||
|
} message: { correction in
|
||||||
|
Text("Remove the \(correction.hours >= 0 ? "+" : "")\(correction.hours, specifier: "%.1f")h correction on \(formatDate(correction.date))?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add correction sheet
|
||||||
|
|
||||||
|
private var addCorrectionSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Date") {
|
||||||
|
DatePicker("Date", selection: $correctionDate, displayedComponents: .date)
|
||||||
|
}
|
||||||
|
Section("Hours adjustment") {
|
||||||
|
HStack {
|
||||||
|
TextField("Hours (positive = bonus, negative = penalty)",
|
||||||
|
value: $correctionHours, format: .number)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
Text("h").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("Positive values reduce the weekly target; negative values increase it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Section("Description (optional)") {
|
||||||
|
TextField("Note", text: $correctionDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Correction")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { showAddCorrection = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(isSavingCorrection ? "Saving…" : "Add") {
|
||||||
|
Task { await addCorrection() }
|
||||||
|
}
|
||||||
|
.disabled(correctionHours == 0 || isSavingCorrection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API calls
|
||||||
|
|
||||||
|
private func loadTarget() async {
|
||||||
|
isLoadingTarget = true; targetError = nil
|
||||||
|
do {
|
||||||
|
let allTargets: [ClientTarget] = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clientTargets, authenticated: true
|
||||||
|
)
|
||||||
|
target = allTargets.first { $0.clientId == client.id }
|
||||||
|
} catch { targetError = error.localizedDescription }
|
||||||
|
isLoadingTarget = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createTarget() async {
|
||||||
|
isSavingTarget = true
|
||||||
|
do {
|
||||||
|
let input = CreateClientTargetInput(
|
||||||
|
clientId: client.id,
|
||||||
|
weeklyHours: newWeeklyHours,
|
||||||
|
startDate: newStartDate.iso8601FullDate
|
||||||
|
)
|
||||||
|
let created: ClientTarget = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clientTargets,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
target = created
|
||||||
|
showNewTargetForm = false
|
||||||
|
} catch { targetError = error.localizedDescription }
|
||||||
|
isSavingTarget = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveTarget(existingId: String) async {
|
||||||
|
isSavingTarget = true
|
||||||
|
do {
|
||||||
|
let input = UpdateClientTargetInput(
|
||||||
|
weeklyHours: editingWeeklyHours,
|
||||||
|
startDate: editingStartDate?.iso8601FullDate
|
||||||
|
)
|
||||||
|
let _: ClientTarget = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clientTarget(id: existingId),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
isEditingTarget = false
|
||||||
|
editingWeeklyHours = nil
|
||||||
|
editingStartDate = nil
|
||||||
|
await loadTarget() // reload to get fresh balance
|
||||||
|
} catch { targetError = error.localizedDescription }
|
||||||
|
isSavingTarget = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addCorrection() async {
|
||||||
|
guard let t = target else { return }
|
||||||
|
isSavingCorrection = true
|
||||||
|
do {
|
||||||
|
let input = CreateBalanceCorrectionInput(
|
||||||
|
date: correctionDate.iso8601FullDate,
|
||||||
|
hours: correctionHours,
|
||||||
|
description: correctionDescription.isEmpty ? nil : correctionDescription
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.clientTargetCorrections(targetId: t.id),
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
correctionHours = 0
|
||||||
|
correctionDescription = ""
|
||||||
|
showAddCorrection = false
|
||||||
|
await loadTarget()
|
||||||
|
} catch { targetError = error.localizedDescription }
|
||||||
|
isSavingCorrection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteCorrection(_ correction: BalanceCorrection) async {
|
||||||
|
guard let t = target else { return }
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.clientTargetCorrection(targetId: t.id, correctionId: correction.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
await loadTarget()
|
||||||
|
} catch { targetError = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatDate(_ string: String) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
guard let d = f.date(from: string) else { return string }
|
||||||
|
let out = DateFormatter()
|
||||||
|
out.dateStyle = .medium
|
||||||
|
return out.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseDate(_ string: String) -> Date? {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Client Form Sheet (create / edit)
|
||||||
|
|
||||||
|
struct ClientFormSheet: View {
|
||||||
|
enum Mode {
|
||||||
|
case create
|
||||||
|
case edit(Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
init(mode: Mode, onSave: @escaping () -> Void) {
|
||||||
|
self.mode = mode
|
||||||
|
self.onSave = onSave
|
||||||
|
if case .edit(let client) = mode {
|
||||||
|
_name = State(initialValue: client.name)
|
||||||
|
_description = State(initialValue: client.description ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Client name", text: $name)
|
||||||
|
}
|
||||||
|
Section("Description (optional)") {
|
||||||
|
TextField("Description", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).font(.caption).foregroundStyle(.red) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(isEditing ? "Edit Client" : "New Client")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(isSaving ? "Saving…" : "Save") {
|
||||||
|
Task { await save() }
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty || isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isEditing: Bool {
|
||||||
|
if case .edit = mode { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
isSaving = true; error = nil
|
||||||
|
do {
|
||||||
|
switch mode {
|
||||||
|
case .create:
|
||||||
|
let input = CreateClientInput(name: name, description: description.isEmpty ? nil : description)
|
||||||
|
let _: Client = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clients, method: .post, body: input, authenticated: true
|
||||||
|
)
|
||||||
|
case .edit(let client):
|
||||||
|
let input = UpdateClientInput(name: name, description: description.isEmpty ? nil : description)
|
||||||
|
let _: Client = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.client(id: client.id), method: .put, body: input, authenticated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
|
dismiss()
|
||||||
|
onSave()
|
||||||
|
} catch {
|
||||||
|
isSaving = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date extension
|
||||||
|
|
||||||
|
private extension Date {
|
||||||
|
func nextMonday() -> Date {
|
||||||
|
let cal = Calendar.current
|
||||||
|
var comps = DateComponents()
|
||||||
|
comps.weekday = 2 // Monday
|
||||||
|
return cal.nextDate(after: self, matching: comps, matchingPolicy: .nextTime) ?? self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Projects List (under Settings)
|
||||||
|
|
||||||
|
struct ProjectsListView: View {
|
||||||
|
@State private var projects: [Project] = []
|
||||||
|
@State private var clients: [Client] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var showAddProject = false
|
||||||
|
@State private var projectToDelete: Project?
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading && projects.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else if let err = error, projects.isEmpty {
|
||||||
|
ErrorView(message: err) { Task { await loadData() } }
|
||||||
|
} else if projects.isEmpty {
|
||||||
|
EmptyView(icon: "folder", title: "No Projects",
|
||||||
|
message: "Create a project to start tracking time.")
|
||||||
|
} else {
|
||||||
|
projectList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Projects")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showAddProject = true } label: { Image(systemName: "plus") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await loadData() }
|
||||||
|
.sheet(isPresented: $showAddProject) {
|
||||||
|
ProjectFormSheet(mode: .create, clients: clients) {
|
||||||
|
Task { await loadData() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Delete Project?", isPresented: $showDeleteConfirmation, presenting: projectToDelete) { project in
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Delete", role: .destructive) { Task { await deleteProject(project) } }
|
||||||
|
} message: { project in
|
||||||
|
Text("Deleting '\(project.name)' will also delete all its time entries. This cannot be undone.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group projects by client
|
||||||
|
private var projectsByClient: [(clientName: String, projects: [Project])] {
|
||||||
|
let grouped = Dictionary(grouping: projects) { $0.client.name }
|
||||||
|
return grouped.sorted { $0.key < $1.key }
|
||||||
|
.map { (clientName: $0.key, projects: $0.value.sorted { $0.name < $1.name }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var projectList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(projectsByClient, id: \.clientName) { group in
|
||||||
|
Section(group.clientName) {
|
||||||
|
ForEach(group.projects) { project in
|
||||||
|
NavigationLink {
|
||||||
|
ProjectDetailView(project: project, clients: clients) {
|
||||||
|
Task { await loadData() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
ProjectListRow(project: project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
let deleteTargets = indexSet.map { group.projects[$0] }
|
||||||
|
if let first = deleteTargets.first {
|
||||||
|
projectToDelete = first
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await loadData() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadData() async {
|
||||||
|
isLoading = true; error = nil
|
||||||
|
do {
|
||||||
|
async let fetchProjects: [Project] = apiClient.request(endpoint: APIEndpoint.projects, authenticated: true)
|
||||||
|
async let fetchClients: [Client] = apiClient.request(endpoint: APIEndpoint.clients, authenticated: true)
|
||||||
|
projects = try await fetchProjects
|
||||||
|
clients = try await fetchClients
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteProject(_ project: Project) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(endpoint: APIEndpoint.project(id: project.id),
|
||||||
|
method: .delete, authenticated: true)
|
||||||
|
projects.removeAll { $0.id == project.id }
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project list row
|
||||||
|
|
||||||
|
struct ProjectListRow: View {
|
||||||
|
let project: Project
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ProjectColorDot(color: project.color, size: 14)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(project.name).font(.headline)
|
||||||
|
if let desc = project.description {
|
||||||
|
Text(desc).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project Detail / Edit
|
||||||
|
|
||||||
|
struct ProjectDetailView: View {
|
||||||
|
let project: Project
|
||||||
|
let clients: [Client]
|
||||||
|
let onUpdate: () -> Void
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var projectDescription: String
|
||||||
|
@State private var selectedColor: String
|
||||||
|
@State private var selectedClient: Client?
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var saveError: String?
|
||||||
|
@State private var saveSuccess = false
|
||||||
|
|
||||||
|
private let colorPalette = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
|
||||||
|
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
init(project: Project, clients: [Client], onUpdate: @escaping () -> Void) {
|
||||||
|
self.project = project
|
||||||
|
self.clients = clients
|
||||||
|
self.onUpdate = onUpdate
|
||||||
|
_name = State(initialValue: project.name)
|
||||||
|
_projectDescription = State(initialValue: project.description ?? "")
|
||||||
|
_selectedColor = State(initialValue: project.color ?? "#3B82F6")
|
||||||
|
_selectedClient = State(initialValue: clients.first { $0.id == project.clientId })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Project name", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Description (optional)") {
|
||||||
|
TextField("Description", text: $projectDescription, axis: .vertical)
|
||||||
|
.lineLimit(2...5)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Colour") {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
|
||||||
|
ForEach(colorPalette, id: \.self) { color in
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: color))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.overlay(
|
||||||
|
Circle().strokeBorder(
|
||||||
|
Color.primary,
|
||||||
|
lineWidth: selectedColor == color ? 3 : 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onTapGesture { selectedColor = color }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Client") {
|
||||||
|
Picker("Client", selection: $selectedClient) {
|
||||||
|
Text("Select Client").tag(nil as Client?)
|
||||||
|
ForEach(clients) { client in
|
||||||
|
Text(client.name).tag(client as Client?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let err = saveError {
|
||||||
|
Section { Text(err).font(.caption).foregroundStyle(.red) }
|
||||||
|
}
|
||||||
|
if saveSuccess {
|
||||||
|
Section { Label("Saved", systemImage: "checkmark.circle").foregroundStyle(.green) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(isSaving ? "Saving…" : "Save Project") {
|
||||||
|
Task { await save() }
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty || selectedClient == nil || isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(project.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.onAppear {
|
||||||
|
// Ensure selectedClient resolves correctly once clients are available
|
||||||
|
if selectedClient == nil {
|
||||||
|
selectedClient = clients.first { $0.id == project.clientId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let client = selectedClient else { return }
|
||||||
|
isSaving = true; saveError = nil; saveSuccess = false
|
||||||
|
do {
|
||||||
|
let input = UpdateProjectInput(
|
||||||
|
name: name,
|
||||||
|
description: projectDescription.isEmpty ? nil : projectDescription,
|
||||||
|
color: selectedColor,
|
||||||
|
clientId: client.id
|
||||||
|
)
|
||||||
|
let _: Project = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.project(id: project.id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
saveSuccess = true
|
||||||
|
onUpdate()
|
||||||
|
} catch { saveError = error.localizedDescription }
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project Form Sheet (create)
|
||||||
|
|
||||||
|
struct ProjectFormSheet: View {
|
||||||
|
enum Mode { case create }
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
let clients: [Client]
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var selectedColor = "#3B82F6"
|
||||||
|
@State private var selectedClient: Client?
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private let colorPalette = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
|
||||||
|
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Project name", text: $name)
|
||||||
|
}
|
||||||
|
Section("Description (optional)") {
|
||||||
|
TextField("Description", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(2...5)
|
||||||
|
}
|
||||||
|
Section("Colour") {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
|
||||||
|
ForEach(colorPalette, id: \.self) { color in
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: color))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.overlay(
|
||||||
|
Circle().strokeBorder(
|
||||||
|
Color.primary,
|
||||||
|
lineWidth: selectedColor == color ? 3 : 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onTapGesture { selectedColor = color }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
Section("Client") {
|
||||||
|
Picker("Client", selection: $selectedClient) {
|
||||||
|
Text("Select Client").tag(nil as Client?)
|
||||||
|
ForEach(clients) { client in
|
||||||
|
Text(client.name).tag(client as Client?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Section { Text(error).font(.caption).foregroundStyle(.red) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Project")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(isSaving ? "Saving…" : "Save") {
|
||||||
|
Task { await save() }
|
||||||
|
}
|
||||||
|
.disabled(name.isEmpty || selectedClient == nil || isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let client = selectedClient else { return }
|
||||||
|
isSaving = true; error = nil
|
||||||
|
do {
|
||||||
|
let input = CreateProjectInput(
|
||||||
|
name: name,
|
||||||
|
description: description.isEmpty ? nil : description,
|
||||||
|
color: selectedColor,
|
||||||
|
clientId: client.id
|
||||||
|
)
|
||||||
|
let _: Project = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects, method: .post, body: input, authenticated: true
|
||||||
|
)
|
||||||
|
isSaving = false
|
||||||
|
dismiss()
|
||||||
|
onSave()
|
||||||
|
} catch {
|
||||||
|
isSaving = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
@State private var showLogoutConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
// User info header
|
||||||
|
if let user = authManager.currentUser {
|
||||||
|
Section {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor.opacity(0.15))
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.overlay(
|
||||||
|
Text(user.username.prefix(1).uppercased())
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(user.fullName ?? user.username)
|
||||||
|
.font(.headline)
|
||||||
|
Text(user.email)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
Section("Data") {
|
||||||
|
NavigationLink {
|
||||||
|
ClientsListView()
|
||||||
|
} label: {
|
||||||
|
Label("Clients", systemImage: "person.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
ProjectsListView()
|
||||||
|
} label: {
|
||||||
|
Label("Projects", systemImage: "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showLogoutConfirmation = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.alert("Sign Out?", isPresented: $showLogoutConfirmation) {
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Sign Out", role: .destructive) {
|
||||||
|
Task { try? await authManager.logout() }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("You will be signed out and need to sign in again to use the app.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimeEntriesView: View {
|
||||||
|
@StateObject private var viewModel = TimeEntriesViewModel()
|
||||||
|
|
||||||
|
// dayOffset is the source of truth: 0 = today, -1 = yesterday, etc.
|
||||||
|
@State private var dayOffset: Int = 0
|
||||||
|
// tabSelection is always snapped back to 1 (middle) after each swipe.
|
||||||
|
// Pages are: 0 = dayOffset-1, 1 = dayOffset, 2 = dayOffset+1
|
||||||
|
@State private var tabSelection: Int = 1
|
||||||
|
|
||||||
|
@State private var showFilterSheet = false
|
||||||
|
@State private var showAddEntry = false
|
||||||
|
@State private var entryToEdit: TimeEntry?
|
||||||
|
@State private var entryToDelete: TimeEntry?
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.entries.isEmpty {
|
||||||
|
LoadingView()
|
||||||
|
} else if let error = viewModel.error, viewModel.entries.isEmpty {
|
||||||
|
ErrorView(message: error) {
|
||||||
|
Task { await viewModel.loadEntries() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mainContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Entries")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { toolbarContent }
|
||||||
|
.task { await viewModel.loadEntries() }
|
||||||
|
.refreshable { await viewModel.loadEntries() }
|
||||||
|
.sheet(isPresented: $showFilterSheet) {
|
||||||
|
TimeEntriesFilterSheet(viewModel: viewModel) {
|
||||||
|
Task { await viewModel.loadEntries() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddEntry) {
|
||||||
|
TimeEntryDetailSheet(entry: nil) {
|
||||||
|
Task { await viewModel.loadEntries() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $entryToEdit) { entry in
|
||||||
|
TimeEntryDetailSheet(entry: entry) {
|
||||||
|
Task { await viewModel.loadEntries() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Delete Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
Task { await viewModel.deleteEntry(entry) }
|
||||||
|
}
|
||||||
|
} message: { entry in
|
||||||
|
Text("Delete the time entry for '\(entry.project.name)'? This cannot be undone.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarContent: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showAddEntry = true } label: { Image(systemName: "plus") }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.loadFilterSupportData() }
|
||||||
|
showFilterSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.activeFilters.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place the DatePicker in the principal placement (center of nav bar)
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
DatePicker(
|
||||||
|
"",
|
||||||
|
selection: Binding(
|
||||||
|
get: {
|
||||||
|
Calendar.current.date(byAdding: .day, value: dayOffset, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
||||||
|
},
|
||||||
|
set: { newDate in
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
let normalizedNewDate = Calendar.current.startOfDay(for: newDate)
|
||||||
|
let components = Calendar.current.dateComponents([.day], from: today, to: normalizedNewDate)
|
||||||
|
if let dayDifference = components.day {
|
||||||
|
dayOffset = dayDifference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
displayedComponents: .date
|
||||||
|
)
|
||||||
|
.datePickerStyle(.compact)
|
||||||
|
.labelsHidden()
|
||||||
|
.environment(\.locale, Locale.current) // Ensure correct start of week
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Main content
|
||||||
|
|
||||||
|
private var mainContent: some View {
|
||||||
|
// Only 3 pages exist at any time: previous, current, next.
|
||||||
|
// After each swipe settles, we reset tabSelection to 1 and shift
|
||||||
|
// dayOffset, so the carousel appears infinite while staying cheap.
|
||||||
|
TabView(selection: $tabSelection) {
|
||||||
|
ForEach(0..<3, id: \.self) { page in
|
||||||
|
let offset = dayOffset + (page - 1)
|
||||||
|
let day = Calendar.current.date(
|
||||||
|
byAdding: .day,
|
||||||
|
value: offset,
|
||||||
|
to: Calendar.current.startOfDay(for: Date())
|
||||||
|
) ?? Date()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
dayEntriesSection(for: day)
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Day entries section
|
||||||
|
|
||||||
|
private func dayEntriesSection(for day: Date) -> some View {
|
||||||
|
let dayEntries = viewModel.entries(for: day)
|
||||||
|
return VStack(alignment: .leading, spacing: 0) {
|
||||||
|
|
||||||
|
// Optional: A small summary header for the day
|
||||||
|
HStack {
|
||||||
|
Text(dayTitle(day))
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
Text(dayEntries.isEmpty ? "No entries" : "\(dayEntries.count) \(dayEntries.count == 1 ? "entry" : "entries")")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
|
if dayEntries.isEmpty {
|
||||||
|
Text("No entries for this day")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, 32)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(dayEntries.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 < dayEntries.count - 1 {
|
||||||
|
Divider().padding(.leading, 56)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func dayTitle(_ date: Date) -> String {
|
||||||
|
let cal = Calendar.current
|
||||||
|
if cal.isDateInToday(date) { return "Today" }
|
||||||
|
if cal.isDateInYesterday(date) { return "Yesterday" }
|
||||||
|
if cal.isDateInTomorrow(date) { return "Tomorrow" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEEE, MMM d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entry Row
|
||||||
|
|
||||||
|
struct EntryRow: View {
|
||||||
|
let entry: TimeEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
// Color dot
|
||||||
|
ProjectColorDot(color: entry.project.color, size: 12)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(entry.project.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(timeRange)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("·")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(entry.project.client.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let desc = entry.description, !desc.isEmpty {
|
||||||
|
Text(desc)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(entry.duration.formattedShortDuration)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeRange: String {
|
||||||
|
let fmt = DateFormatter()
|
||||||
|
fmt.dateFormat = "HH:mm"
|
||||||
|
let start = Date.fromISO8601(entry.startTime).map { fmt.string(from: $0) } ?? ""
|
||||||
|
let end = Date.fromISO8601(entry.endTime).map { fmt.string(from: $0) } ?? ""
|
||||||
|
return "\(start) – \(end)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Filter Sheet
|
||||||
|
|
||||||
|
struct TimeEntriesFilterSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ObservedObject var viewModel: TimeEntriesViewModel
|
||||||
|
let onApply: () -> Void
|
||||||
|
|
||||||
|
@State private var startDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||||
|
@State private var endDate: Date = Date()
|
||||||
|
@State private var useStartDate = false
|
||||||
|
@State private var useEndDate = false
|
||||||
|
@State private var selectedProjectId: String?
|
||||||
|
@State private var selectedProjectName: String?
|
||||||
|
@State private var selectedClientId: String?
|
||||||
|
@State private var selectedClientName: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Date Range") {
|
||||||
|
Toggle("From", isOn: $useStartDate)
|
||||||
|
if useStartDate {
|
||||||
|
DatePicker("", selection: $startDate, displayedComponents: .date)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
Toggle("To", isOn: $useEndDate)
|
||||||
|
if useEndDate {
|
||||||
|
DatePicker("", selection: $endDate, displayedComponents: .date)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Project") {
|
||||||
|
Picker("Project", selection: $selectedProjectId) {
|
||||||
|
Text("Any Project").tag(nil as String?)
|
||||||
|
ForEach(viewModel.projects) { project in
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: project.color, size: 10)
|
||||||
|
Text(project.name)
|
||||||
|
}
|
||||||
|
.tag(project.id as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
.onChange(of: selectedProjectId) { _, newId in
|
||||||
|
selectedProjectName = viewModel.projects.first { $0.id == newId }?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Client") {
|
||||||
|
Picker("Client", selection: $selectedClientId) {
|
||||||
|
Text("Any Client").tag(nil as String?)
|
||||||
|
ForEach(viewModel.clients) { client in
|
||||||
|
Text(client.name).tag(client.id as String?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
.onChange(of: selectedClientId) { _, newId in
|
||||||
|
selectedClientName = viewModel.clients.first { $0.id == newId }?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Clear All Filters", role: .destructive) {
|
||||||
|
useStartDate = false
|
||||||
|
useEndDate = false
|
||||||
|
selectedProjectId = nil
|
||||||
|
selectedClientId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Filter Entries")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Apply") { applyAndDismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { loadCurrentFilters() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCurrentFilters() {
|
||||||
|
let f = viewModel.activeFilters
|
||||||
|
if let s = f.startDate { startDate = s; useStartDate = true }
|
||||||
|
if let e = f.endDate { endDate = e; useEndDate = true }
|
||||||
|
selectedProjectId = f.projectId
|
||||||
|
selectedClientId = f.clientId
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAndDismiss() {
|
||||||
|
viewModel.activeFilters = TimeEntryActiveFilters(
|
||||||
|
startDate: useStartDate ? startDate : nil,
|
||||||
|
endDate: useEndDate ? endDate : nil,
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
projectName: selectedProjectName,
|
||||||
|
clientId: selectedClientId,
|
||||||
|
clientName: selectedClientName
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
|
onApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Active Filters (UI state)
|
||||||
|
|
||||||
|
struct TimeEntryActiveFilters: Equatable {
|
||||||
|
var startDate: Date?
|
||||||
|
var endDate: Date?
|
||||||
|
var projectId: String?
|
||||||
|
var projectName: String?
|
||||||
|
var clientId: String?
|
||||||
|
var clientName: String?
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
startDate == nil && endDate == nil && projectId == nil && clientId == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModel
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TimeEntriesViewModel: ObservableObject {
|
||||||
|
// All loaded entries (flat list, accumulated across pages)
|
||||||
|
@Published var entries: [TimeEntry] = []
|
||||||
|
@Published var pagination: Pagination?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isLoadingMore = false
|
||||||
|
@Published var error: String?
|
||||||
|
|
||||||
|
// Active filters driving the current fetch
|
||||||
|
@Published var activeFilters = TimeEntryActiveFilters()
|
||||||
|
|
||||||
|
// Projects and clients needed for filter sheet pickers
|
||||||
|
@Published var projects: [Project] = []
|
||||||
|
@Published var clients: [Client] = []
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
// MARK: - Fetch
|
||||||
|
|
||||||
|
func loadEntries(resetPage: Bool = true) async {
|
||||||
|
if resetPage { entries = [] }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: TimeEntryListResponse = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
queryItems: buildQueryItems(page: 1),
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
entries = response.entries
|
||||||
|
pagination = response.pagination
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMoreIfNeeded(currentEntry entry: TimeEntry) async {
|
||||||
|
guard let pagination, !isLoadingMore,
|
||||||
|
pagination.page < pagination.totalPages else { return }
|
||||||
|
// Trigger when the last entry in the list becomes visible
|
||||||
|
guard entries.last?.id == entry.id else { return }
|
||||||
|
|
||||||
|
isLoadingMore = true
|
||||||
|
let nextPage = pagination.page + 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: TimeEntryListResponse = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
queryItems: buildQueryItems(page: nextPage),
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
entries.append(contentsOf: response.entries)
|
||||||
|
self.pagination = response.pagination
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteEntry(_ entry: TimeEntry) async {
|
||||||
|
do {
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: entry.id),
|
||||||
|
method: .delete,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
entries.removeAll { $0.id == entry.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting data for filter sheet
|
||||||
|
|
||||||
|
func loadFilterSupportData() async {
|
||||||
|
async let fetchProjects: [Project] = apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
async let fetchClients: [Client] = apiClient.request(
|
||||||
|
endpoint: APIEndpoint.clients,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
projects = (try? await fetchProjects) ?? []
|
||||||
|
clients = (try? await fetchClients) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Entries grouped by calendar day
|
||||||
|
|
||||||
|
var entriesByDay: [(date: Date, entries: [TimeEntry])] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let grouped = Dictionary(grouping: entries) { entry -> Date in
|
||||||
|
guard let d = Date.fromISO8601(entry.startTime) else { return Date() }
|
||||||
|
return calendar.startOfDay(for: d)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
.sorted { $0.key > $1.key }
|
||||||
|
.map { (date: $0.key, entries: $0.value.sorted {
|
||||||
|
(Date.fromISO8601($0.startTime) ?? Date()) > (Date.fromISO8601($1.startTime) ?? Date())
|
||||||
|
}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All calendar days that have at least one entry (for dot decorations)
|
||||||
|
var daysWithEntries: Set<Date> {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
return Set(entries.compactMap { entry in
|
||||||
|
guard let d = Date.fromISO8601(entry.startTime) else { return nil }
|
||||||
|
return calendar.startOfDay(for: d)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries for a specific calendar day
|
||||||
|
func entries(for day: Date) -> [TimeEntry] {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
return entries.filter { entry in
|
||||||
|
guard let d = Date.fromISO8601(entry.startTime) else { return false }
|
||||||
|
return calendar.isDate(d, inSameDayAs: day)
|
||||||
|
}.sorted {
|
||||||
|
(Date.fromISO8601($0.startTime) ?? Date()) < (Date.fromISO8601($1.startTime) ?? Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func buildQueryItems(page: Int) -> [URLQueryItem] {
|
||||||
|
var items: [URLQueryItem] = [
|
||||||
|
URLQueryItem(name: "page", value: "\(page)"),
|
||||||
|
URLQueryItem(name: "limit", value: "100")
|
||||||
|
]
|
||||||
|
if let start = activeFilters.startDate {
|
||||||
|
items.append(URLQueryItem(name: "startDate", value: start.iso8601String))
|
||||||
|
}
|
||||||
|
if let end = activeFilters.endDate {
|
||||||
|
// Push to end-of-day so the full day is included
|
||||||
|
let endOfDay = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: end) ?? end
|
||||||
|
items.append(URLQueryItem(name: "endDate", value: endOfDay.iso8601String))
|
||||||
|
}
|
||||||
|
if let pid = activeFilters.projectId {
|
||||||
|
items.append(URLQueryItem(name: "projectId", value: pid))
|
||||||
|
}
|
||||||
|
if let cid = activeFilters.clientId {
|
||||||
|
items.append(URLQueryItem(name: "clientId", value: cid))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Detail/edit sheet for a single time entry. Used both for creating new entries
|
||||||
|
/// (pass `entry: nil`) and editing existing ones.
|
||||||
|
struct TimeEntryDetailSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Pass an existing entry to edit it; pass nil to create a new one.
|
||||||
|
let entry: TimeEntry?
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
@State private var startDateTime = Date()
|
||||||
|
@State private var endDateTime = Date()
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var selectedProject: Project?
|
||||||
|
|
||||||
|
// Supporting data
|
||||||
|
@State private var projects: [Project] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
|
||||||
|
init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) {
|
||||||
|
self.entry = entry
|
||||||
|
self.onSave = onSave
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
projectSection
|
||||||
|
timeSection
|
||||||
|
descriptionSection
|
||||||
|
if let error { errorSection(error) }
|
||||||
|
}
|
||||||
|
.navigationTitle(entry == nil ? "New Entry" : "Edit Entry")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Button("Save") { Task { await save() } }
|
||||||
|
.disabled(selectedProject == nil || endDateTime <= startDateTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await loadProjects()
|
||||||
|
populateFromEntry()
|
||||||
|
}
|
||||||
|
.overlay { if isLoading { LoadingView() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sections
|
||||||
|
|
||||||
|
private var projectSection: some View {
|
||||||
|
Section("Project") {
|
||||||
|
Picker("Project", selection: $selectedProject) {
|
||||||
|
Text("Select Project").tag(nil as Project?)
|
||||||
|
ForEach(projects) { project in
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: project.color, size: 10)
|
||||||
|
Text(project.name)
|
||||||
|
Text("· \(project.client.name)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.tag(project as Project?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeSection: some View {
|
||||||
|
Section {
|
||||||
|
DatePicker("Start", selection: $startDateTime)
|
||||||
|
DatePicker("End", selection: $endDateTime, in: startDateTime...)
|
||||||
|
if endDateTime > startDateTime {
|
||||||
|
HStack {
|
||||||
|
Text("Duration")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(endDateTime.timeIntervalSince(startDateTime).formattedShortDuration)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var descriptionSection: some View {
|
||||||
|
Section("Description") {
|
||||||
|
TextField("Optional notes…", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorSection(_ message: String) -> some View {
|
||||||
|
Section {
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data loading
|
||||||
|
|
||||||
|
private func loadProjects() async {
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
projects = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
// Re-apply project selection now that the list is populated
|
||||||
|
matchProjectAfterLoad()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func populateFromEntry() {
|
||||||
|
guard let entry else {
|
||||||
|
// Default: now rounded to minute, 1-hour window
|
||||||
|
let now = Date().roundedToMinute()
|
||||||
|
startDateTime = now
|
||||||
|
endDateTime = now.addingTimeInterval(3600)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startDateTime = Date.fromISO8601(entry.startTime) ?? Date()
|
||||||
|
endDateTime = Date.fromISO8601(entry.endTime) ?? Date()
|
||||||
|
description = entry.description ?? ""
|
||||||
|
// Pre-select the project once projects are loaded
|
||||||
|
if !projects.isEmpty {
|
||||||
|
selectedProject = projects.first { $0.id == entry.projectId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called after projects load — re-apply the project selection if it wasn't
|
||||||
|
// set yet (projects may have loaded after populateFromEntry ran).
|
||||||
|
private func matchProjectAfterLoad() {
|
||||||
|
guard let entry, selectedProject == nil else { return }
|
||||||
|
selectedProject = projects.first { $0.id == entry.projectId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Save
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
guard let project = selectedProject else { return }
|
||||||
|
isSaving = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let existingEntry = entry {
|
||||||
|
let input = UpdateTimeEntryInput(
|
||||||
|
startTime: startDateTime.iso8601String,
|
||||||
|
endTime: endDateTime.iso8601String,
|
||||||
|
description: description.isEmpty ? nil : description,
|
||||||
|
projectId: project.id
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntry(id: existingEntry.id),
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let input = CreateTimeEntryInput(
|
||||||
|
startTime: startDateTime,
|
||||||
|
endTime: endDateTime,
|
||||||
|
description: description.isEmpty ? nil : description,
|
||||||
|
projectId: project.id
|
||||||
|
)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timeEntries,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isSaving = false
|
||||||
|
dismiss()
|
||||||
|
onSave()
|
||||||
|
} catch {
|
||||||
|
isSaving = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date rounding helper
|
||||||
|
|
||||||
|
private extension Date {
|
||||||
|
func roundedToMinute() -> Date {
|
||||||
|
let cal = Calendar.current
|
||||||
|
var comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: self)
|
||||||
|
comps.second = 0
|
||||||
|
return cal.date(from: comps) ?? self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// TimeEntryFormView.swift — replaced by TimeEntryDetailSheet.swift
|
||||||
|
import Foundation
|
||||||
209
ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift
Normal file
209
ios/TimeTracker/TimeTracker/Features/Timer/TimerView.swift
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TimerView: View {
|
||||||
|
@StateObject private var viewModel = TimerViewModel()
|
||||||
|
@State private var showProjectPicker = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isLoading && viewModel.activeTimer == nil {
|
||||||
|
LoadingView()
|
||||||
|
} else if let error = viewModel.error, viewModel.activeTimer == nil {
|
||||||
|
ErrorView(message: error) {
|
||||||
|
Task { await viewModel.loadData() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timerContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Timer")
|
||||||
|
.task {
|
||||||
|
await viewModel.loadData()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showProjectPicker) {
|
||||||
|
ProjectPickerSheet(
|
||||||
|
projects: viewModel.projects,
|
||||||
|
selectedProject: viewModel.selectedProject
|
||||||
|
) { project in
|
||||||
|
Task {
|
||||||
|
await viewModel.updateProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timerContent: some View {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Timer Display
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(viewModel.elapsedTime.formattedDuration)
|
||||||
|
.font(.system(size: 64, weight: .light, design: .monospaced))
|
||||||
|
.foregroundStyle(viewModel.activeTimer != nil ? .primary : .secondary)
|
||||||
|
|
||||||
|
TimerUnitLabels(elapsed: viewModel.elapsedTime)
|
||||||
|
|
||||||
|
if let project = viewModel.selectedProject {
|
||||||
|
ProjectColorBadge(
|
||||||
|
color: project.color,
|
||||||
|
name: project.name
|
||||||
|
)
|
||||||
|
} else if let timerProject = viewModel.activeTimer?.project {
|
||||||
|
ProjectColorBadge(
|
||||||
|
color: timerProject.color,
|
||||||
|
name: timerProject.name
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("No project selected")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if viewModel.activeTimer == nil {
|
||||||
|
Button {
|
||||||
|
showProjectPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
Text(viewModel.selectedProject?.name ?? "Select Project")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.startTimer() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
Text("Start Timer")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
showProjectPicker = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
Text("Change Project")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.large)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.stopTimer() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "stop.fill")
|
||||||
|
Text("Stop Timer")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(viewModel.selectedProject == nil && viewModel.activeTimer?.project == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Displays "h min sec" (or "min sec") column labels aligned under the
|
||||||
|
/// monospaced timer digits.
|
||||||
|
private struct TimerUnitLabels: View {
|
||||||
|
let elapsed: TimeInterval
|
||||||
|
|
||||||
|
private var showHours: Bool { Int(elapsed) >= 3600 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
if showHours {
|
||||||
|
Text("h")
|
||||||
|
.frame(width: 64)
|
||||||
|
Text("min")
|
||||||
|
.frame(width: 64)
|
||||||
|
} else {
|
||||||
|
Text("min")
|
||||||
|
.frame(width: 64)
|
||||||
|
}
|
||||||
|
Text("sec")
|
||||||
|
.frame(width: 64)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectPickerSheet: View {
|
||||||
|
let projects: [Project]
|
||||||
|
let selectedProject: Project?
|
||||||
|
let onSelect: (Project?) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Button {
|
||||||
|
onSelect(nil)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("No Project")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if selectedProject == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(projects) { project in
|
||||||
|
Button {
|
||||||
|
onSelect(project)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
ProjectColorDot(color: project.color)
|
||||||
|
Text(project.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(project.client.name)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if selectedProject?.id == project.id {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Select Project")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift
Normal file
159
ios/TimeTracker/TimeTracker/Features/Timer/TimerViewModel.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TimerViewModel: ObservableObject {
|
||||||
|
@Published var activeTimer: OngoingTimer?
|
||||||
|
@Published var projects: [Project] = []
|
||||||
|
@Published var selectedProject: Project?
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var error: String?
|
||||||
|
@Published var elapsedTime: TimeInterval = 0
|
||||||
|
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let database = DatabaseService.shared
|
||||||
|
private var timerTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
startElapsedTimeUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timerTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadData() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Fetch active timer
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timer,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache timer for widget
|
||||||
|
try await database.cacheTimer(activeTimer)
|
||||||
|
|
||||||
|
// Fetch projects
|
||||||
|
projects = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.projects,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set selected project if timer has one
|
||||||
|
if let timerProject = activeTimer?.project {
|
||||||
|
selectedProject = projects.first { $0.id == timerProject.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate elapsed time
|
||||||
|
if let timer = activeTimer {
|
||||||
|
elapsedTime = timer.elapsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
|
||||||
|
// Try to load cached data
|
||||||
|
if let cachedTimer = try? await database.getCachedTimer() {
|
||||||
|
activeTimer = cachedTimer
|
||||||
|
elapsedTime = cachedTimer.elapsedTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTimer() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = StartTimerInput(projectId: selectedProject?.id)
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timerStart,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.cacheTimer(activeTimer)
|
||||||
|
|
||||||
|
if let timer = activeTimer {
|
||||||
|
elapsedTime = timer.elapsedTime
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopTimer() async {
|
||||||
|
guard let timer = activeTimer else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
let projectId = selectedProject?.id ?? timer.projectId ?? ""
|
||||||
|
|
||||||
|
do {
|
||||||
|
let input = StopTimerInput(projectId: projectId)
|
||||||
|
try await apiClient.requestVoid(
|
||||||
|
endpoint: APIEndpoint.timerStop,
|
||||||
|
method: .post,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
activeTimer = nil
|
||||||
|
selectedProject = nil
|
||||||
|
elapsedTime = 0
|
||||||
|
|
||||||
|
try await database.cacheTimer(nil)
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProject(_ project: Project?) async {
|
||||||
|
selectedProject = project
|
||||||
|
|
||||||
|
guard let timer = activeTimer else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard let projectId = project?.id else { return }
|
||||||
|
|
||||||
|
let input = UpdateTimerInput(projectId: projectId)
|
||||||
|
activeTimer = try await apiClient.request(
|
||||||
|
endpoint: APIEndpoint.timer,
|
||||||
|
method: .put,
|
||||||
|
body: input,
|
||||||
|
authenticated: true
|
||||||
|
)
|
||||||
|
|
||||||
|
try await database.cacheTimer(activeTimer)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startElapsedTimeUpdater() {
|
||||||
|
timerTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
|
||||||
|
guard let self = self, self.activeTimer != nil else { continue }
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
ios/TimeTracker/TimeTracker/Info.plist
Normal file
66
ios/TimeTracker/TimeTracker/Info.plist
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_BASE_URL</key>
|
||||||
|
<string>https://timetracker.simon-franken.de/api</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.timetracker.app</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>timetracker</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIColorName</key>
|
||||||
|
<string>LaunchBackground</string>
|
||||||
|
</dict>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
|
<array>
|
||||||
|
<string>armv7</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
19
ios/TimeTracker/TimeTracker/Models/Client.swift
Normal file
19
ios/TimeTracker/TimeTracker/Models/Client.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Client: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateClientInput: Codable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateClientInput: Codable {
|
||||||
|
let name: String?
|
||||||
|
let description: String?
|
||||||
|
}
|
||||||
62
ios/TimeTracker/TimeTracker/Models/ClientTarget.swift
Normal file
62
ios/TimeTracker/TimeTracker/Models/ClientTarget.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Client Target
|
||||||
|
|
||||||
|
struct ClientTarget: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let clientId: String
|
||||||
|
let clientName: String
|
||||||
|
let userId: String
|
||||||
|
let weeklyHours: Double
|
||||||
|
let startDate: String // "YYYY-MM-DD"
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
let corrections: [BalanceCorrection]
|
||||||
|
|
||||||
|
// Computed balance fields returned by the API
|
||||||
|
let totalBalanceSeconds: Int
|
||||||
|
let currentWeekTrackedSeconds: Int
|
||||||
|
let currentWeekTargetSeconds: Int
|
||||||
|
let weeks: [WeekBalance]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Week Balance
|
||||||
|
|
||||||
|
struct WeekBalance: Codable, Identifiable, Equatable {
|
||||||
|
var id: String { weekStart }
|
||||||
|
let weekStart: String // "YYYY-MM-DD"
|
||||||
|
let weekEnd: String
|
||||||
|
let trackedSeconds: Int
|
||||||
|
let targetSeconds: Int
|
||||||
|
let correctionHours: Double
|
||||||
|
let balanceSeconds: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Balance Correction
|
||||||
|
|
||||||
|
struct BalanceCorrection: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let date: String // "YYYY-MM-DD"
|
||||||
|
let hours: Double
|
||||||
|
let description: String?
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input Types
|
||||||
|
|
||||||
|
struct CreateClientTargetInput: Codable {
|
||||||
|
let clientId: String
|
||||||
|
let weeklyHours: Double
|
||||||
|
let startDate: String // "YYYY-MM-DD", must be a Monday
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateClientTargetInput: Codable {
|
||||||
|
let weeklyHours: Double?
|
||||||
|
let startDate: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateBalanceCorrectionInput: Codable {
|
||||||
|
let date: String // "YYYY-MM-DD"
|
||||||
|
let hours: Double
|
||||||
|
let description: String?
|
||||||
|
}
|
||||||
39
ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift
Normal file
39
ios/TimeTracker/TimeTracker/Models/OngoingTimer.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct OngoingTimer: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let startTime: String
|
||||||
|
let projectId: String?
|
||||||
|
let project: ProjectReference?
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
var elapsedTime: TimeInterval {
|
||||||
|
guard let start = Date.fromISO8601(startTime) else { return 0 }
|
||||||
|
return Date().timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StartTimerInput: Codable {
|
||||||
|
let projectId: String?
|
||||||
|
|
||||||
|
init(projectId: String? = nil) {
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateTimerInput: Codable {
|
||||||
|
let projectId: String
|
||||||
|
|
||||||
|
init(projectId: String) {
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StopTimerInput: Codable {
|
||||||
|
let projectId: String
|
||||||
|
|
||||||
|
init(projectId: String) {
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
31
ios/TimeTracker/TimeTracker/Models/Project.swift
Normal file
31
ios/TimeTracker/TimeTracker/Models/Project.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Project: Codable, Identifiable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let clientId: String
|
||||||
|
let client: ClientReference
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientReference: Codable, Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateProjectInput: Codable {
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let clientId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateProjectInput: Codable {
|
||||||
|
let name: String?
|
||||||
|
let description: String?
|
||||||
|
let color: String?
|
||||||
|
let clientId: String?
|
||||||
|
}
|
||||||
89
ios/TimeTracker/TimeTracker/Models/TimeEntry.swift
Normal file
89
ios/TimeTracker/TimeTracker/Models/TimeEntry.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TimeEntry: Codable, Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let startTime: String
|
||||||
|
let endTime: String
|
||||||
|
let description: String?
|
||||||
|
let projectId: String
|
||||||
|
let project: ProjectReference
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
var duration: TimeInterval {
|
||||||
|
guard let start = Date.fromISO8601(startTime),
|
||||||
|
let end = Date.fromISO8601(endTime) else { return 0 }
|
||||||
|
return end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectReference: Codable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let color: String?
|
||||||
|
let client: ClientReference
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeEntryListResponse: Codable {
|
||||||
|
let entries: [TimeEntry]
|
||||||
|
let pagination: Pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pagination: Codable, Equatable {
|
||||||
|
let page: Int
|
||||||
|
let limit: Int
|
||||||
|
let total: Int
|
||||||
|
let totalPages: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TimeEntryFilters: Codable {
|
||||||
|
var startDate: String?
|
||||||
|
var endDate: String?
|
||||||
|
var projectId: String?
|
||||||
|
var clientId: String?
|
||||||
|
var page: Int?
|
||||||
|
var limit: Int?
|
||||||
|
|
||||||
|
init(
|
||||||
|
startDate: Date? = nil,
|
||||||
|
endDate: Date? = nil,
|
||||||
|
projectId: String? = nil,
|
||||||
|
clientId: String? = nil,
|
||||||
|
page: Int = 1,
|
||||||
|
limit: Int = 20
|
||||||
|
) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
|
||||||
|
self.startDate = startDate.map { formatter.string(from: $0) }
|
||||||
|
self.endDate = endDate.map { formatter.string(from: $0) }
|
||||||
|
self.projectId = projectId
|
||||||
|
self.clientId = clientId
|
||||||
|
self.page = page
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreateTimeEntryInput: Codable {
|
||||||
|
let startTime: String
|
||||||
|
let endTime: String
|
||||||
|
let description: String?
|
||||||
|
let projectId: String
|
||||||
|
|
||||||
|
init(startTime: Date, endTime: Date, description: String? = nil, projectId: String) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
|
||||||
|
self.startTime = formatter.string(from: startTime)
|
||||||
|
self.endTime = formatter.string(from: endTime)
|
||||||
|
self.description = description
|
||||||
|
self.projectId = projectId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdateTimeEntryInput: Codable {
|
||||||
|
let startTime: String?
|
||||||
|
let endTime: String?
|
||||||
|
let description: String?
|
||||||
|
let projectId: String?
|
||||||
|
}
|
||||||
57
ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift
Normal file
57
ios/TimeTracker/TimeTracker/Models/TimeStatistics.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct TimeStatistics: Codable, Equatable {
|
||||||
|
let totalSeconds: Int
|
||||||
|
let entryCount: Int
|
||||||
|
let byProject: [ProjectStatistics]
|
||||||
|
let byClient: [ClientStatistics]
|
||||||
|
let filters: StatisticsFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectStatistics: Codable, Identifiable, Equatable {
|
||||||
|
let projectId: String
|
||||||
|
let projectName: String
|
||||||
|
let projectColor: String?
|
||||||
|
let totalSeconds: Int
|
||||||
|
let entryCount: Int
|
||||||
|
|
||||||
|
var id: String { projectId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientStatistics: Codable, Identifiable, Equatable {
|
||||||
|
let clientId: String
|
||||||
|
let clientName: String
|
||||||
|
let totalSeconds: Int
|
||||||
|
let entryCount: Int
|
||||||
|
|
||||||
|
var id: String { clientId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatisticsFilters: Codable, Equatable {
|
||||||
|
let startDate: String?
|
||||||
|
let endDate: String?
|
||||||
|
let projectId: String?
|
||||||
|
let clientId: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatisticsFiltersInput: Codable {
|
||||||
|
let startDate: String?
|
||||||
|
let endDate: String?
|
||||||
|
let projectId: String?
|
||||||
|
let clientId: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
startDate: Date? = nil,
|
||||||
|
endDate: Date? = nil,
|
||||||
|
projectId: String? = nil,
|
||||||
|
clientId: String? = nil
|
||||||
|
) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
|
||||||
|
self.startDate = startDate.map { formatter.string(from: $0) }
|
||||||
|
self.endDate = endDate.map { formatter.string(from: $0) }
|
||||||
|
self.projectId = projectId
|
||||||
|
self.clientId = clientId
|
||||||
|
}
|
||||||
|
}
|
||||||
19
ios/TimeTracker/TimeTracker/Models/User.swift
Normal file
19
ios/TimeTracker/TimeTracker/Models/User.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct User: Codable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let fullName: String?
|
||||||
|
let email: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserResponse: Codable {
|
||||||
|
let id: String
|
||||||
|
let username: String
|
||||||
|
let fullName: String?
|
||||||
|
let email: String
|
||||||
|
|
||||||
|
func toUser() -> User {
|
||||||
|
User(id: id, username: username, fullName: fullName, email: email)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.984",
|
||||||
|
"green" : "0.584",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "app_icon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 918 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "1.000",
|
||||||
|
"green" : "1.000",
|
||||||
|
"red" : "1.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoadingView: View {
|
||||||
|
var message: String = "Loading..."
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorView: View {
|
||||||
|
let message: String
|
||||||
|
var retryAction: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let retryAction = retryAction {
|
||||||
|
Button("Retry", action: retryAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
var message: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
if let message = message {
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProjectColorDot: View {
|
||||||
|
let color: String?
|
||||||
|
var size: CGFloat = 12
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(colorValue)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var colorValue: Color {
|
||||||
|
if let hex = color {
|
||||||
|
return Color(hex: hex)
|
||||||
|
}
|
||||||
|
return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProjectColorBadge: View {
|
||||||
|
let color: String?
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProjectColorDot(color: color, size: 10)
|
||||||
|
Text(name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift
Normal file
68
ios/TimeTracker/TimeTracker/Shared/Components/StatCard.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StatCard: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let icon: String
|
||||||
|
var color: Color = .accentColor
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimeInterval {
|
||||||
|
/// Formats as a clock string used for the live timer display: "1:23:45" or "05:30".
|
||||||
|
var formattedDuration: String {
|
||||||
|
let totalSeconds = Int(self)
|
||||||
|
let hours = totalSeconds / 3600
|
||||||
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable duration used in lists and cards: "3h 48min", "45min", "< 1min".
|
||||||
|
var formattedShortDuration: String {
|
||||||
|
let totalSeconds = Int(self)
|
||||||
|
let hours = totalSeconds / 3600
|
||||||
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
|
||||||
|
if hours > 0 && minutes > 0 {
|
||||||
|
return "\(hours)h \(minutes)min"
|
||||||
|
} else if hours > 0 {
|
||||||
|
return "\(hours)h"
|
||||||
|
} else if minutes > 0 {
|
||||||
|
return "\(minutes)min"
|
||||||
|
} else {
|
||||||
|
return "< 1min"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats as hours with one decimal place, e.g. "3.8h". Used by the widget.
|
||||||
|
var formattedHours: String {
|
||||||
|
let hours = self / 3600
|
||||||
|
return String(format: "%.1fh", hours)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(hex: String) {
|
||||||
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
Scanner(string: hex).scanHexInt64(&int)
|
||||||
|
|
||||||
|
let a, r, g, b: UInt64
|
||||||
|
switch hex.count {
|
||||||
|
case 3: // RGB (12-bit)
|
||||||
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||||
|
case 6: // RGB (24-bit)
|
||||||
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
case 8: // ARGB (32-bit)
|
||||||
|
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
default:
|
||||||
|
(a, r, g, b) = (255, 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double(r) / 255,
|
||||||
|
green: Double(g) / 255,
|
||||||
|
blue: Double(b) / 255,
|
||||||
|
opacity: Double(a) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let defaultProjectColors: [Color] = [
|
||||||
|
Color(hex: "EF4444"), // Red
|
||||||
|
Color(hex: "F97316"), // Orange
|
||||||
|
Color(hex: "EAB308"), // Yellow
|
||||||
|
Color(hex: "22C55E"), // Green
|
||||||
|
Color(hex: "14B8A6"), // Teal
|
||||||
|
Color(hex: "06B6D4"), // Cyan
|
||||||
|
Color(hex: "3B82F6"), // Blue
|
||||||
|
Color(hex: "6366F1"), // Indigo
|
||||||
|
Color(hex: "A855F7"), // Purple
|
||||||
|
Color(hex: "EC4899"), // Pink
|
||||||
|
]
|
||||||
|
|
||||||
|
static func projectColor(for index: Int) -> Color {
|
||||||
|
defaultProjectColors[index % defaultProjectColors.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Date {
|
||||||
|
var startOfDay: Date {
|
||||||
|
Calendar.current.startOfDay(for: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfDay: Date {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.day = 1
|
||||||
|
components.second = -1
|
||||||
|
return Calendar.current.date(byAdding: components, to: startOfDay) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
var startOfWeek: Date {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: self)
|
||||||
|
return calendar.date(from: components) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
var endOfWeek: Date {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.day = 7
|
||||||
|
components.second = -1
|
||||||
|
return Calendar.current.date(byAdding: components, to: startOfWeek) ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatted(style: DateFormatter.Style) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = style
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formattedDateTime() -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var iso8601String: String {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var iso8601FullDate: String {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withFullDate]
|
||||||
|
return formatter.string(from: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromISO8601(_ string: String) -> Date? {
|
||||||
|
// Try with fractional seconds first (e.g. "2026-02-20T09:00:00.000Z" from
|
||||||
|
// Prisma/Node.js JSON serialisation), then fall back to whole seconds.
|
||||||
|
let withFractional = ISO8601DateFormatter()
|
||||||
|
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = withFractional.date(from: string) { return date }
|
||||||
|
|
||||||
|
let wholeSec = ISO8601DateFormatter()
|
||||||
|
wholeSec.formatOptions = [.withInternetDateTime]
|
||||||
|
return wholeSec.date(from: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func hideKeyboard() {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func `if`<Transform: View>(_ condition: Bool, transform: (Self) -> Transform) -> some View {
|
||||||
|
if condition {
|
||||||
|
transform(self)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FormFieldStyle: ViewModifier {
|
||||||
|
var isEditing: Bool = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(isEditing ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func formFieldStyle(isEditing: Bool = false) -> some View {
|
||||||
|
modifier(FormFieldStyle(isEditing: isEditing))
|
||||||
|
}
|
||||||
|
}
|
||||||
10
ios/TimeTracker/TimeTracker/TimeTracker.entitlements
Normal file
10
ios/TimeTracker/TimeTracker/TimeTracker.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.simonfranken.timetracker.app</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TimeTrackerApp: App {
|
||||||
|
@StateObject private var authManager = AuthManager.shared
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environmentObject(authManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@EnvironmentObject var authManager: AuthManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if authManager.isAuthenticated {
|
||||||
|
MainTabView()
|
||||||
|
} else {
|
||||||
|
LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await authManager.checkAuthState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MainTabView: View {
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
DashboardView()
|
||||||
|
.tabItem { Label("Dashboard", systemImage: "chart.bar") }
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
TimerView()
|
||||||
|
.tabItem { Label("Timer", systemImage: "timer") }
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
|
TimeEntriesView()
|
||||||
|
.tabItem { Label("Entries", systemImage: "calendar") }
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
|
SettingsView()
|
||||||
|
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||||
|
.tag(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user