39 Commits

Author SHA1 Message Date
simon.franken
a91a8e9dfa adds timer break feature requirement 2026-03-26 09:51:55 +01:00
simon.franken
fb94745588 adds instructions for feature development 2026-03-26 09:26:52 +01:00
simon.franken
8eddbed00b update docs 2026-03-26 09:17:41 +01:00
simon.franken
e83d247cf9 documents ios-authentication 2026-03-25 10:50:09 +01:00
simon.franken
88866f73e6 moves helm chart 2026-03-25 10:25:29 +01:00
ca521000bf Merge pull request 'feature/mcp-and-api-keys' (#11) from feature/mcp-and-api-keys into main
Reviewed-on: #11
2026-03-16 16:46:26 +00:00
simon.franken
a7ab55932f fix: review fixes for MCP and API key feature
- Remove spurious ALTER TABLE client_targets DROP DEFAULT from migration
  (Prisma schema drift side-effect unrelated to this PR)
- Surface revoke errors in ApiKeysPage UI via deleteApiKey.isError
- Fix UpdateProjectInput.color type to allow null, removing unsafe cast
  in the update_project MCP tool handler
2026-03-16 15:35:06 +01:00
simon.franken
64211e6a49 feat: add MCP endpoint and API key management
- Add ApiKey Prisma model (SHA-256 hash, prefix, lastUsedAt) with migration
- Implement ApiKeyService (create, list, delete, verify)
- Extend requireAuth middleware to accept sk_-prefixed API keys alongside JWTs
- Add GET/POST /api-keys routes for creating and revoking keys
- Add stateless Streamable HTTP MCP server at POST/GET /mcp exposing all 20
  time-tracking tools (clients, projects, time entries, timer, statistics,
  client targets and corrections)
- Frontend: ApiKey types, apiKeys API module, useApiKeys hook
- Frontend: ApiKeysPage with key table, one-time raw-key reveal modal, and
  inline revoke confirmation
- Wire /api-keys route and add API Keys link to Management dropdown in Navbar
2026-03-16 15:26:09 +01:00
cd03d8751e fix: timer widget blocks dialogs 2026-03-13 17:08:21 +00:00
1964f76f74 fix: add bg-gray-50 back to Layout 2026-03-13 17:07:32 +00:00
1f4e12298e fix 2026-03-13 17:46:53 +01:00
simon.franken
1049410fee adaption 2026-03-09 11:20:53 +01:00
c9bd0abf18 feat: include ongoing timer in today's tracked time on Dashboard
The 'Today' stat card now adds the running timer's elapsed seconds to
the total, so the displayed duration ticks up live alongside the timer
widget. The timer is only counted when it started today (timers carried
over from the previous day are excluded).

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

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

Fix: in the ongoing-period branch, sum only corrections whose date is
<= today, matching the same window used for elapsed working days and
tracked time.
2026-02-24 21:27:03 +01:00
7101f38bc8 feat: implement client targets v2 (weekly/monthly periods, working days, pro-ration)
- Add PeriodType enum and working_days column to ClientTarget schema
- Rename weekly_hours -> target_hours; remove Monday-only constraint
- Add migration 20260224000000_client_targets_v2
- Rewrite computeBalance() to support weekly/monthly periods, per-spec
  pro-ration for first period, ongoing vs completed period logic, and
  elapsed working-day counting (§4–§6 of requirements doc)
- Update Zod schemas and TypeScript input types for new fields
- Frontend: replace WeekBalance with PeriodBalance; update
  ClientTargetWithBalance to currentPeriod* fields
- ClientTargetPanel: period type radio, working-day toggles, free date
  picker, dynamic hours label
- DashboardPage: rename widget to Targets, dynamic This week/This month
  label
2026-02-24 19:02:32 +01:00
3850e2db06 docs: add client targets v2 feature requirements 2026-02-24 18:50:34 +01:00
5b7b8e47cb ui adaptions 2026-02-23 20:59:01 +01:00
7dd3873148 Merge branch 'main' into feature/soft-delete 2026-02-23 17:59:29 +01:00
850f12e09d Merge pull request 'feature/ios-time-entries-rework' (#2) from feature/ios-time-entries-rework into main
Reviewed-on: #2
2026-02-23 16:58:44 +00:00
74999ce265 Merge pull request 'ios-rebuild' (#3) from ios-rebuild into main
Reviewed-on: #3
2026-02-23 16:57:45 +00:00
0c0fbf42ef updates icons 2026-02-23 17:57:25 +01:00
0d116c8c26 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 17:29:21 +01:00
25b7371d08 Merge branch 'main' into ios-rebuild 2026-02-23 17:28:51 +01:00
simon.franken
ddb0926dba Implement soft-delete for client targets and balance corrections
Deleting a target or correction sets deletedAt instead of hard-deleting.
Creating a target for a user+client that has a soft-deleted record
reactivates it (clears deletedAt, applies new weeklyHours/startDate)
rather than failing the unique constraint. All reads filter deletedAt = null
on the target, its corrections, and the parent client.
2026-02-23 15:48:07 +01:00
simon.franken
c99bdf56e6 Merge branch 'ios-rebuild' into feature/ios-time-entries-rework 2026-02-23 10:12:12 +01:00
simon.franken
15abfe0511 Merge branch 'main' into ios-rebuild 2026-02-23 10:12:01 +01:00
simon.franken
544b86c948 fix(ios): replace 2001-page TabView with 3-page recycling carousel
Eliminates the eager instantiation of 2001 view bodies by keeping only
three pages (previous, current, next) alive at all times. After each
swipe settles, dayOffset is shifted and tabSelection is silently reset
to the middle page, preserving the native paging animation.
2026-02-23 10:09:51 +01:00
simon.franken
b971569983 feat(ios): replace week strip with native DatePicker and TabView paging 2026-02-23 10:02:24 +01:00
b613fe4edd Replace full-month UICalendarView with compact week strip in time entries 2026-02-21 18:01:02 +01:00
30d5139ad8 update 2026-02-21 14:03:56 +01:00
ef38578596 Fix .accentColor ShapeStyle compile error — use Color.accentColor 2026-02-21 13:57:31 +01:00
ba4765b8a2 Rebuild iOS app: calendar entries, overtime dashboard, settings tab, full CRUD
- Replace 5-tab layout with 4 tabs: Dashboard, Timer, Entries, Settings
- Dashboard: add Work Time Balance section using /client-targets API, showing
  per-client weekly progress bar, overtime/undertime label and expandable week breakdown
- Time Entries: replace flat list with UICalendarView month grid; tap a day to see
  that day's entries; add filter sheet (date range, project, client); new
  TimeEntryDetailSheet for creating and editing entries; duration shown as Xh Ymin
- Settings tab: user info header, navigation to Clients and Projects, logout button
- ClientsListView: list with NavigationLink to ClientDetailView
- ClientDetailView: inline client editing + full work time target CRUD (create,
  edit, delete target; add/delete balance corrections with date, hours, description)
- ProjectsListView: grouped by client, NavigationLink to ProjectDetailView
- ProjectDetailView: edit name, description, colour, client assignment
- Add ClientTarget, WeekBalance, BalanceCorrection models and APIEndpoints for
  /client-targets routes
- Update TimeInterval formatter: add formattedShortDuration (Xh Ymin / Xmin / <1min)
  used throughout app; keep formattedDuration for live timer display
2026-02-21 13:51:41 +01:00
67 changed files with 5117 additions and 1087 deletions

View File

@@ -0,0 +1,251 @@
# Feature Planning Skill
This skill provides a structured workflow for implementing new features in the TimeTracker project. Use this skill when the user requests a new feature or significant functionality change.
## Workflow Overview
```
1. Requirements Discovery (iterative)
└── Clarify edge cases, acceptance criteria, constraints
2. Feature Plan Creation
└── docs/features/{feature-name}.md
3. Implementation
└── Use plan as single source of truth
```
## Phase 1: Requirements Discovery
**Goal:** Understand exactly what needs to be built before writing any code.
### Questions to Ask
Ask targeted questions to clarify:
#### Core Functionality
- What is the primary purpose of this feature?
- What user problem does it solve?
- How should users interact with this feature?
#### Data & API
- What new data needs to be stored?
- What existing data structures are affected?
- What API endpoints are needed (if any)?
#### User Interface
- Where in the UI should this feature appear?
- What views or components are needed?
- What user interactions are required?
#### Edge Cases
- What happens when inputs are invalid?
- How should errors be handled?
- What are the boundary conditions?
- Are there any race conditions to consider?
#### Constraints
- Are there performance requirements?
- Any security considerations?
- Browser/device compatibility?
- Integration with existing features?
### Iteration Process
1. **Initial Questions** — Ask 3-5 focused questions based on the feature description
2. **Wait for Answers** — Let the programmer respond
3. **Follow-up Questions** — Dig deeper based on responses
4. **Confirm Understanding** — Summarize your understanding and ask for confirmation
5. **Iterate** — Repeat until requirements are clear
### Example Question Flow
```
User: "Add the ability to export time entries to CSV"
Agent: "Before I start planning, I have a few questions:
1. Should the export include all entries or only filtered entries (if filters are active)?
2. What columns should be included in the CSV? (date, duration, project, client, description?)
3. Should the CSV include break minutes and net duration, or just total time?
4. Is there a date range limit, or can users export all historical data?
5. Should the export be triggered from the Time Entries page, or from a separate Export page?"
```
## Phase 2: Feature Plan
**Goal:** Create a comprehensive plan document before implementation.
### Plan Location
Create the plan at: `docs/features/{feature-name}.md`
Use kebab-case for the filename (e.g., `csv-export.md`, `dark-mode.md`).
### Plan Template
```markdown
# Feature: {Feature Name}
## Overview
Brief description of what this feature does and why it's needed.
## Requirements
### Functional Requirements
- Requirement 1
- Requirement 2
- Requirement 3
### Non-Functional Requirements
- Performance: ...
- Security: ...
- Usability: ...
### Constraints
- Constraint 1
- Constraint 2
## Technical Approach
### Architecture Decisions
- Decision 1 and rationale
- Decision 2 and rationale
### Database Changes
- New tables/columns
- Migrations needed
- Data migration strategy (if any)
### API Changes
- New endpoints
- Modified endpoints
- Request/response formats
### Frontend Changes
- New components
- Modified components
- State management approach
## Implementation Steps
1. **Step 1: Backend - Database**
- Create migration
- Update Prisma schema
- Regenerate client
2. **Step 2: Backend - Service**
- Add service methods
- Add validation schemas
3. **Step 3: Backend - Routes**
- Create route handlers
- Add middleware
4. **Step 4: Frontend - API Client**
- Add API functions
5. **Step 5: Frontend - Components**
- Create/update components
- Add to routes if needed
6. **Step 6: Testing**
- Manual testing steps
- Edge case verification
## File Changes
### New Files
- `backend/src/services/export.service.ts`
- `frontend/src/hooks/useExport.ts`
### Modified Files
- `backend/src/routes/timeEntry.routes.ts` — Add export endpoint
- `frontend/src/pages/TimeEntriesPage.tsx` — Add export button
- `frontend/src/api/timeEntries.ts` — Add export function
### Database
- No changes required (or specify migration)
## Edge Cases
| Case | Handling |
|------|----------|
| No entries match filter | Show empty state, export empty CSV with headers |
| Very large export (>10k entries) | Stream response, show progress indicator |
| User cancels export mid-stream | Gracefully close connection |
| Invalid date range | Return 400 error with clear message |
## Testing Strategy
### Manual Testing
1. Navigate to Time Entries page
2. Apply date filter
3. Click Export button
4. Verify CSV downloads with correct data
5. Open CSV and verify format
### Edge Case Testing
1. Export with no entries
2. Export with 1000+ entries
3. Export with special characters in descriptions
4. Export while timer is running
## Open Questions
- [ ] Question 1 (to be resolved during implementation)
- [ ] Question 2
```
### Plan Review
After creating the plan:
1. Present the plan to the programmer
2. Ask for feedback and approval
3. Make requested changes
4. Get final approval before proceeding to implementation
## Phase 3: Implementation
**Goal:** Implement the feature exactly as planned.
### Rules
1. **Read the plan first** — Start by reading the full plan file
2. **Follow the plan** — Implement step by step as outlined
3. **Update if needed** — If implementation differs from plan, update the plan file
4. **Document changes** — After completion, update relevant documentation
### Implementation Checklist
- [ ] Read `docs/features/{feature-name}.md`
- [ ] Implement database changes (if any)
- [ ] Implement backend service logic
- [ ] Implement backend routes
- [ ] Implement frontend API client
- [ ] Implement frontend components
- [ ] Run linting: `npm run lint`
- [ ] Manual testing
- [ ] Update plan if implementation differs
- [ ] Update `project.md` if requirements changed
- [ ] Update `README.md` if API changed
- [ ] Update `AGENTS.md` if patterns changed
## Quick Reference
### Commands
- Frontend lint: `npm run lint` (in `frontend/`)
- Backend build: `npm run build` (in `backend/`)
- DB migration: `npm run db:migrate` (in `backend/`)
- DB generate: `npm run db:generate` (in `backend/`)
### File Locations
- Backend routes: `backend/src/routes/`
- Backend services: `backend/src/services/`
- Backend schemas: `backend/src/schemas/`
- Frontend pages: `frontend/src/pages/`
- Frontend hooks: `frontend/src/hooks/`
- Frontend API: `frontend/src/api/`
- Feature plans: `docs/features/`

View File

@@ -1,6 +1,6 @@
# AGENTS.md — Codebase Guide for AI Coding Agents # 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. This document describes the structure, conventions, and commands for the `vibe_coding_timetracker` monorepo. **Read it in full before making changes.**
## Repository Structure ## Repository Structure
@@ -18,17 +18,103 @@ This document describes the structure, conventions, and commands for the `vibe_c
├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL) ├── backend/ # Express REST API (TypeScript + Prisma + PostgreSQL)
│ └── src/ │ └── src/
│ ├── auth/ # OIDC + JWT logic │ ├── auth/ # OIDC + JWT logic
│ ├── config/ # Configuration constants
│ ├── errors/ # AppError subclasses │ ├── errors/ # AppError subclasses
│ ├── middleware/# Express middlewares │ ├── middleware/# Express middlewares
│ ├── prisma/ # Prisma client singleton │ ├── prisma/ # Prisma client singleton
│ ├── routes/ # Express routers (xxx.routes.ts) │ ├── routes/ # Express routers (xxx.routes.ts)
│ ├── schemas/ # Zod validation schemas │ ├── schemas/ # Zod validation schemas
── services/ # Business logic classes (xxx.service.ts) ── services/ # Business logic classes (xxx.service.ts)
│ ├── types/ # TypeScript interfaces
│ └── utils/ # Utility functions
├── ios/ # Native iOS app (Swift/Xcode) ├── ios/ # Native iOS app (Swift/Xcode)
├── timetracker-chart/ # Helm chart for Kubernetes deployment ├── helm/ # Helm chart for Kubernetes deployment
└── docker-compose.yml └── docker-compose.yml
``` ```
## AI Agent Workflow
### Before Making Changes
1. Read this file completely
2. Read `project.md` for feature requirements
3. Read `README.md` for setup instructions
4. Understand the specific task or feature request
### During Development
1. Follow all code conventions in this document
2. Write clean, maintainable code
3. Add inline comments only when necessary for clarity
4. Run linting before completing: `npm run lint`
### After Making Changes
**Always update documentation.** See [Documentation Maintenance](#documentation-maintenance).
## Feature Development Workflow
**For new features, AI agents MUST follow this process before writing any code.**
### Phase 1: Requirements Discovery
1. Ask clarifying questions about the feature request
2. Identify edge cases, constraints, and acceptance criteria
3. Confirm understanding with the programmer
4. Iterate until requirements are clear
### Phase 2: Feature Plan
1. Create `docs/features/{feature-name}.md` with the feature plan
2. Include: overview, requirements, technical approach, file changes, edge cases, testing
3. Present plan for review
4. Iterate until approved by the programmer
### Phase 3: Implementation
1. Use the approved plan as the single source of truth
2. Implement step by step following the plan
3. Update the plan if implementation differs
4. Update documentation after completion
**See the `feature-planning` skill for detailed workflow and templates.**
## Documentation Maintenance
**Every code change requires a documentation review.** When you modify the codebase, check whether documentation needs updating.
### Documentation Files and Their Purposes
| File | Purpose | Update When |
|------|---------|-------------|
| `AGENTS.md` | Code conventions, commands, architecture patterns | Changing conventions, adding new patterns, modifying architecture |
| `README.md` | Setup instructions, API reference, features list | Adding endpoints, changing environment variables, adding features |
| `project.md` | Requirements, data model, functional specifications | Modifying business logic, adding entities, changing validation rules |
### Update Rules
#### Update `AGENTS.md` When:
- Adding a new coding pattern or convention
- Changing the project structure (new directories, reorganization)
- Adding or modifying build/lint/test commands
- Introducing a new architectural pattern
- Changing state management or error handling approaches
#### Update `README.md` When:
- Adding, removing, or modifying API endpoints
- Changing environment variables or configuration
- Adding new features visible to users
- Modifying setup or installation steps
- Changing the technology stack
#### Update `project.md` When:
- Adding or modifying business requirements
- Changing the data model or relationships
- Adding new validation rules
- Modifying functional specifications
- Updating security or non-functional requirements
### Documentation Format Rules
- Use Markdown formatting
- Keep entries concise and actionable
- Match the existing tone and style
- Use code blocks for commands and code examples
- Maintain alphabetical or logical ordering in lists
## Build, Lint, and Dev Commands ## Build, Lint, and Dev Commands
### Frontend (`frontend/`) ### Frontend (`frontend/`)

39
DOCS.md Normal file
View File

@@ -0,0 +1,39 @@
# Documentation Guide
## Documentation Files
| File | Purpose | When to Update |
|------|---------|----------------|
| `AGENTS.md` | Code conventions, commands, architecture, AI agent workflow | Adding patterns, changing conventions, modifying structure, updating agent workflow |
| `README.md` | Setup instructions, API reference, features list | New endpoints, config changes, new features, technology stack changes |
| `project.md` | Requirements, data model, functional specifications | Business logic changes, new entities, validation rules, UI requirements |
| `DOCS.md` | Documentation standards and index | Documentation process changes, new documentation files |
| `docs/features/*.md` | Feature implementation plans | Created during feature development, updated if implementation differs |
## AI Agent Skills
| Skill | Purpose | When to Use |
|-------|---------|-------------|
| `feature-planning` | Structured workflow for new features | When implementing new features or significant functionality changes |
## Documentation Standards
- Use Markdown formatting
- Keep entries concise and actionable
- Use code blocks for commands and examples
- Match existing tone and style
- Maintain logical ordering in lists
## Maintenance Rules
### AI Agents Must Update Documentation When:
1. Adding new code patterns or conventions
2. Modifying API endpoints or configuration
3. Changing business logic or data models
4. Adding new features or entities
### Review Checklist
- [ ] Documentation reflects code changes
- [ ] Examples are accurate and tested
- [ ] Formatting is consistent
- [ ] No outdated information remains

View File

@@ -10,6 +10,10 @@ A multi-user web application for tracking time spent working on projects. Users
- **Time Tracking** - Start/stop timer with live elapsed time display - **Time Tracking** - Start/stop timer with live elapsed time display
- **Manual Entry** - Add time entries manually for past work - **Manual Entry** - Add time entries manually for past work
- **Validation** - Overlap prevention and end-time validation - **Validation** - Overlap prevention and end-time validation
- **Statistics** - View aggregated time tracking data by project and client
- **Client Targets** - Set hourly targets per client with weekly/monthly periods
- **API Keys** - Generate API keys for external tools and AI agents
- **MCP Integration** - Model Context Protocol endpoint for AI agent access
- **Responsive UI** - Works on desktop and mobile - **Responsive UI** - Works on desktop and mobile
## Architecture ## Architecture
@@ -125,6 +129,27 @@ APP_URL="http://localhost:5173"
- `POST /api/timer/start` - Start timer - `POST /api/timer/start` - Start timer
- `PUT /api/timer` - Update timer (set project) - `PUT /api/timer` - Update timer (set project)
- `POST /api/timer/stop` - Stop timer (creates entry) - `POST /api/timer/stop` - Stop timer (creates entry)
- `POST /api/timer/cancel` - Cancel timer without saving
### Client Targets
- `GET /api/client-targets` - List targets with balance
- `POST /api/client-targets` - Create target
- `PUT /api/client-targets/:id` - Update target
- `DELETE /api/client-targets/:id` - Delete target
- `POST /api/client-targets/:id/corrections` - Add correction
- `DELETE /api/client-targets/:id/corrections/:correctionId` - Delete correction
### API Keys
- `GET /api/api-keys` - List API keys
- `POST /api/api-keys` - Create API key
- `DELETE /api/api-keys/:id` - Revoke API key
### MCP (Model Context Protocol)
- `GET /mcp` - SSE stream for server-initiated messages
- `POST /mcp` - JSON-RPC requests (tool invocations)
## Data Model ## Data Model

View File

@@ -8,6 +8,7 @@
"name": "timetracker-backend", "name": "timetracker-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19", "@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -471,6 +472,367 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.27.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
"cors": "^2.8.5",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/jose": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -771,6 +1133,39 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -983,6 +1378,20 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1193,6 +1602,27 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -1239,6 +1669,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": { "node_modules/express-session": {
"version": "1.19.0", "version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
@@ -1292,6 +1740,28 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1456,6 +1926,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hono": {
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1494,6 +1973,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1503,6 +1991,18 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1522,6 +2022,18 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1808,6 +2320,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openid-client": { "node_modules/openid-client": {
"version": "5.7.1", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -1832,6 +2353,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -1852,6 +2382,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/pkg-types": { "node_modules/pkg-types": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
@@ -1993,6 +2532,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -2003,6 +2551,55 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
}, },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/router/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2092,6 +2689,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2321,6 +2939,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -2335,6 +2974,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
} }
} }
} }

View File

@@ -10,6 +10,7 @@
"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", "@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -0,0 +1,5 @@
-- AlterTable: add deleted_at column to client_targets
ALTER TABLE "client_targets" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- AlterTable: add deleted_at column to balance_corrections
ALTER TABLE "balance_corrections" ADD COLUMN "deleted_at" TIMESTAMP(3);

View File

@@ -0,0 +1,10 @@
-- CreateEnum
CREATE TYPE "PeriodType" AS ENUM ('WEEKLY', 'MONTHLY');
-- AlterTable: rename weekly_hours -> target_hours, add period_type, add working_days
ALTER TABLE "client_targets"
RENAME COLUMN "weekly_hours" TO "target_hours";
ALTER TABLE "client_targets"
ADD COLUMN "period_type" "PeriodType" NOT NULL DEFAULT 'WEEKLY',
ADD COLUMN "working_days" TEXT[] NOT NULL DEFAULT ARRAY['MON','TUE','WED','THU','FRI']::TEXT[];

View File

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

View File

@@ -20,6 +20,7 @@ model User {
timeEntries TimeEntry[] timeEntries TimeEntry[]
ongoingTimer OngoingTimer? ongoingTimer OngoingTimer?
clientTargets ClientTarget[] clientTargets ClientTarget[]
apiKeys ApiKey[]
@@map("users") @@map("users")
} }
@@ -99,12 +100,20 @@ model OngoingTimer {
@@map("ongoing_timers") @@map("ongoing_timers")
} }
enum PeriodType {
WEEKLY
MONTHLY
}
model ClientTarget { model ClientTarget {
id String @id @default(uuid()) id String @id @default(uuid())
weeklyHours Float @map("weekly_hours") targetHours Float @map("target_hours")
startDate DateTime @map("start_date") @db.Date // Always a Monday periodType PeriodType @default(WEEKLY) @map("period_type")
workingDays String[] @map("working_days") // e.g. ["MON","WED","FRI"]
startDate DateTime @map("start_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -126,6 +135,7 @@ model BalanceCorrection {
description String? @db.VarChar(255) description String? @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
clientTargetId String @map("client_target_id") clientTargetId String @map("client_target_id")
clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade) clientTarget ClientTarget @relation(fields: [clientTargetId], references: [id], onDelete: Cascade)
@@ -142,3 +152,18 @@ model Session {
@@map("sessions") @@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")
}

View File

@@ -13,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
@@ -70,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);

View File

@@ -2,6 +2,9 @@ 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 { verifyBackendJwt } from '../auth/jwt';
import { ApiKeyService } from '../services/apiKey.service';
const apiKeyService = new ApiKeyService();
export async function requireAuth( export async function requireAuth(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@@ -17,11 +20,33 @@ export async function requireAuth(
return next(); return next();
} }
// 2. Bearer JWT auth (iOS / native clients) // 2. Bearer token auth (JWT or API key)
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) { if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7); const token = authHeader.slice(7);
console.log(`${tag} -> Bearer token present (first 20 chars: ${token.slice(0, 20)}…)`); 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 { try {
req.user = verifyBackendJwt(token); req.user = verifyBackendJwt(token);
console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`); console.log(`${tag} -> JWT auth OK (user: ${req.user.id})`);

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

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

View File

@@ -73,14 +73,20 @@ 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(),
}); });
@@ -89,3 +95,7 @@ export const CreateCorrectionSchema = z.object({
hours: z.number().min(-1000).max(1000), 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),
});

View 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,
};
}
}

View File

@@ -3,44 +3,191 @@ import { NotFoundError, BadRequestError } from '../errors/AppError';
import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types'; import type { CreateClientTargetInput, UpdateClientTargetInput, CreateCorrectionInput } from '../types';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
// Returns the Monday of the week containing the given date // ---------------------------------------------------------------------------
function getMondayOfWeek(date: Date): Date { // Day-of-week helpers
const d = new Date(date); // ---------------------------------------------------------------------------
const day = d.getUTCDay(); // 0 = Sunday, 1 = Monday, ...
const DAY_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const;
/** Returns the UTC day index (0=Sun … 6=Sat) for a YYYY-MM-DD string. */
function dayIndex(dateStr: string): number {
return new Date(dateStr + 'T00:00:00Z').getUTCDay();
}
/** Checks whether a day-name string (e.g. "MON") is in the working-days array. */
function isWorkingDay(dateStr: string, workingDays: string[]): boolean {
return workingDays.includes(DAY_NAMES[dayIndex(dateStr)]);
}
/** Adds `n` calendar days to a YYYY-MM-DD string and returns a new YYYY-MM-DD. */
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().split('T')[0];
}
/** Returns the Monday of the ISO week that contains the given date string. */
function getMondayOfWeek(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
const day = d.getUTCDay(); // 0=Sun
const diff = day === 0 ? -6 : 1 - day; const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff); d.setUTCDate(d.getUTCDate() + diff);
d.setUTCHours(0, 0, 0, 0); return d.toISOString().split('T')[0];
return d;
} }
// Returns the Sunday (end of week) for a given Monday /** Returns the Sunday of the ISO week given its Monday date string. */
function getSundayOfWeek(monday: Date): Date { function getSundayOfWeek(monday: string): string {
const d = new Date(monday); return addDays(monday, 6);
d.setUTCDate(d.getUTCDate() + 6);
d.setUTCHours(23, 59, 59, 999);
return d;
} }
// Returns all Mondays from startDate up to and including the current week's Monday /** Returns the first day of the month for a given date string. */
function getWeekMondays(startDate: Date): Date[] { function getMonthStart(dateStr: string): string {
const mondays: Date[] = []; return dateStr.slice(0, 7) + '-01';
const currentMonday = getMondayOfWeek(new Date()); }
let cursor = new Date(startDate);
cursor.setUTCHours(0, 0, 0, 0); /** 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: MonSun.
* For monthly: 1stlast day of month.
*/
function getPeriodForDate(dateStr: string, periodType: 'weekly' | 'monthly'): Period {
if (periodType === 'weekly') {
const monday = getMondayOfWeek(dateStr);
return { start: monday, end: getSundayOfWeek(monday) };
} else {
return { start: getMonthStart(dateStr), end: getMonthEnd(dateStr) };
} }
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, client: { deletedAt: null } }, 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, client: { deletedAt: null } }, 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
const startDate = new Date(data.startDate + 'T00:00:00Z');
const dayOfWeek = startDate.getUTCDay();
if (dayOfWeek !== 1) {
throw new BadRequestError('startDate must be a Monday');
}
// Ensure the client belongs to this user and is not soft-deleted // Ensure the client belongs to this user and is not soft-deleted
const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } }); const client = await prisma.client.findFirst({ where: { id: data.clientId, userId, deletedAt: null } });
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,101 +378,221 @@ export class ClientTargetService {
if (!target) throw new NotFoundError('Client target not found'); if (!target) throw new NotFoundError('Client target not found');
const correction = await prisma.balanceCorrection.findFirst({ const correction = await prisma.balanceCorrection.findFirst({
where: { id: correctionId, clientTargetId: targetId }, where: { id: correctionId, clientTargetId: targetId, deletedAt: null },
}); });
if (!correction) throw new NotFoundError('Correction not found'); if (!correction) throw new NotFoundError('Correction not found');
await prisma.balanceCorrection.delete({ where: { id: correctionId } }); await prisma.balanceCorrection.update({
where: { id: correctionId },
data: { deletedAt: new Date() },
});
} }
private async computeBalance(target: { // ---------------------------------------------------------------------------
id: string; // Balance computation
clientId: string; // ---------------------------------------------------------------------------
userId: string;
weeklyHours: number;
startDate: Date;
createdAt: Date;
updatedAt: Date;
client: { id: string; name: string };
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
}): Promise<ClientTargetWithBalance> {
const mondays = getWeekMondays(target.startDate);
if (mondays.length === 0) { private async computeBalance(target: TargetRecord): Promise<ClientTargetWithBalance> {
return this.emptyBalance(target); const startDateStr = target.startDate.toISOString().split('T')[0];
const periodType = target.periodType.toLowerCase() as 'weekly' | 'monthly';
const workingDays = target.workingDays;
const periods = enumeratePeriods(startDateStr, periodType);
if (periods.length === 0) {
return this.emptyBalance(target, periodType);
} }
// Fetch all tracked time for this user on this client's projects in one query const overallStart = periods[0].start;
// covering startDate to end of current week const overallEnd = periods[periods.length - 1].end;
const periodStart = mondays[0]; const today = new Date().toISOString().split('T')[0];
const periodEnd = getSundayOfWeek(mondays[mondays.length - 1]);
type TrackedRow = { week_start: Date; tracked_seconds: bigint }; // Fetch active timer for this user (if any) and check if it belongs to this client
const ongoingTimer = await prisma.ongoingTimer.findUnique({
where: { userId: target.userId },
include: { project: { select: { clientId: true } } },
});
const rows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql` // Elapsed seconds from the active timer attributed to this client target.
// We only count it if the timer has a project assigned and that project
// belongs to the same client as this target.
let ongoingTimerSeconds = 0;
let ongoingTimerPeriodStart: string | null = null;
if (
ongoingTimer &&
ongoingTimer.projectId !== null &&
ongoingTimer.project?.clientId === target.clientId
) {
ongoingTimerSeconds = Math.floor(
(Date.now() - ongoingTimer.startTime.getTime()) / 1000,
);
// Determine which period the timer's start time falls into
const timerDateStr = ongoingTimer.startTime.toISOString().split('T')[0];
const timerPeriod = getPeriodForDate(timerDateStr, periodType);
ongoingTimerPeriodStart = timerPeriod.start;
}
// Fetch all time tracked for this client across the full range in one query
type TrackedRow = { period_start: string; tracked_seconds: bigint };
let trackedRows: TrackedRow[];
if (periodType === 'weekly') {
trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
SELECT SELECT
DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') AS week_start, TO_CHAR(
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS tracked_seconds DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC'),
'YYYY-MM-DD'
) AS period_start,
COALESCE(
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
0
)::bigint AS tracked_seconds
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId} WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId} AND p.client_id = ${target.clientId}
AND te.start_time >= ${periodStart} AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${periodEnd} AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
AND te.deleted_at IS NULL AND te.deleted_at IS NULL
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC') GROUP BY DATE_TRUNC('week', te.start_time AT TIME ZONE 'UTC')
`); `);
} else {
// Index tracked seconds by week start (ISO Monday string) trackedRows = await prisma.$queryRaw<TrackedRow[]>(Prisma.sql`
const trackedByWeek = new Map<string, number>(); SELECT
for (const row of rows) { TO_CHAR(
// DATE_TRUNC with 'week' gives Monday in Postgres (ISO week) DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC'),
const monday = getMondayOfWeek(new Date(row.week_start)); 'YYYY-MM-DD'
const key = monday.toISOString().split('T')[0]; ) AS period_start,
trackedByWeek.set(key, Number(row.tracked_seconds)); COALESCE(
SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)),
0
)::bigint AS tracked_seconds
FROM time_entries te
JOIN projects p ON p.id = te.project_id
WHERE te.user_id = ${target.userId}
AND p.client_id = ${target.clientId}
AND te.start_time >= ${new Date(overallStart + 'T00:00:00Z')}
AND te.start_time <= ${new Date(overallEnd + 'T23:59:59Z')}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
GROUP BY DATE_TRUNC('month', te.start_time AT TIME ZONE 'UTC')
`);
} }
// Index corrections by week // Map tracked seconds by period start date string
const correctionsByWeek = new Map<string, number>(); const trackedByPeriod = new Map<string, number>();
for (const row of trackedRows) {
// Normalise: for weekly, Postgres DATE_TRUNC('week') already gives Monday
const key = typeof row.period_start === 'string'
? row.period_start
: (row.period_start as Date).toISOString().split('T')[0];
trackedByPeriod.set(key, Number(row.tracked_seconds));
}
// Index corrections by period start date
const correctionsByPeriod = new Map<string, number>();
for (const c of target.corrections) { for (const c of target.corrections) {
const monday = getMondayOfWeek(new Date(c.date)); const corrDateStr = c.date.toISOString().split('T')[0];
const key = monday.toISOString().split('T')[0]; const period = getPeriodForDate(corrDateStr, periodType);
correctionsByWeek.set(key, (correctionsByWeek.get(key) ?? 0) + c.hours); const key = period.start;
correctionsByPeriod.set(key, (correctionsByPeriod.get(key) ?? 0) + c.hours);
} }
const targetSecondsPerWeek = target.weeklyHours * 3600; const periodBalances: PeriodBalance[] = [];
const weeks: WeekBalance[] = [];
let totalBalanceSeconds = 0; let totalBalanceSeconds = 0;
const isFirstPeriod = (i: number) => i === 0;
for (let i = 0; i < periods.length; i++) {
const period = periods[i];
// Effective start for this period (clamped to startDate for first period)
const effectiveStart = isFirstPeriod(i) && cmpDate(startDateStr, period.start) > 0
? startDateStr
: period.start;
// Period target hours (with possible pro-ration on the first period)
const periodTargetHours = isFirstPeriod(i)
? computePeriodTargetHours(period, startDateStr, target.targetHours, periodType)
: target.targetHours;
// Add ongoing timer seconds to the period it started in (if it belongs to this client)
const timerContribution =
ongoingTimerPeriodStart !== null && period.start === ongoingTimerPeriodStart
? ongoingTimerSeconds
: 0;
const trackedSeconds = (trackedByPeriod.get(period.start) ?? 0) + timerContribution;
const correctionHours = correctionsByPeriod.get(period.start) ?? 0;
const isOngoing = cmpDate(period.start, today) <= 0 && cmpDate(today, period.end) <= 0;
let balanceSeconds: number;
let extra: Partial<PeriodBalance> = {};
if (isOngoing) {
// §6: ongoing period — expected hours based on elapsed working days
const workingDaysInPeriod = countWorkingDays(effectiveStart, period.end, workingDays);
const dailyRateHours = workingDaysInPeriod > 0 ? periodTargetHours / workingDaysInPeriod : 0;
const elapsedEnd = today < period.end ? today : period.end;
const elapsedWorkingDays = countWorkingDays(effectiveStart, elapsedEnd, workingDays);
const expectedHours = elapsedWorkingDays * dailyRateHours;
// Only count corrections up to and including today — future corrections
// within the ongoing period must not be counted until those days have elapsed,
// otherwise a +8h correction for tomorrow inflates the balance immediately.
const correctionHoursToDate = target.corrections.reduce((sum, c) => {
const d = c.date.toISOString().split('T')[0];
if (cmpDate(d, effectiveStart) >= 0 && cmpDate(d, today) <= 0) {
return sum + c.hours;
}
return sum;
}, 0);
balanceSeconds = Math.round(
(trackedSeconds + correctionHoursToDate * 3600) - expectedHours * 3600,
);
extra = {
dailyRateHours,
workingDaysInPeriod,
elapsedWorkingDays,
expectedHours,
};
} else {
// §4: completed period — simple formula
balanceSeconds = Math.round(
(trackedSeconds + correctionHours * 3600) - periodTargetHours * 3600,
);
}
for (const monday of mondays) {
const key = monday.toISOString().split('T')[0];
const sunday = getSundayOfWeek(monday);
const trackedSeconds = trackedByWeek.get(key) ?? 0;
const correctionHours = correctionsByWeek.get(key) ?? 0;
const effectiveTargetSeconds = targetSecondsPerWeek - correctionHours * 3600;
const balanceSeconds = trackedSeconds - effectiveTargetSeconds;
totalBalanceSeconds += balanceSeconds; totalBalanceSeconds += balanceSeconds;
weeks.push({ periodBalances.push({
weekStart: key, periodStart: period.start,
weekEnd: sunday.toISOString().split('T')[0], periodEnd: period.end,
targetHours: periodTargetHours,
trackedSeconds, trackedSeconds,
targetSeconds: effectiveTargetSeconds,
correctionHours, correctionHours,
balanceSeconds, balanceSeconds,
isOngoing,
...extra,
}); });
} }
const currentWeek = weeks[weeks.length - 1]; const currentPeriod = periodBalances.find(p => p.isOngoing) ?? periodBalances[periodBalances.length - 1];
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
startDate: target.startDate.toISOString().split('T')[0], targetHours: target.targetHours,
workingDays,
startDate: startDateStr,
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: target.corrections.map(c => ({ corrections: target.corrections.map(c => ({
@@ -293,37 +603,33 @@ export class ClientTargetService {
createdAt: c.createdAt.toISOString(), createdAt: c.createdAt.toISOString(),
})), })),
totalBalanceSeconds, totalBalanceSeconds,
currentWeekTrackedSeconds: currentWeek?.trackedSeconds ?? 0, currentPeriodTrackedSeconds: currentPeriod?.trackedSeconds ?? 0,
currentWeekTargetSeconds: currentWeek?.targetSeconds ?? targetSecondsPerWeek, currentPeriodTargetSeconds: currentPeriod
weeks, ? Math.round(currentPeriod.targetHours * 3600)
: Math.round(target.targetHours * 3600),
periods: periodBalances,
hasOngoingTimer: ongoingTimerSeconds > 0,
}; };
} }
private emptyBalance(target: { private emptyBalance(target: TargetRecord, periodType: 'weekly' | 'monthly'): ClientTargetWithBalance {
id: string;
clientId: string;
userId: string;
weeklyHours: number;
startDate: Date;
createdAt: Date;
updatedAt: Date;
client: { id: string; name: string };
corrections: Array<{ id: string; date: Date; hours: number; description: string | null; createdAt: Date }>;
}): ClientTargetWithBalance {
return { return {
id: target.id, id: target.id,
clientId: target.clientId, clientId: target.clientId,
clientName: target.client.name, clientName: target.client.name,
userId: target.userId, userId: target.userId,
weeklyHours: target.weeklyHours, periodType,
targetHours: target.targetHours,
workingDays: target.workingDays,
startDate: target.startDate.toISOString().split('T')[0], startDate: target.startDate.toISOString().split('T')[0],
createdAt: target.createdAt.toISOString(), createdAt: target.createdAt.toISOString(),
updatedAt: target.updatedAt.toISOString(), updatedAt: target.updatedAt.toISOString(),
corrections: [], corrections: [],
totalBalanceSeconds: 0, totalBalanceSeconds: 0,
currentWeekTrackedSeconds: 0, currentPeriodTrackedSeconds: 0,
currentWeekTargetSeconds: target.weeklyHours * 3600, currentPeriodTargetSeconds: Math.round(target.targetHours * 3600),
weeks: [], periods: [],
hasOngoingTimer: false,
}; };
} }
} }

View File

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

View File

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

0
docs/features/.gitkeep Normal file
View File

View File

@@ -0,0 +1,131 @@
# Feature: Timer Breaks (Pause During Work)
## Overview
Allow users to take breaks while a timer is running. When on break, elapsed time is frozen and break time is tracked. When resumed, break time accumulates and is subtracted from the displayed work time.
## User Experience
### Timer States
1. **Running** — normal state, elapsed time ticking
2. **On Break** — elapsed time frozen, break time ticking, Stop/Cancel buttons disabled
3. **Stopped** — no timer active
### UI Changes (TimerWidget)
- Add a **"Break"** button (amber, `Pause` icon) next to Stop when timer is running
- When on break:
- Change pulsing dot color from red to amber
- Elapsed time frozen at net work time
- Show break time below: `Break: Xm XXs` (live-ticking)
- Replace "Break" button with **"Resume"** button (green, `Play` icon)
- **Disable** Stop and Cancel buttons (tooltip: "Resume before stopping")
### Duration Calculations
- **Work time (displayed):** `now - startTime - totalBreakSeconds`
- Where `totalBreakSeconds = (breakMinutes * 60) + (now - breakStart if on break)`
- When on break: frozen at `(breakStart - startTime - breakMinutes * 60)`
- **Break time (displayed):** `breakMinutes * 60 + (now - breakStart if on break)`
## Implementation
### 1. Database Schema (`backend/prisma/schema.prisma`)
Add two fields to `OngoingTimer`:
```prisma
model OngoingTimer {
// ... existing fields ...
breakMinutes Int @default(0) @map("break_minutes")
breakStart DateTime? @map("break_start") @db.Timestamptz()
}
```
Run: `npx prisma migrate dev --name add_timer_break_fields`
### 2. Backend Service (`backend/src/services/timer.service.ts`)
**New method `startBreak(userId)`:**
- Get ongoing timer, throw `NotFoundError` if none
- Check `timer.breakStart` is null (not already on break), throw `BadRequestError` if on break
- Update: `breakStart = new Date()`
- Return updated timer
**New method `endBreak(userId)`:**
- Get ongoing timer, throw `NotFoundError` if none
- Check `timer.breakStart` is not null, throw `BadRequestError` if not on break
- Calculate additional break minutes: `Math.floor((now - breakStart) / 60000)`
- Update: `breakMinutes += additionalMinutes`, `breakStart = null`
- Return updated timer
**Modify `stop(userId)`:**
- Before creating time entry, check `timer.breakStart` is null — throw `BadRequestError("Cannot stop timer while on break")` if break is active
- When creating `TimeEntry`, set `breakMinutes: timer.breakMinutes`
**Modify `cancel(userId)`:**
- Check `timer.breakStart` is null — throw `BadRequestError("Cannot cancel timer while on break")` if break is active
### 3. Backend Routes (`backend/src/routes/timer.routes.ts`)
Add two new routes (both require auth, no body validation):
```
POST /api/timer/break → timerService.startBreak(userId)
POST /api/timer/resume → timerService.endBreak(userId)
```
### 4. MCP Tools (`backend/src/routes/mcp.routes.ts`)
Add two MCP tools: `pause_timer` and `resume_timer`.
### 5. Frontend Types (`frontend/src/types/index.ts`)
Update `OngoingTimer` interface:
```typescript
export interface OngoingTimer {
// ... existing fields ...
breakMinutes: number;
breakStart: string | null;
}
```
### 6. Frontend API (`frontend/src/api/timer.ts`)
Add two methods:
```typescript
startBreak: async (): Promise<OngoingTimer> => { ... }
endBreak: async (): Promise<OngoingTimer> => { ... }
```
### 7. Frontend TimerContext (`frontend/src/contexts/TimerContext.tsx`)
- Add `breakSeconds` state (live-updating, similar to `elapsedSeconds`)
- Expose `isOnBreak` derived boolean (`ongoingTimer?.breakStart !== null`)
- Update elapsed time calculation:
- Running: `(now - startTime) - (breakMinutes * 60) - (now - breakStart if on break)`
- On break: `(breakStart - startTime) - (breakMinutes * 60)` (frozen)
- Break seconds: `(breakMinutes * 60) + (now - breakStart if on break)`
- Add `startBreak()` and `endBreak()` callbacks
- Expose `breakSeconds` and `isOnBreak` in context value
### 8. Frontend TimerWidget (`frontend/src/components/TimerWidget.tsx`)
- Import `Pause` icon from lucide-react
- Add Break/Resume button between project selector and Stop button
- Show break time display when `breakSeconds > 0` or `isOnBreak`
- Change dot color to amber when on break
- Disable Stop/Cancel when on break with tooltip
## Files to Modify (in order)
| # | File | Change |
|---|------|--------|
| 1 | `backend/prisma/schema.prisma` | Add `breakMinutes`, `breakStart` to `OngoingTimer` |
| 2 | `backend/src/services/timer.service.ts` | Add `startBreak()`, `endBreak()`, modify `stop()` and `cancel()` |
| 3 | `backend/src/routes/timer.routes.ts` | Add `/break` and `/resume` routes |
| 4 | `backend/src/routes/mcp.routes.ts` | Add `pause_timer` and `resume_timer` MCP tools |
| 5 | `frontend/src/types/index.ts` | Add `breakMinutes`, `breakStart` to `OngoingTimer` |
| 6 | `frontend/src/api/timer.ts` | Add `startBreak()`, `endBreak()` API methods |
| 7 | `frontend/src/contexts/TimerContext.tsx` | Add break state, `breakSeconds`, `isOnBreak`, break methods |
| 8 | `frontend/src/components/TimerWidget.tsx` | Add break UI (button, display, disabled states) |
## Edge Cases
- Break start must be after timer start (always true since break is clicked after start)
- Break duration naturally cannot exceed work duration (breakStart > startTime)
- On stop: reject if break is active (user must resume first)
- On cancel: reject if break is active (user must resume first)
- Break minutes accumulate across multiple break/resume cycles
- Timer refetch (every 60s) will sync break state from server
## Verification
- Run `npm run lint` in both `frontend/` and `backend/`
- Run `npm run build` in both `frontend/` and `backend/`
- Manual testing: start timer → break → verify elapsed frozen, break ticking → resume → verify break added to total → stop → verify time entry has correct breakMinutes

View File

@@ -1,40 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="48 48 416 416" width="100%" height="100%"> <?xml version="1.0" encoding="UTF-8"?>
<defs> <!-- Generated by Pixelmator Pro 4.0 -->
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <svg width="416" height="416" viewBox="0 0 416 416" xmlns="http://www.w3.org/2000/svg">
<stop offset="0%" stop-color="#818CF8" /> <linearGradient id="linearGradient1" x1="0" y1="0" x2="416" y2="416" gradientUnits="userSpaceOnUse">
<stop offset="100%" stop-color="#4F46E5" /> <stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient> </linearGradient>
</defs> <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">
<!-- App Icon Background --> <path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 208 48 L 208 92"/>
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" /> <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"/>
<!-- Inner Icon Group --> <path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 314 90 L 342 118"/>
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round"> <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"/>
<!-- Stopwatch Top Button --> <path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 136 L 208 224"/>
<path d="M256 96 v44" stroke-width="28" /> <path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 208 224 L 256 256"/>
<path d="M224 88 h64" stroke-width="24" /> <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"/>
<!-- Stopwatch Side Button --> <path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 124 224 L 140 224"/>
<path d="M352 176 l 24 -24" stroke-width="24" /> <path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 292 224 L 276 224"/>
<!-- Cap for side button -->
<path d="M362 138 l 28 28" stroke-width="24" />
<!-- Outer Ring -->
<circle cx="256" cy="272" r="132" stroke-width="28" />
<!-- Clock Hands -->
<!-- Minute Hand -->
<path d="M256 184 v 88" stroke-width="24" />
<!-- Hour Hand -->
<path d="M256 272 l 48 32" stroke-width="24" />
<!-- Dial Tick Marks -->
<g stroke-width="12" opacity="0.6">
<line x1="256" y1="172" x2="256" y2="188" />
<line x1="256" y1="356" x2="256" y2="372" />
<line x1="172" y1="272" x2="188" y2="272" />
<line x1="340" y1="272" x2="324" y2="272" />
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,43 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%"> <?xml version="1.0" encoding="UTF-8"?>
<defs> <!-- Generated by Pixelmator Pro 4.0 -->
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%"> <svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<stop offset="0%" stop-color="#818CF8" /> <linearGradient id="linearGradient1" x1="48" y1="48" x2="464" y2="464" gradientUnits="userSpaceOnUse">
<stop offset="100%" stop-color="#4F46E5" /> <stop offset="1e-05" stop-color="#818cf8" stop-opacity="1"/>
<stop offset="1" stop-color="#4f46e5" stop-opacity="1"/>
</linearGradient> </linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%"> <filter id="filter1" x="0" y="0" width="512" height="512" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#4F46E5" flood-opacity="0.4" /> <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> </filter>
</defs> <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">
<!-- App Icon Background --> <path id="path1" fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round" stroke-linejoin="round" d="M 256 96 L 256 140"/>
<rect x="48" y="48" width="416" height="416" rx="96" fill="url(#bg)" filter="url(#shadow)" /> <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"/>
<!-- Inner Icon Group --> <path id="path4" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 362 138 L 390 166"/>
<g stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round"> <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"/>
<!-- Stopwatch Top Button --> <path id="path6" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 184 L 256 272"/>
<path d="M256 96 v44" stroke-width="28" /> <path id="path7" fill="none" stroke="#ffffff" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" d="M 256 272 L 304 304"/>
<path d="M224 88 h64" stroke-width="24" /> <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"/>
<!-- Stopwatch Side Button --> <path id="path9" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 172 272 L 188 272"/>
<path d="M352 176 l 24 -24" stroke-width="24" /> <path id="path10" fill="none" stroke="#ffffff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" d="M 340 272 L 324 272"/>
<!-- Cap for side button -->
<path d="M362 138 l 28 28" stroke-width="24" />
<!-- Outer Ring -->
<circle cx="256" cy="272" r="132" stroke-width="28" />
<!-- Clock Hands -->
<!-- Minute Hand -->
<path d="M256 184 v 88" stroke-width="24" />
<!-- Hour Hand -->
<path d="M256 272 l 48 32" stroke-width="24" />
<!-- Dial Tick Marks -->
<g stroke-width="12" opacity="0.6">
<line x1="256" y1="172" x2="256" y2="188" />
<line x1="256" y1="356" x2="256" y2="372" />
<line x1="172" y1="272" x2="188" y2="272" />
<line x1="340" y1="272" x2="324" y2="272" />
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

View 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}`);
},
};

View File

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

View File

@@ -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
<img src="/icon.svg" alt="TimeTracker Logo" className="h-8 w-8 drop-shadow-sm" /> className="flex-shrink-0 flex items-center"
to={"/dashboard"}
>
<img
src="/icon.svg"
alt="TimeTracker Logo"
className="h-8 w-8 drop-shadow-sm"
/>
<span className="ml-2 text-xl font-bold text-gray-900"> <span className="ml-2 text-xl font-bold text-gray-900">
TimeTracker TimeTracker
</span> </span>
</div> </NavLink>
<div className="hidden sm:ml-8 sm:flex sm:space-x-4"> <div className="hidden sm:ml-8 sm:flex sm:space-x-4 items-center">
{/* Main Navigation Items */} {/* Main Navigation Items */}
{mainNavItems.map((item) => ( {mainNavItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors ${ `inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors h-min ${
isActive isActive
? "text-primary-600 bg-primary-50" ? "text-primary-600 bg-primary-50"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50" : "text-gray-600 hover:text-gray-900 hover:bg-gray-50"

View File

@@ -3,27 +3,37 @@ interface StatCardProps {
label: string; label: string;
value: string; value: string;
color: 'blue' | 'green' | 'purple' | 'orange'; color: 'blue' | 'green' | 'purple' | 'orange';
/** When true, renders a pulsing green dot to signal a live/active state. */
indicator?: boolean;
} }
const colorClasses: Record<StatCardProps['color'], string> = { const colorClasses: Record<NonNullable<StatCardProps['color']>, string> = {
blue: 'bg-blue-50 text-blue-600', blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600', green: 'bg-green-50 text-green-600',
purple: 'bg-purple-50 text-purple-600', purple: 'bg-purple-50 text-purple-600',
orange: 'bg-orange-50 text-orange-600', orange: 'bg-orange-50 text-orange-600',
}; };
export function StatCard({ icon: Icon, label, value, color }: StatCardProps) { export function StatCard({ icon: Icon, label, value, color, indicator }: StatCardProps) {
return ( return (
<div className="card p-4"> <div className="card p-4">
<div className="flex items-center"> <div className="flex items-center">
<div className={`p-3 rounded-lg ${colorClasses[color]}`}> <div className={`p-3 rounded-lg ${colorClasses[color]}`}>
<Icon className="h-6 w-6" /> <Icon className="h-6 w-6" />
</div> </div>
<div className="ml-4"> <div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{label}</p> <p className="text-sm font-medium text-gray-600">{label}</p>
<div className="flex items-center gap-2">
{indicator && (
<span
className="inline-block h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse"
title="Timer running"
/>
)}
<p className="text-2xl font-bold text-gray-900">{value}</p> <p className="text-2xl font-bold text-gray-900">{value}</p>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -147,7 +147,7 @@ export function TimerWidget() {
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>
@@ -156,7 +156,7 @@ 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 ? (
<> <>

View 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,
};
}

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clientTargetsApi } from '@/api/clientTargets'; import { clientTargetsApi } from '@/api/clientTargets';
import { useTimer } from '@/contexts/TimerContext';
import type { import type {
CreateClientTargetInput, CreateClientTargetInput,
UpdateClientTargetInput, UpdateClientTargetInput,
@@ -8,10 +9,13 @@ import type {
export function useClientTargets() { export function useClientTargets() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { ongoingTimer } = useTimer();
const { data: targets, isLoading, error } = useQuery({ const { data: targets, isLoading, error } = useQuery({
queryKey: ['clientTargets'], queryKey: ['clientTargets'],
queryFn: clientTargetsApi.getAll, queryFn: clientTargetsApi.getAll,
// Poll every 30 s while a timer is running so the balance stays current
refetchInterval: ongoingTimer ? 30_000 : false,
}); });
const createTarget = useMutation({ const createTarget = useMutation({

View File

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

View File

@@ -14,33 +14,10 @@ import type {
CreateCorrectionInput, CreateCorrectionInput,
} from '@/types'; } from '@/types';
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16" const ALL_DAYS = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'] as const;
function weekInputToMonday(weekValue: string): string { const DAY_LABELS: Record<string, string> = {
const [yearStr, weekStr] = weekValue.split('-W'); MON: 'Mon', TUE: 'Tue', WED: 'Wed', THU: 'Thu', FRI: 'Fri', SAT: 'Sat', SUN: 'Sun',
const year = parseInt(yearStr, 10); };
const week = parseInt(weekStr, 10);
// ISO week 1 is the week containing the first Thursday of January
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Day = jan4.getUTCDay() || 7; // Mon=1..Sun=7
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + (week - 1) * 7);
return monday.toISOString().split('T')[0];
}
// Convert a YYYY-MM-DD Monday to "YYYY-Www" for the week input
function mondayToWeekInput(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00Z');
// ISO week number calculation
const jan4 = new Date(Date.UTC(date.getUTCFullYear(), 0, 4));
const jan4Day = jan4.getUTCDay() || 7;
const firstMonday = new Date(jan4);
firstMonday.setUTCDate(jan4.getUTCDate() - jan4Day + 1);
const diff = date.getTime() - firstMonday.getTime();
const week = Math.floor(diff / (7 * 24 * 3600 * 1000)) + 1;
// Handle year boundary: if week > 52 we might be in week 1 of next year
const year = date.getUTCFullYear();
return `${year}-W${week.toString().padStart(2, '0')}`;
}
function balanceLabel(seconds: number): { text: string; color: string } { function balanceLabel(seconds: number): { text: string; color: string } {
if (seconds === 0) return { text: '±0', color: 'text-gray-500' }; if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
@@ -58,7 +35,12 @@ function ClientTargetPanel({
}: { }: {
client: Client; client: Client;
target: ClientTargetWithBalance | undefined; target: ClientTargetWithBalance | undefined;
onCreated: (weeklyHours: number, startDate: string) => Promise<void>; onCreated: (input: {
targetHours: number;
periodType: 'weekly' | 'monthly';
workingDays: string[];
startDate: string;
}) => Promise<void>;
onDeleted: () => Promise<void>; onDeleted: () => Promise<void>;
}) { }) {
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets(); const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
@@ -69,7 +51,9 @@ function ClientTargetPanel({
// Create/edit form state // Create/edit form state
const [formHours, setFormHours] = useState(''); const [formHours, setFormHours] = useState('');
const [formWeek, setFormWeek] = useState(''); const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly');
const [formWorkingDays, setFormWorkingDays] = useState<string[]>(['MON', 'TUE', 'WED', 'THU', 'FRI']);
const [formStartDate, setFormStartDate] = useState('');
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [formSaving, setFormSaving] = useState(false); const [formSaving, setFormSaving] = useState(false);
@@ -81,13 +65,13 @@ function ClientTargetPanel({
const [corrError, setCorrError] = useState<string | null>(null); const [corrError, setCorrError] = useState<string | null>(null);
const [corrSaving, setCorrSaving] = useState(false); const [corrSaving, setCorrSaving] = useState(false);
const todayIso = new Date().toISOString().split('T')[0];
const openCreate = () => { const openCreate = () => {
setFormHours(''); setFormHours('');
const today = new Date(); setFormPeriodType('weekly');
const day = today.getUTCDay() || 7; setFormWorkingDays(['MON', 'TUE', 'WED', 'THU', 'FRI']);
const monday = new Date(today); setFormStartDate(todayIso);
monday.setUTCDate(today.getUTCDate() - day + 1);
setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0]));
setFormError(null); setFormError(null);
setEditing(false); setEditing(false);
setShowForm(true); setShowForm(true);
@@ -95,32 +79,56 @@ function ClientTargetPanel({
const openEdit = () => { const openEdit = () => {
if (!target) return; if (!target) return;
setFormHours(String(target.weeklyHours)); setFormHours(String(target.targetHours));
setFormWeek(mondayToWeekInput(target.startDate)); setFormPeriodType(target.periodType);
setFormWorkingDays([...target.workingDays]);
setFormStartDate(target.startDate);
setFormError(null); setFormError(null);
setEditing(true); setEditing(true);
setShowForm(true); setShowForm(true);
}; };
const toggleDay = (day: string) => {
setFormWorkingDays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day],
);
};
const handleFormSubmit = async (e: React.FormEvent) => { const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
const hours = parseFloat(formHours); const hours = parseFloat(formHours);
if (isNaN(hours) || hours <= 0 || hours > 168) { if (isNaN(hours) || hours <= 0 || hours > 168) {
setFormError('Weekly hours must be between 0 and 168'); setFormError(`${formPeriodType === 'weekly' ? 'Weekly' : 'Monthly'} hours must be between 0 and 168`);
return; return;
} }
if (!formWeek) { if (formWorkingDays.length === 0) {
setFormError('Please select a start week'); setFormError('Select at least one working day');
return;
}
if (!formStartDate) {
setFormError('Please select a start date');
return; return;
} }
const startDate = weekInputToMonday(formWeek);
setFormSaving(true); setFormSaving(true);
try { try {
if (editing && target) { if (editing && target) {
await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } }); await updateTarget.mutateAsync({
id: target.id,
input: {
targetHours: hours,
periodType: formPeriodType,
workingDays: formWorkingDays,
startDate: formStartDate,
},
});
} else { } else {
await onCreated(hours, startDate); await onCreated({
targetHours: hours,
periodType: formPeriodType,
workingDays: formWorkingDays,
startDate: formStartDate,
});
} }
setShowForm(false); setShowForm(false);
} catch (err) { } catch (err) {
@@ -185,23 +193,46 @@ function ClientTargetPanel({
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium" className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
> >
<Target className="h-3.5 w-3.5" /> <Target className="h-3.5 w-3.5" />
Set weekly target Set target
</button> </button>
</div> </div>
); );
} }
if (showForm) { if (showForm) {
const hoursLabel = formPeriodType === 'weekly' ? 'Hours/week' : 'Hours/month';
return ( return (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2"> <p className="text-xs font-medium text-gray-700 mb-2">
{editing ? 'Edit target' : 'Set weekly target'} {editing ? 'Edit target' : 'Set target'}
</p> </p>
<form onSubmit={handleFormSubmit} className="space-y-2"> <form onSubmit={handleFormSubmit} className="space-y-2">
{formError && <p className="text-xs text-red-600">{formError}</p>} {formError && <p className="text-xs text-red-600">{formError}</p>}
{/* Period type */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">Period</label>
<div className="flex gap-2">
{(['weekly', 'monthly'] as const).map(pt => (
<label key={pt} className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="radio"
name="periodType"
value={pt}
checked={formPeriodType === pt}
onChange={() => setFormPeriodType(pt)}
className="accent-primary-600"
/>
{pt.charAt(0).toUpperCase() + pt.slice(1)}
</label>
))}
</div>
</div>
{/* Hours + Start Date */}
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label> <label className="block text-xs text-gray-500 mb-0.5">{hoursLabel}</label>
<input <input
type="number" type="number"
value={formHours} value={formHours}
@@ -215,16 +246,41 @@ function ClientTargetPanel({
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label> <label className="block text-xs text-gray-500 mb-0.5">Start date</label>
<input <input
type="week" type="date"
value={formWeek} value={formStartDate}
onChange={e => setFormWeek(e.target.value)} onChange={e => setFormStartDate(e.target.value)}
className="input text-sm py-1" className="input text-sm py-1"
required required
/> />
</div> </div>
</div> </div>
{/* Working days */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">Working days</label>
<div className="flex gap-1 flex-wrap">
{ALL_DAYS.map(day => {
const active = formWorkingDays.includes(day);
return (
<button
key={day}
type="button"
onClick={() => toggleDay(day)}
className={`text-xs px-2 py-0.5 rounded border font-medium transition-colors ${
active
? 'bg-primary-600 border-primary-600 text-white'
: 'bg-white border-gray-300 text-gray-600 hover:border-primary-400'
}`}
>
{DAY_LABELS[day]}
</button>
);
})}
</div>
</div>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
type="button" type="button"
@@ -248,6 +304,7 @@ function ClientTargetPanel({
// Target exists — show summary + expandable details // Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds); const balance = balanceLabel(target!.totalBalanceSeconds);
const periodLabel = target!.periodType === 'weekly' ? 'week' : 'month';
return ( return (
<div className="mt-3 pt-3 border-t border-gray-100"> <div className="mt-3 pt-3 border-t border-gray-100">
@@ -256,9 +313,15 @@ function ClientTargetPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" /> <Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-600"> <span className="text-xs text-gray-600">
<span className="font-medium">{target!.weeklyHours}h</span>/week <span className="font-medium">{target!.targetHours}h</span>/{periodLabel}
</span> </span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span> <span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
{target!.hasOngoingTimer && (
<span
className="inline-block h-2 w-2 rounded-full bg-green-500 animate-pulse"
title="Timer running — balance updates every 30 s"
/>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@@ -531,8 +594,14 @@ export function ClientsPage() {
<ClientTargetPanel <ClientTargetPanel
client={client} client={client}
target={target} target={target}
onCreated={async (weeklyHours, startDate) => { onCreated={async ({ targetHours, periodType, workingDays, startDate }) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate }); await createTarget.mutateAsync({
clientId: client.id,
targetHours,
periodType,
workingDays,
startDate,
});
}} }}
onDeleted={async () => { onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id); if (target) await deleteTarget.mutateAsync(target.id);

View File

@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react"; import { Clock, Calendar, Briefcase, TrendingUp, Target, Edit2, Trash2 } from "lucide-react";
import { useTimeEntries } from "@/hooks/useTimeEntries"; import { useTimeEntries } from "@/hooks/useTimeEntries";
import { useClientTargets } from "@/hooks/useClientTargets"; import { useClientTargets } from "@/hooks/useClientTargets";
import { useTimer } from "@/contexts/TimerContext";
import { ProjectColorDot } from "@/components/ProjectColorDot"; import { ProjectColorDot } from "@/components/ProjectColorDot";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { TimeEntryFormModal } from "@/components/TimeEntryFormModal"; import { TimeEntryFormModal } from "@/components/TimeEntryFormModal";
@@ -30,6 +31,7 @@ export function DashboardPage() {
}); });
const { targets } = useClientTargets(); const { targets } = useClientTargets();
const { ongoingTimer, elapsedSeconds } = useTimer();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null); const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
@@ -54,12 +56,19 @@ export function DashboardPage() {
} }
}; };
const totalTodaySeconds = const completedTodaySeconds =
todayEntries?.entries.reduce((total, entry) => { todayEntries?.entries.reduce((total, entry) => {
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes); return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0; }, 0) ?? 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? []; // Only add the running timer if it started today (not a timer left running from yesterday)
const timerStartedToday =
ongoingTimer !== null &&
new Date(ongoingTimer.startTime) >= startOfDay(today);
const totalTodaySeconds = completedTodaySeconds + (timerStartedToday ? elapsedSeconds : 0);
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,6 +87,7 @@ export function DashboardPage() {
label="Today" label="Today"
value={formatDurationHoursMinutes(totalTodaySeconds)} value={formatDurationHoursMinutes(totalTodaySeconds)}
color="blue" color="blue"
indicator={timerStartedToday}
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
@@ -108,7 +118,7 @@ export function DashboardPage() {
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" /> <Target className="h-5 w-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2> <h2 className="text-lg font-semibold text-gray-900">Targets</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{targetsWithData.map(target => { {targetsWithData.map(target => {
@@ -116,8 +126,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance); const absBalance = Math.abs(balance);
const isOver = balance > 0; const isOver = balance > 0;
const isEven = balance === 0; const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds); const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds); const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
return ( return (
<div <div
@@ -127,10 +138,17 @@ export function DashboardPage() {
<div> <div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p> <p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget} {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="flex items-center justify-end gap-1.5">
{target.hasOngoingTimer && (
<span
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
title="Timer running — balance updates every 30 s"
/>
)}
<p <p
className={`text-sm font-bold ${ className={`text-sm font-bold ${
isEven isEven
@@ -144,6 +162,7 @@ export function DashboardPage() {
? '±0' ? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)} : (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p> </p>
</div>
<p className="text-xs text-gray-400">running balance</p> <p className="text-xs text-gray-400">running balance</p>
</div> </div>
</div> </div>

View File

@@ -152,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 {
@@ -168,26 +175,34 @@ export interface ClientTargetWithBalance {
clientId: string; clientId: string;
clientName: string; clientName: string;
userId: string; userId: string;
weeklyHours: number; periodType: "weekly" | "monthly";
targetHours: number;
workingDays: string[]; // e.g. ["MON","WED"]
startDate: string; // YYYY-MM-DD startDate: string; // YYYY-MM-DD
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
corrections: BalanceCorrection[]; corrections: BalanceCorrection[];
totalBalanceSeconds: number; totalBalanceSeconds: number;
currentWeekTrackedSeconds: number; currentPeriodTrackedSeconds: number;
currentWeekTargetSeconds: number; currentPeriodTargetSeconds: number;
weeks: WeekBalance[]; periods: PeriodBalance[];
/** True when an active timer for a project belonging to this client is running. */
hasOngoingTimer: boolean;
} }
export interface CreateClientTargetInput { export interface CreateClientTargetInput {
clientId: string; clientId: string;
weeklyHours: number; targetHours: number;
periodType: "weekly" | "monthly";
workingDays: string[]; // e.g. ["MON","WED","FRI"]
startDate: string; // YYYY-MM-DD startDate: string; // YYYY-MM-DD
} }
export interface UpdateClientTargetInput { export interface UpdateClientTargetInput {
weeklyHours?: number; targetHours?: number;
startDate?: string; periodType?: "weekly" | "monthly";
workingDays?: string[];
startDate?: string; // YYYY-MM-DD
} }
export interface CreateCorrectionInput { export interface CreateCorrectionInput {
@@ -195,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;
}

View File

@@ -25,6 +25,14 @@ enum APIEndpoint {
static let timer = "/timer" static let timer = "/timer"
static let timerStart = "/timer/start" static let timerStart = "/timer/start"
static let timerStop = "/timer/stop" 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 { struct APIEndpoints {

View File

@@ -1,134 +1,3 @@
import SwiftUI // ClientsView.swift replaced by Features/Settings/ClientsListView.swift
// This file is intentionally empty; ClientsViewModel is no longer used directly.
struct ClientsView: View { import Foundation
@StateObject private var viewModel = ClientsViewModel()
@State private var showAddClient = false
@State private var clientToDelete: Client?
@State private var showDeleteConfirmation = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.clients.isEmpty {
LoadingView()
} else if let error = viewModel.error, viewModel.clients.isEmpty {
ErrorView(message: error) {
Task { await viewModel.loadClients() }
}
} else if viewModel.clients.isEmpty {
EmptyView(
icon: "person.2",
title: "No Clients",
message: "Create a client to organize your projects."
)
} else {
clientsList
}
}
.navigationTitle("Clients")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showAddClient = true
} label: {
Image(systemName: "plus")
}
}
}
.task {
await viewModel.loadClients()
}
.sheet(isPresented: $showAddClient) {
ClientFormView(onSave: { name, description in
Task {
await viewModel.createClient(name: name, description: description)
}
})
}
}
}
private var clientsList: some View {
List {
ForEach(viewModel.clients) { client in
ClientRow(client: client)
}
.onDelete { indexSet in
if let index = indexSet.first {
clientToDelete = viewModel.clients[index]
showDeleteConfirmation = true
}
}
}
.alert("Delete Client?", isPresented: $showDeleteConfirmation, presenting: clientToDelete) { client in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteClient(client)
}
}
} message: { client in
Text("This will permanently delete '\(client.name)' and all related projects and time entries. This action cannot be undone.")
}
.refreshable {
await viewModel.loadClients()
}
}
}
struct ClientRow: View {
let client: Client
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(client.name)
.font(.headline)
if let description = client.description {
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
struct ClientFormView: View {
@Environment(\.dismiss) private var dismiss
let onSave: (String, String?) -> Void
@State private var name = ""
@State private var 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)
}
}
.navigationTitle("New Client")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(name, description.isEmpty ? nil : description)
dismiss()
}
.disabled(name.isEmpty)
}
}
}
}
}

View File

@@ -5,79 +5,68 @@ struct DashboardView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { Group {
VStack(spacing: 24) { if viewModel.isLoading && viewModel.statistics == nil && viewModel.recentEntries.isEmpty {
// Active Timer Card LoadingView()
timerCard } else {
scrollContent
// Weekly Stats }
if let stats = viewModel.statistics { }
statsSection(stats) .navigationTitle("Dashboard")
.refreshable { await viewModel.loadData() }
.task { await viewModel.loadData() }
}
} }
// Recent Entries // 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 recentEntriesSection
} }
.padding() .padding()
} }
.navigationTitle("Dashboard")
.refreshable {
await viewModel.loadData()
}
.task {
await viewModel.loadData()
}
}
} }
// MARK: - Active Timer Card
private var timerCard: some View { private var timerCard: some View {
VStack(spacing: 16) {
if let timer = viewModel.activeTimer {
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if let timer = viewModel.activeTimer {
Text("Timer Running") Text("Timer Running")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(viewModel.elapsedTime.formattedDuration) Text(viewModel.elapsedTime.formattedDuration)
.font(.system(size: 32, weight: .medium, design: .monospaced)) .font(.system(size: 32, weight: .medium, design: .monospaced))
if let project = timer.project { if let project = timer.project {
ProjectColorBadge(color: project.color, name: project.name) ProjectColorBadge(color: project.color, name: project.name)
} }
}
Spacer()
Image(systemName: "timer")
.font(.title)
.foregroundStyle(.green)
}
} else { } else {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("No Active Timer") Text("No Active Timer")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Start tracking to see your time") Text("Start tracking to see your time")
.font(.headline) .font(.headline)
} }
}
Spacer() Spacer()
Image(systemName: "timer") Image(systemName: "timer")
.font(.title) .font(.title)
.foregroundStyle(.secondary) .foregroundStyle(viewModel.activeTimer != nil ? .green : .secondary)
}
}
} }
.padding() .padding()
.background(Color(.systemGray6)) .background(Color(.systemGray6))
.cornerRadius(12) .cornerRadius(12)
} }
private func statsSection(_ stats: TimeStatistics) -> some View { // MARK: - Weekly Stats
private func weeklyStatsSection(_ stats: TimeStatistics) -> some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("This Week") Text("This Week")
.font(.headline) .font(.headline)
@@ -85,11 +74,10 @@ struct DashboardView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
StatCard( StatCard(
title: "Hours Tracked", title: "Hours Tracked",
value: TimeInterval(stats.totalSeconds).formattedHours, value: TimeInterval(stats.totalSeconds).formattedShortDuration,
icon: "clock.fill", icon: "clock.fill",
color: .blue color: .blue
) )
StatCard( StatCard(
title: "Entries", title: "Entries",
value: "\(stats.entryCount)", value: "\(stats.entryCount)",
@@ -103,7 +91,6 @@ struct DashboardView: View {
Text("By Project") Text("By Project")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
ForEach(stats.byProject.prefix(5)) { projectStat in ForEach(stats.byProject.prefix(5)) { projectStat in
HStack { HStack {
if let color = projectStat.projectColor { if let color = projectStat.projectColor {
@@ -112,16 +99,34 @@ struct DashboardView: View {
Text(projectStat.projectName) Text(projectStat.projectName)
.font(.subheadline) .font(.subheadline)
Spacer() Spacer()
Text(TimeInterval(projectStat.totalSeconds).formattedHours) Text(TimeInterval(projectStat.totalSeconds).formattedShortDuration)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .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 { private var recentEntriesSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Recent Entries") Text("Recent Entries")
@@ -134,7 +139,8 @@ struct DashboardView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding() .padding()
} else { } else {
ForEach(viewModel.recentEntries) { entry in VStack(spacing: 0) {
ForEach(Array(viewModel.recentEntries.enumerated()), id: \.element.id) { index, entry in
HStack { HStack {
ProjectColorDot(color: entry.project.color, size: 10) ProjectColorDot(color: entry.project.color, size: 10)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
@@ -145,11 +151,15 @@ struct DashboardView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(entry.duration.formattedHours) Text(entry.duration.formattedShortDuration)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.vertical, 4) .padding(.vertical, 8)
if index < viewModel.recentEntries.count - 1 {
Divider()
}
}
} }
} }
} }
@@ -163,3 +173,138 @@ struct DashboardView: View {
return date.formattedDateTime() 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)
}
}

View File

@@ -6,6 +6,7 @@ final class DashboardViewModel: ObservableObject {
@Published var activeTimer: OngoingTimer? @Published var activeTimer: OngoingTimer?
@Published var statistics: TimeStatistics? @Published var statistics: TimeStatistics?
@Published var recentEntries: [TimeEntry] = [] @Published var recentEntries: [TimeEntry] = []
@Published var clientTargets: [ClientTarget] = []
@Published var isLoading = false @Published var isLoading = false
@Published var error: String? @Published var error: String?
@Published var elapsedTime: TimeInterval = 0 @Published var elapsedTime: TimeInterval = 0
@@ -33,13 +34,14 @@ final class DashboardViewModel: ObservableObject {
authenticated: true authenticated: true
) )
// Get statistics for this week // Statistics for this week
let calendar = Calendar.current let calendar = Calendar.current
let today = Date() let today = Date()
let startOfWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! let startOfWeek = calendar.date(
from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today)
)!
let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)! let endOfWeek = calendar.date(byAdding: .day, value: 6, to: startOfWeek)!
let statsInput = StatisticsFiltersInput(startDate: startOfWeek, endDate: endOfWeek)
statistics = try await apiClient.request( statistics = try await apiClient.request(
endpoint: APIEndpoint.timeEntriesStatistics, endpoint: APIEndpoint.timeEntriesStatistics,
queryItems: [ queryItems: [
@@ -49,16 +51,20 @@ final class DashboardViewModel: ObservableObject {
authenticated: true authenticated: true
) )
// Fetch recent entries // Recent entries (last 5)
let entriesResponse: TimeEntryListResponse = try await apiClient.request( let entriesResponse: TimeEntryListResponse = try await apiClient.request(
endpoint: APIEndpoint.timeEntries, endpoint: APIEndpoint.timeEntries,
queryItems: [ queryItems: [URLQueryItem(name: "limit", value: "5")],
URLQueryItem(name: "limit", value: "5")
],
authenticated: true authenticated: true
) )
recentEntries = entriesResponse.entries recentEntries = entriesResponse.entries
// Client targets (for overtime/undertime)
clientTargets = try await apiClient.request(
endpoint: APIEndpoint.clientTargets,
authenticated: true
)
if let timer = activeTimer { if let timer = activeTimer {
elapsedTime = timer.elapsedTime elapsedTime = timer.elapsedTime
} }
@@ -68,7 +74,7 @@ final class DashboardViewModel: ObservableObject {
isLoading = false isLoading = false
self.error = error.localizedDescription self.error = error.localizedDescription
// Try to load cached data // Fallback to cached timer
if let cachedTimer = try? await database.getCachedTimer() { if let cachedTimer = try? await database.getCachedTimer() {
activeTimer = cachedTimer activeTimer = cachedTimer
elapsedTime = cachedTimer.elapsedTime elapsedTime = cachedTimer.elapsedTime
@@ -80,9 +86,7 @@ final class DashboardViewModel: ObservableObject {
timerTask = Task { [weak self] in timerTask = Task { [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000) try? await Task.sleep(nanoseconds: 1_000_000_000)
guard let self = self, self.activeTimer != nil else { continue } guard let self = self, self.activeTimer != nil else { continue }
await MainActor.run { await MainActor.run {
self.elapsedTime = self.activeTimer?.elapsedTime ?? 0 self.elapsedTime = self.activeTimer?.elapsedTime ?? 0
} }

View File

@@ -1,178 +1,3 @@
import SwiftUI // ProjectsView.swift replaced by Features/Settings/ProjectsListView.swift
// This file is intentionally empty; ProjectsViewModel is no longer used directly.
struct ProjectsView: View { import Foundation
@StateObject private var viewModel = ProjectsViewModel()
@State private var showAddProject = false
@State private var projectToDelete: Project?
@State private var showDeleteConfirmation = false
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading && viewModel.projects.isEmpty {
LoadingView()
} else if let error = viewModel.error, viewModel.projects.isEmpty {
ErrorView(message: error) {
Task { await viewModel.loadData() }
}
} else if viewModel.projects.isEmpty {
EmptyView(
icon: "folder",
title: "No Projects",
message: "Create a project to start tracking time."
)
} else {
projectsList
}
}
.navigationTitle("Projects")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showAddProject = true
} label: {
Image(systemName: "plus")
}
}
}
.task {
await viewModel.loadData()
}
.sheet(isPresented: $showAddProject) {
ProjectFormView(
clients: viewModel.clients,
onSave: { name, description, color, clientId in
Task {
await viewModel.createProject(
name: name,
description: description,
color: color,
clientId: clientId
)
}
}
)
}
}
}
private var projectsList: some View {
List {
ForEach(viewModel.projects) { project in
ProjectRow(project: project)
}
.onDelete { indexSet in
if let index = indexSet.first {
projectToDelete = viewModel.projects[index]
showDeleteConfirmation = true
}
}
}
.alert("Delete Project?", isPresented: $showDeleteConfirmation, presenting: projectToDelete) { project in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteProject(project)
}
}
} message: { project in
Text("This will permanently delete '\(project.name)' and all related time entries. This action cannot be undone.")
}
.refreshable {
await viewModel.loadData()
}
}
}
struct ProjectRow: View {
let project: Project
var body: some View {
HStack {
ProjectColorDot(color: project.color, size: 16)
VStack(alignment: .leading, spacing: 2) {
Text(project.name)
.font(.headline)
Text(project.client.name)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
struct ProjectFormView: View {
@Environment(\.dismiss) private var dismiss
let clients: [Client]
let onSave: (String, String?, String?, String) -> Void
@State private var name = ""
@State private var description = ""
@State private var selectedColor: String = "#3B82F6"
@State private var selectedClient: Client?
private let colors = ["#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
"#06B6D4", "#3B82F6", "#6366F1", "#A855F7", "#EC4899"]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Project name", text: $name)
}
Section("Description (Optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
Section("Color") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
ForEach(colors, 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?)
}
}
}
}
.navigationTitle("New Project")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
guard let client = selectedClient else { return }
onSave(name, description.isEmpty ? nil : description, selectedColor, client.id)
dismiss()
}
.disabled(name.isEmpty || selectedClient == nil)
}
}
}
}
}

View File

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

View File

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

View File

@@ -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.")
}
}
}
}

View File

@@ -2,7 +2,16 @@ import SwiftUI
struct TimeEntriesView: View { struct TimeEntriesView: View {
@StateObject private var viewModel = TimeEntriesViewModel() @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 showAddEntry = false
@State private var entryToEdit: TimeEntry?
@State private var entryToDelete: TimeEntry? @State private var entryToDelete: TimeEntry?
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
@@ -15,109 +24,338 @@ struct TimeEntriesView: View {
ErrorView(message: error) { ErrorView(message: error) {
Task { await viewModel.loadEntries() } Task { await viewModel.loadEntries() }
} }
} else if viewModel.entries.isEmpty {
EmptyView(
icon: "clock",
title: "No Time Entries",
message: "Start tracking your time to see entries here."
)
} else { } else {
entriesList mainContent
} }
} }
.navigationTitle("Time Entries") .navigationTitle("Entries")
.toolbar { .navigationBarTitleDisplayMode(.inline)
ToolbarItem(placement: .primaryAction) { .toolbar { toolbarContent }
Button { .task { await viewModel.loadEntries() }
showAddEntry = true .refreshable { await viewModel.loadEntries() }
} label: { .sheet(isPresented: $showFilterSheet) {
Image(systemName: "plus") TimeEntriesFilterSheet(viewModel: viewModel) {
Task { await viewModel.loadEntries() }
} }
} }
}
.task {
await viewModel.loadEntries()
}
.sheet(isPresented: $showAddEntry) { .sheet(isPresented: $showAddEntry) {
TimeEntryFormView(onSave: { TimeEntryDetailSheet(entry: nil) {
Task { await viewModel.loadEntries() } 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.")
} }
} }
} }
private var entriesList: some View { // MARK: - Toolbar
List {
ForEach(viewModel.entries) { entry in @ToolbarContentBuilder
TimeEntryRow(entry: entry) private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Button { showAddEntry = true } label: { Image(systemName: "plus") }
} }
.onDelete { indexSet in ToolbarItem(placement: .topBarLeading) {
if let index = indexSet.first { Button {
entryToDelete = viewModel.entries[index] 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 showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
if index < dayEntries.count - 1 {
Divider().padding(.leading, 56)
} }
} }
} }
.alert("Delete Time Entry?", isPresented: $showDeleteConfirmation, presenting: entryToDelete) { entry in
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
Task {
await viewModel.deleteEntry(entry)
} }
.padding(.bottom, 40) // Give some breathing room at the bottom of the scroll
} }
} message: { entry in
Text("This will permanently delete the time entry for '\(entry.project.name)'. This action cannot be undone.") // MARK: - Helpers
}
.refreshable { private func dayTitle(_ date: Date) -> String {
await viewModel.loadEntries() 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)
} }
} }
struct TimeEntryRow: View { // MARK: - Entry Row
struct EntryRow: View {
let entry: TimeEntry let entry: TimeEntry
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 12) {
HStack { // Color dot
ProjectColorDot(color: entry.project.color) ProjectColorDot(color: entry.project.color, size: 12)
Text(entry.project.name) .padding(.top, 4)
.font(.headline)
Spacer()
Text(entry.duration.formattedHours)
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack { VStack(alignment: .leading, spacing: 3) {
Text(formatDateRange(start: entry.startTime, end: entry.endTime)) Text(entry.project.name)
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium)
HStack(spacing: 6) {
Text(timeRange)
.font(.caption)
.foregroundStyle(.secondary)
Text("·")
.font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer()
Text(entry.project.client.name) Text(entry.project.client.name)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let description = entry.description { if let desc = entry.description, !desc.isEmpty {
Text(description) Text(desc)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
} }
} }
.padding(.vertical, 4)
Spacer()
Text(entry.duration.formattedShortDuration)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 10)
} }
private func formatDateRange(start: String, end: String) -> String { private var timeRange: String {
let formatter = DateFormatter() let fmt = DateFormatter()
formatter.dateFormat = "MMM d, HH:mm" fmt.dateFormat = "HH:mm"
let start = Date.fromISO8601(entry.startTime).map { fmt.string(from: $0) } ?? ""
guard let startDate = Date.fromISO8601(start), let end = Date.fromISO8601(entry.endTime).map { fmt.string(from: $0) } ?? ""
let endDate = Date.fromISO8601(end) else { return "\(start) \(end)"
return "" }
} }
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
// 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()
} }
} }

View File

@@ -1,45 +1,54 @@
import Foundation import Foundation
import SwiftUI 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 @MainActor
final class TimeEntriesViewModel: ObservableObject { final class TimeEntriesViewModel: ObservableObject {
// All loaded entries (flat list, accumulated across pages)
@Published var entries: [TimeEntry] = [] @Published var entries: [TimeEntry] = []
@Published var pagination: Pagination? @Published var pagination: Pagination?
@Published var isLoading = false @Published var isLoading = false
@Published var isLoadingMore = false
@Published var error: String? @Published var error: String?
@Published var filters = TimeEntryFilters()
// 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() private let apiClient = APIClient()
func loadEntries() async { // MARK: - Fetch
func loadEntries(resetPage: Bool = true) async {
if resetPage { entries = [] }
isLoading = true isLoading = true
error = nil error = nil
do { do {
var queryItems: [URLQueryItem] = []
if let startDate = filters.startDate {
queryItems.append(URLQueryItem(name: "startDate", value: startDate))
}
if let endDate = filters.endDate {
queryItems.append(URLQueryItem(name: "endDate", value: endDate))
}
if let projectId = filters.projectId {
queryItems.append(URLQueryItem(name: "projectId", value: projectId))
}
if let page = filters.page {
queryItems.append(URLQueryItem(name: "page", value: String(page)))
}
if let limit = filters.limit {
queryItems.append(URLQueryItem(name: "limit", value: String(limit)))
}
let response: TimeEntryListResponse = try await apiClient.request( let response: TimeEntryListResponse = try await apiClient.request(
endpoint: APIEndpoint.timeEntries, endpoint: APIEndpoint.timeEntries,
queryItems: queryItems.isEmpty ? nil : queryItems, queryItems: buildQueryItems(page: 1),
authenticated: true authenticated: true
) )
entries = response.entries entries = response.entries
pagination = response.pagination pagination = response.pagination
isLoading = false isLoading = false
@@ -49,20 +58,27 @@ final class TimeEntriesViewModel: ObservableObject {
} }
} }
func nextPage() async { func loadMoreIfNeeded(currentEntry entry: TimeEntry) async {
guard let pagination = pagination, guard let pagination, !isLoadingMore,
pagination.page < pagination.totalPages else { return } pagination.page < pagination.totalPages else { return }
// Trigger when the last entry in the list becomes visible
guard entries.last?.id == entry.id else { return }
filters.page = pagination.page + 1 isLoadingMore = true
await loadEntries() 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 previousPage() async {
guard let pagination = pagination,
pagination.page > 1 else { return }
filters.page = pagination.page - 1
await loadEntries()
} }
func deleteEntry(_ entry: TimeEntry) async { func deleteEntry(_ entry: TimeEntry) async {
@@ -72,10 +88,83 @@ final class TimeEntriesViewModel: ObservableObject {
method: .delete, method: .delete,
authenticated: true authenticated: true
) )
entries.removeAll { $0.id == entry.id } entries.removeAll { $0.id == entry.id }
} catch { } catch {
self.error = error.localizedDescription 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
}
} }

View File

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

View File

@@ -1,171 +1,2 @@
import SwiftUI // TimeEntryFormView.swift replaced by TimeEntryDetailSheet.swift
import Foundation
struct TimeEntryFormView: View {
@Environment(\.dismiss) private var dismiss
let entry: TimeEntry?
let onSave: () -> Void
@State private var startDate = Date()
@State private var startTime = Date()
@State private var endDate = Date()
@State private var endTime = Date()
@State private var description = ""
@State private var selectedProject: Project?
@State private var projects: [Project] = []
@State private var isLoading = false
@State private var error: String?
private let apiClient = APIClient()
init(entry: TimeEntry? = nil, onSave: @escaping () -> Void) {
self.entry = entry
self.onSave = onSave
}
var body: some View {
NavigationStack {
Form {
Section("Project") {
Picker("Project", selection: $selectedProject) {
Text("Select Project").tag(nil as Project?)
ForEach(projects) { project in
HStack {
ProjectColorDot(color: project.color)
Text(project.name)
}
.tag(project as Project?)
}
}
}
Section("Start Time") {
DatePicker("Date", selection: $startDate, displayedComponents: .date)
DatePicker("Time", selection: $startTime, displayedComponents: .hourAndMinute)
}
Section("End Time") {
DatePicker("Date", selection: $endDate, displayedComponents: .date)
DatePicker("Time", selection: $endTime, displayedComponents: .hourAndMinute)
}
Section("Description (Optional)") {
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle(entry == nil ? "New Entry" : "Edit Entry")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
save()
}
.disabled(selectedProject == nil || isLoading)
}
}
.task {
await loadProjects()
if let entry = entry {
await loadEntry(entry)
}
}
}
}
private func loadProjects() async {
do {
projects = try await apiClient.request(
endpoint: APIEndpoint.projects,
authenticated: true
)
} catch {
self.error = error.localizedDescription
}
}
private func loadEntry(_ entry: TimeEntry) async {
let startFormatter = DateFormatter()
startFormatter.dateFormat = "yyyy-MM-dd"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "HH:mm"
if let startDateObj = Date.fromISO8601(entry.startTime) {
startDate = startDateObj
startTime = startDateObj
}
if let endDateObj = Date.fromISO8601(entry.endTime) {
endDate = endDateObj
endTime = endDateObj
}
description = entry.description ?? ""
selectedProject = projects.first { $0.id == entry.projectId }
}
private func save() {
guard let project = selectedProject else { return }
isLoading = true
let calendar = Calendar.current
let startDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: startTime),
minute: calendar.component(.minute, from: startTime),
second: 0,
of: startDate) ?? startDate
let endDateTime = calendar.date(bySettingHour: calendar.component(.hour, from: endTime),
minute: calendar.component(.minute, from: endTime),
second: 0,
of: endDate) ?? endDate
Task {
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
)
}
await MainActor.run {
isLoading = false
dismiss()
onSave()
}
} catch {
let errorMessage = error.localizedDescription
await MainActor.run {
isLoading = false
self.error = errorMessage
}
}
}
}
}

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

View File

@@ -1,6 +1,29 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "app_icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

View File

@@ -29,10 +29,12 @@ struct StatCard: View {
} }
extension TimeInterval { extension TimeInterval {
/// Formats as a clock string used for the live timer display: "1:23:45" or "05:30".
var formattedDuration: String { var formattedDuration: String {
let hours = Int(self) / 3600 let totalSeconds = Int(self)
let minutes = (Int(self) % 3600) / 60 let hours = totalSeconds / 3600
let seconds = Int(self) % 60 let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if hours > 0 { if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds) return String(format: "%d:%02d:%02d", hours, minutes, seconds)
@@ -41,6 +43,24 @@ extension TimeInterval {
} }
} }
/// 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 { var formattedHours: String {
let hours = self / 3600 let hours = self / 3600
return String(format: "%.1fh", hours) return String(format: "%.1fh", hours)

View File

@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.simonfranken.timetracker</string> <string>group.com.simonfranken.timetracker.app</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@@ -35,34 +35,20 @@ struct MainTabView: View {
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
DashboardView() DashboardView()
.tabItem { .tabItem { Label("Dashboard", systemImage: "chart.bar") }
Label("Dashboard", systemImage: "chart.bar")
}
.tag(0) .tag(0)
TimerView() TimerView()
.tabItem { .tabItem { Label("Timer", systemImage: "timer") }
Label("Timer", systemImage: "timer")
}
.tag(1) .tag(1)
TimeEntriesView() TimeEntriesView()
.tabItem { .tabItem { Label("Entries", systemImage: "calendar") }
Label("Entries", systemImage: "clock")
}
.tag(2) .tag(2)
ProjectsView() SettingsView()
.tabItem { .tabItem { Label("Settings", systemImage: "gearshape") }
Label("Projects", systemImage: "folder")
}
.tag(3) .tag(3)
ClientsView()
.tabItem {
Label("Clients", systemImage: "person.2")
}
.tag(4)
} }
} }
} }

View File

@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.simonfranken.timetracker</string> <string>group.com.simonfranken.timetracker.app</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@@ -40,16 +40,22 @@ A multi-user web application for tracking time spent working on projects. Users
| **Project** | A project belonging to a client | User, belongs to one Client | | **Project** | A project belonging to a client | User, belongs to one Client |
| **TimeEntry** | A completed time tracking record | User (explicit), belongs to one Project | | **TimeEntry** | A completed time tracking record | User (explicit), belongs to one Project |
| **OngoingTimer** | An active timer while tracking is in progress | User (explicit), belongs to one Project (optional) | | **OngoingTimer** | An active timer while tracking is in progress | User (explicit), belongs to one Project (optional) |
| **ClientTarget** | Hourly target for a client per period | User, belongs to one Client |
| **BalanceCorrection** | Manual hour adjustment for a target | Belongs to one ClientTarget |
| **ApiKey** | API key for external tool access | User |
### Relationships ### Relationships
``` ```
User User
├── Client (one-to-many) ├── Client (one-to-many)
── Project (one-to-many) ── Project (one-to-many)
└── TimeEntry (one-to-many, explicit user reference) └── TimeEntry (one-to-many, explicit user reference)
│ └── ClientTarget (one-to-one per client)
│ └── BalanceCorrection (one-to-many)
── OngoingTimer (zero-or-one, explicit user reference) ── OngoingTimer (zero-or-one, explicit user reference)
└── ApiKey (one-to-many)
``` ```
**Important**: Both `TimeEntry` and `OngoingTimer` have explicit references to the user who created them. This is distinct from the project's ownership and is required for future extensibility (see Future Extensibility section). **Important**: Both `TimeEntry` and `OngoingTimer` have explicit references to the user who created them. This is distinct from the project's ownership and is required for future extensibility (see Future Extensibility section).
@@ -127,10 +133,72 @@ User
- Start time - Start time
- End time - End time
- Project - Project
- Optional fields:
- Break minutes (deducted from total duration)
- Description (notes about the work)
- The entry is validated against overlap rules before saving - The entry is validated against overlap rules before saving
--- ---
### 6. Statistics
- User can view aggregated time tracking statistics
- Filters available:
- Date range (start/end)
- Client
- Project
- Statistics display:
- Total working time
- Entry count
- Breakdown by project (with color indicators)
- Breakdown by client
---
### 7. Client Targets
- User can set hourly targets per client
- Target configuration:
- Target hours per period
- Period type (weekly or monthly)
- Working days (e.g., MON-FRI)
- Start date
- Balance tracking:
- Shows current balance vs target
- Supports manual corrections (e.g., holidays, overtime carry-over)
- Only one target per client allowed
---
### 8. API Keys
- User can generate API keys for external tool access
- API key properties:
- Name (for identification)
- Prefix (first characters shown for identification)
- Last used timestamp
- Security:
- Raw key shown only once at creation
- Key is hashed (SHA-256) before storage
- Keys can be revoked (deleted)
---
### 9. MCP Integration
- Model Context Protocol endpoint for AI agent access
- Stateless operation (no session persistence)
- Tools exposed:
- Client CRUD operations
- Project CRUD operations
- Time entry CRUD operations
- Timer start/stop/cancel
- Client target management
- Statistics queries
- Authentication via API keys
---
## API Endpoints (Suggested) ## API Endpoints (Suggested)
### Authentication ### Authentication
@@ -165,8 +233,29 @@ User
- `POST /api/timer/start` — Start timer (creates OngoingTimer) - `POST /api/timer/start` — Start timer (creates OngoingTimer)
- `PUT /api/timer` — Update ongoing timer (e.g., set project) - `PUT /api/timer` — Update ongoing timer (e.g., set project)
- `POST /api/timer/stop` — Stop timer (converts to TimeEntry) - `POST /api/timer/stop` — Stop timer (converts to TimeEntry)
- `POST /api/timer/cancel` — Cancel timer without saving
- `GET /api/timer` — Get current ongoing timer (if any) - `GET /api/timer` — Get current ongoing timer (if any)
### Client Targets
- `GET /api/client-targets` — List targets with computed balance
- `POST /api/client-targets` — Create a target
- `PUT /api/client-targets/{id}` — Update a target
- `DELETE /api/client-targets/{id}` — Delete a target
- `POST /api/client-targets/{id}/corrections` — Add a correction
- `DELETE /api/client-targets/{id}/corrections/{correctionId}` — Delete a correction
### API Keys
- `GET /api/api-keys` — List user's API keys
- `POST /api/api-keys` — Create a new API key
- `DELETE /api/api-keys/{id}` — Revoke an API key
### MCP (Model Context Protocol)
- `GET /mcp` — SSE stream for server-initiated messages
- `POST /mcp` — JSON-RPC requests (tool invocations)
--- ---
## UI Requirements ## UI Requirements
@@ -183,6 +272,9 @@ User
- **Dashboard**: Overview with active timer widget and recent entries - **Dashboard**: Overview with active timer widget and recent entries
- **Time Entries**: List/calendar view of all entries with filters (date range, client, project) - **Time Entries**: List/calendar view of all entries with filters (date range, client, project)
- **Clients & Projects**: Management interface for clients and projects - **Clients & Projects**: Management interface for clients and projects
- **Statistics**: Aggregated time data with filters and breakdowns
- **API Keys**: Create and manage API keys for external access
- **Client Targets**: Set and monitor hourly targets per client
--- ---

View File

@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
REGISTRY="git.simon-franken.de" REGISTRY="git.simon-franken.de"
CHART_DIR="timetracker-chart" CHART_DIR="helm"
# Load .env file if present (values do not override existing env variables) # Load .env file if present (values do not override existing env variables)
if [[ -f ".env" ]]; then if [[ -f ".env" ]]; then