39 Commits

Author SHA1 Message Date
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
1b0f5866a1 Restore onDelete: Cascade on Project->Client and TimeEntry->Project
Direct database deletes should still cascade to avoid orphaned records.
The migration now only adds the three deleted_at columns without touching
the existing FK constraints.
2026-02-23 15:32:31 +01:00
simon.franken
159022ef38 Exclude client targets for soft-deleted clients
findAll and findById filter on client.deletedAt = null so targets
belonging to a soft-deleted client are invisible. The create guard
also rejects soft-deleted clients. The raw SQL balance query now
excludes soft-deleted time entries and projects from tracked totals.
2026-02-23 15:24:58 +01:00
simon.franken
1a7d13d5b9 Implement soft-delete for clients, projects, and time entries
Replace hard deletes with deletedAt timestamp flags on all three entities.
Deleting a client or project only sets its own deletedAt; child records are
excluded implicitly by filtering on parent deletedAt in every read query.
Raw SQL statistics queries also filter out soft-deleted parents.
FK ON DELETE CASCADE removed from Project→Client and TimeEntry→Project.
2026-02-23 15:21:13 +01:00
simon.franken
685a311001 Add break time feature to time entries
- Add breakMinutes field to TimeEntry model and database migration
- Users can now add break duration (minutes) to time entries
- Break time is subtracted from total tracked duration
- Validation ensures break time cannot exceed total entry duration
- Statistics and client target balance calculations account for breaks
- Frontend UI includes break time input in TimeEntryFormModal
- Duration displays show break time deduction (e.g., '7h (−1h break)')
- Both project/client statistics and weekly balance calculations updated
2026-02-23 14:39:30 +01:00
d09247d2a5 Merge pull request 'Add Prisma session store for persistent sessions' (#5) from feature/prisma-session-store into main
Reviewed-on: #5
2026-02-23 13:35:21 +00:00
simon.franken
078dc8c304 Add Prisma session store for persistent sessions 2026-02-23 11:39:09 +01:00
simon.franken
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
58 changed files with 5034 additions and 1139 deletions

View File

@@ -8,7 +8,9 @@
"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",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
@@ -470,6 +472,388 @@
"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": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.19.2", "version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
@@ -555,6 +939,24 @@
"@prisma/debug": "6.19.2" "@prisma/debug": "6.19.2"
} }
}, },
"node_modules/@quixo3/prisma-session-store": {
"version": "3.1.19",
"resolved": "https://registry.npmjs.org/@quixo3/prisma-session-store/-/prisma-session-store-3.1.19.tgz",
"integrity": "sha512-fCG7dzmd8dyqoj4XSi5IHETqrbzN+roz4+4pPS1uMo0kVQu8CT9HRbULuIaOxWCAODT7yGyNGNvVywEeGI80lw==",
"license": "MIT",
"dependencies": {
"@paralleldrive/cuid2": "^2.2.0",
"ts-dedent": "^2.2.0",
"type-fest": "^5.3.1"
},
"engines": {
"node": ">=12.0"
},
"peerDependencies": {
"@prisma/client": ">=2.16.1",
"express-session": ">=1.17.1"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -731,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",
@@ -943,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",
@@ -1153,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",
@@ -1199,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",
@@ -1252,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",
@@ -1416,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",
@@ -1454,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",
@@ -1463,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",
@@ -1482,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",
@@ -1768,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",
@@ -1792,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",
@@ -1812,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",
@@ -1953,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",
@@ -1963,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",
@@ -2052,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",
@@ -2133,6 +2791,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -2152,6 +2822,15 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/ts-dedent": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz",
"integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
"license": "MIT",
"engines": {
"node": ">=6.10"
}
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@@ -2172,6 +2851,21 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/type-fest": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
"integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -2245,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",
@@ -2259,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,7 +10,9 @@
"db:seed": "tsx prisma/seed.ts" "db:seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@quixo3/prisma-session-store": "^3.1.19",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"sid" TEXT NOT NULL,
"data" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_sid_key" ON "sessions"("sid");

View File

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

View File

@@ -0,0 +1,8 @@
-- AlterTable: add deleted_at column to clients
ALTER TABLE "clients" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- AlterTable: add deleted_at column to projects
ALTER TABLE "projects" ADD COLUMN "deleted_at" TIMESTAMP(3);
-- AlterTable: add deleted_at column to time_entries
ALTER TABLE "time_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);

View File

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

View File

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

View File

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

@@ -10,7 +10,7 @@ datasource db {
model User { model User {
id String @id @db.VarChar(255) id String @id @db.VarChar(255)
username String @db.VarChar(255) username String @db.VarChar(255)
fullName String? @db.VarChar(255) @map("full_name") fullName String? @map("full_name") @db.VarChar(255)
email String @db.VarChar(255) email String @db.VarChar(255)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -20,6 +20,7 @@ model User {
timeEntries TimeEntry[] timeEntries TimeEntry[]
ongoingTimer OngoingTimer? ongoingTimer OngoingTimer?
clientTargets ClientTarget[] clientTargets ClientTarget[]
apiKeys ApiKey[]
@@map("users") @@map("users")
} }
@@ -30,6 +31,7 @@ model Client {
description String? @db.Text description String? @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -47,6 +49,7 @@ model Project {
color String? @db.VarChar(7) // Hex color code color String? @db.VarChar(7) // Hex color code
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -65,9 +68,11 @@ model TimeEntry {
id String @id @default(uuid()) id String @id @default(uuid())
startTime DateTime @map("start_time") @db.Timestamptz() startTime DateTime @map("start_time") @db.Timestamptz()
endTime DateTime @map("end_time") @db.Timestamptz() endTime DateTime @map("end_time") @db.Timestamptz()
breakMinutes Int @default(0) @map("break_minutes")
description String? @db.Text description String? @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
userId String @map("user_id") @db.VarChar(255) userId String @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -86,7 +91,7 @@ model OngoingTimer {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
userId String @map("user_id") @db.VarChar(255) @unique userId String @unique @map("user_id") @db.VarChar(255)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String? @map("project_id") projectId String? @map("project_id")
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
@@ -95,12 +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)
@@ -122,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)
@@ -129,3 +143,27 @@ model BalanceCorrection {
@@index([clientTargetId]) @@index([clientTargetId])
@@map("balance_corrections") @@map("balance_corrections")
} }
model Session {
id String @id
sid String @unique
data String @db.Text
expiresAt DateTime @map("expires_at")
@@map("sessions")
}
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

@@ -1,8 +1,9 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import session from "express-session"; import session from "express-session";
import { PrismaSessionStore } from "@quixo3/prisma-session-store";
import { config, validateConfig } from "./config"; import { config, validateConfig } from "./config";
import { connectDatabase } from "./prisma/client"; import { connectDatabase, prisma } from "./prisma/client";
import { errorHandler, notFoundHandler } from "./middleware/errorHandler"; import { errorHandler, notFoundHandler } from "./middleware/errorHandler";
// Import routes // Import routes
@@ -12,6 +13,8 @@ import projectRoutes from "./routes/project.routes";
import timeEntryRoutes from "./routes/timeEntry.routes"; import timeEntryRoutes from "./routes/timeEntry.routes";
import timerRoutes from "./routes/timer.routes"; import timerRoutes from "./routes/timer.routes";
import clientTargetRoutes from "./routes/clientTarget.routes"; import clientTargetRoutes from "./routes/clientTarget.routes";
import apiKeyRoutes from "./routes/apiKey.routes";
import mcpRoutes from "./routes/mcp.routes";
async function main() { async function main() {
// Validate configuration // Validate configuration
@@ -43,6 +46,11 @@ async function main() {
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
name: "sessionId", name: "sessionId",
store: new PrismaSessionStore(prisma, {
checkPeriod: 2 * 60 * 1000, // ms
dbRecordIdIsSessionId: true,
dbRecordIdFunction: undefined,
}),
cookie: { cookie: {
secure: config.nodeEnv === "production", secure: config.nodeEnv === "production",
httpOnly: true, httpOnly: true,
@@ -64,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

@@ -31,6 +31,7 @@ export const UpdateProjectSchema = z.object({
export const CreateTimeEntrySchema = z.object({ export const CreateTimeEntrySchema = z.object({
startTime: z.string().datetime(), startTime: z.string().datetime(),
endTime: z.string().datetime(), endTime: z.string().datetime(),
breakMinutes: z.number().int().min(0).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
projectId: z.string().uuid(), projectId: z.string().uuid(),
}); });
@@ -38,6 +39,7 @@ export const CreateTimeEntrySchema = z.object({
export const UpdateTimeEntrySchema = z.object({ export const UpdateTimeEntrySchema = z.object({
startTime: z.string().datetime().optional(), startTime: z.string().datetime().optional(),
endTime: z.string().datetime().optional(), endTime: z.string().datetime().optional(),
breakMinutes: z.number().int().min(0).optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
projectId: z.string().uuid().optional(), projectId: z.string().uuid().optional(),
}); });
@@ -71,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(),
}); });
@@ -87,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

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

View File

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

View File

@@ -7,6 +7,8 @@ export class ProjectService {
return prisma.project.findMany({ return prisma.project.findMany({
where: { where: {
userId, userId,
deletedAt: null,
client: { deletedAt: null },
...(clientId && { clientId }), ...(clientId && { clientId }),
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -23,7 +25,12 @@ export class ProjectService {
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.project.findFirst({ return prisma.project.findFirst({
where: { id, userId }, where: {
id,
userId,
deletedAt: null,
client: { deletedAt: null },
},
include: { include: {
client: { client: {
select: { select: {
@@ -36,9 +43,9 @@ export class ProjectService {
} }
async create(userId: string, data: CreateProjectInput) { async create(userId: string, data: CreateProjectInput) {
// Verify the client belongs to the user // Verify the client belongs to the user and is not soft-deleted
const client = await prisma.client.findFirst({ const client = await prisma.client.findFirst({
where: { id: data.clientId, userId }, where: { id: data.clientId, userId, deletedAt: null },
}); });
if (!client) { if (!client) {
@@ -70,10 +77,10 @@ export class ProjectService {
throw new NotFoundError("Project not found"); throw new NotFoundError("Project not found");
} }
// If clientId is being updated, verify it belongs to the user // If clientId is being updated, verify it belongs to the user and is not soft-deleted
if (data.clientId) { if (data.clientId) {
const client = await prisma.client.findFirst({ const client = await prisma.client.findFirst({
where: { id: data.clientId, userId }, where: { id: data.clientId, userId, deletedAt: null },
}); });
if (!client) { if (!client) {
@@ -108,8 +115,9 @@ export class ProjectService {
throw new NotFoundError("Project not found"); throw new NotFoundError("Project not found");
} }
await prisma.project.delete({ await prisma.project.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -42,11 +42,15 @@ export class TimeEntryService {
p.id AS project_id, p.id AS project_id,
p.name AS project_name, p.name AS project_name,
p.color AS project_color, p.color AS project_color,
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS total_seconds,
COUNT(te.id)::bigint AS entry_count COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
GROUP BY p.id, p.name, p.color GROUP BY p.id, p.name, p.color
ORDER BY total_seconds DESC ORDER BY total_seconds DESC
@@ -63,12 +67,15 @@ export class TimeEntryService {
SELECT SELECT
c.id AS client_id, c.id AS client_id,
c.name AS client_name, c.name AS client_name,
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS total_seconds,
COUNT(te.id)::bigint AS entry_count COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
GROUP BY c.id, c.name GROUP BY c.id, c.name
ORDER BY total_seconds DESC ORDER BY total_seconds DESC
@@ -77,11 +84,15 @@ export class TimeEntryService {
prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>( prisma.$queryRaw<{ total_seconds: bigint; entry_count: bigint }[]>(
Prisma.sql` Prisma.sql`
SELECT SELECT
COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time))), 0)::bigint AS total_seconds, COALESCE(SUM(EXTRACT(EPOCH FROM (te.end_time - te.start_time)) - (te.break_minutes * 60)), 0)::bigint AS total_seconds,
COUNT(te.id)::bigint AS entry_count COUNT(te.id)::bigint AS entry_count
FROM time_entries te FROM time_entries te
JOIN projects p ON p.id = te.project_id JOIN projects p ON p.id = te.project_id
JOIN clients c ON c.id = p.client_id
WHERE te.user_id = ${userId} WHERE te.user_id = ${userId}
AND te.deleted_at IS NULL
AND p.deleted_at IS NULL
AND c.deleted_at IS NULL
${filterClause} ${filterClause}
`, `,
), ),
@@ -125,10 +136,11 @@ export class TimeEntryService {
const where: { const where: {
userId: string; userId: string;
deletedAt: null;
startTime?: { gte?: Date; lte?: Date }; startTime?: { gte?: Date; lte?: Date };
projectId?: string; projectId?: string;
project?: { clientId?: string }; project?: { deletedAt: null; clientId?: string; client: { deletedAt: null } };
} = { userId }; } = { userId, deletedAt: null };
if (startDate || endDate) { if (startDate || endDate) {
where.startTime = {}; where.startTime = {};
@@ -140,9 +152,13 @@ export class TimeEntryService {
where.projectId = projectId; where.projectId = projectId;
} }
if (clientId) { // Always filter out entries whose project or client is soft-deleted,
where.project = { clientId }; // merging the optional clientId filter into the project relation filter.
} where.project = {
deletedAt: null,
client: { deletedAt: null },
...(clientId && { clientId }),
};
const [entries, total] = await Promise.all([ const [entries, total] = await Promise.all([
prisma.timeEntry.findMany({ prisma.timeEntry.findMany({
@@ -182,7 +198,12 @@ export class TimeEntryService {
async findById(id: string, userId: string) { async findById(id: string, userId: string) {
return prisma.timeEntry.findFirst({ return prisma.timeEntry.findFirst({
where: { id, userId }, where: {
id,
userId,
deletedAt: null,
project: { deletedAt: null, client: { deletedAt: null } },
},
include: { include: {
project: { project: {
select: { select: {
@@ -204,15 +225,22 @@ export class TimeEntryService {
async create(userId: string, data: CreateTimeEntryInput) { async create(userId: string, data: CreateTimeEntryInput) {
const startTime = new Date(data.startTime); const startTime = new Date(data.startTime);
const endTime = new Date(data.endTime); const endTime = new Date(data.endTime);
const breakMinutes = data.breakMinutes ?? 0;
// Validate end time is after start time // Validate end time is after start time
if (endTime <= startTime) { if (endTime <= startTime) {
throw new BadRequestError("End time must be after start time"); throw new BadRequestError("End time must be after start time");
} }
// Verify the project belongs to the user // Validate break time doesn't exceed duration
const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000;
if (breakMinutes > durationMinutes) {
throw new BadRequestError("Break time cannot exceed total duration");
}
// Verify the project belongs to the user and is not soft-deleted (nor its client)
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
}); });
if (!project) { if (!project) {
@@ -235,6 +263,7 @@ export class TimeEntryService {
data: { data: {
startTime, startTime,
endTime, endTime,
breakMinutes,
description: data.description, description: data.description,
userId, userId,
projectId: data.projectId, projectId: data.projectId,
@@ -267,16 +296,23 @@ export class TimeEntryService {
? new Date(data.startTime) ? new Date(data.startTime)
: entry.startTime; : entry.startTime;
const endTime = data.endTime ? new Date(data.endTime) : entry.endTime; const endTime = data.endTime ? new Date(data.endTime) : entry.endTime;
const breakMinutes = data.breakMinutes ?? entry.breakMinutes;
// Validate end time is after start time // Validate end time is after start time
if (endTime <= startTime) { if (endTime <= startTime) {
throw new BadRequestError("End time must be after start time"); throw new BadRequestError("End time must be after start time");
} }
// If project changed, verify it belongs to the user // Validate break time doesn't exceed duration
const durationMinutes = (endTime.getTime() - startTime.getTime()) / 60000;
if (breakMinutes > durationMinutes) {
throw new BadRequestError("Break time cannot exceed total duration");
}
// If project changed, verify it belongs to the user and is not soft-deleted
if (data.projectId && data.projectId !== entry.projectId) { if (data.projectId && data.projectId !== entry.projectId) {
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id: data.projectId, userId }, where: { id: data.projectId, userId, deletedAt: null, client: { deletedAt: null } },
}); });
if (!project) { if (!project) {
@@ -302,6 +338,7 @@ export class TimeEntryService {
data: { data: {
startTime, startTime,
endTime, endTime,
breakMinutes,
description: data.description, description: data.description,
projectId: data.projectId, projectId: data.projectId,
}, },
@@ -329,8 +366,9 @@ export class TimeEntryService {
throw new NotFoundError("Time entry not found"); throw new NotFoundError("Time entry not found");
} }
await prisma.timeEntry.delete({ await prisma.timeEntry.update({
where: { id }, where: { id },
data: { deletedAt: new Date() },
}); });
} }
} }

View File

@@ -31,13 +31,14 @@ export interface CreateProjectInput {
export interface UpdateProjectInput { export interface UpdateProjectInput {
name?: string; name?: string;
description?: string; description?: string;
color?: string; color?: string | null;
clientId?: string; clientId?: string;
} }
export interface CreateTimeEntryInput { export interface CreateTimeEntryInput {
startTime: string; startTime: string;
endTime: string; endTime: string;
breakMinutes?: number;
description?: string; description?: string;
projectId: string; projectId: string;
} }
@@ -45,6 +46,7 @@ export interface CreateTimeEntryInput {
export interface UpdateTimeEntryInput { export interface UpdateTimeEntryInput {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
breakMinutes?: number;
description?: string; description?: string;
projectId?: string; projectId?: string;
} }
@@ -80,13 +82,17 @@ export interface StopTimerInput {
export interface CreateClientTargetInput { export interface CreateClientTargetInput {
clientId: string; clientId: string;
weeklyHours: number; targetHours: number;
startDate: string; // YYYY-MM-DD, always a Monday periodType: 'weekly' | 'monthly';
workingDays: string[]; // e.g. ["MON","WED","FRI"]
startDate: string; // YYYY-MM-DD
} }
export interface UpdateClientTargetInput { export interface UpdateClientTargetInput {
weeklyHours?: number; targetHours?: number;
startDate?: string; // YYYY-MM-DD, always a Monday periodType?: 'weekly' | 'monthly';
workingDays?: string[];
startDate?: string; // YYYY-MM-DD
} }
export interface CreateCorrectionInput { export interface CreateCorrectionInput {

View File

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

View File

@@ -0,0 +1,285 @@
# Client Targets v2 — Feature Requirements
## Overview
This document defines the requirements for the second iteration of the Client Targets feature. The main additions are:
- Targets can be set on a **weekly or monthly** period.
- Each target defines a **fixed weekly working-day pattern** (e.g. Mon + Wed).
- The balance for the **current period** is calculated proportionally based on elapsed working days, so the user can see at any point in time whether they are ahead or behind.
- The **start date** can be any calendar day (no longer restricted to Mondays).
- Manual **balance corrections** are preserved and continue to work as before.
---
## 1. Target Configuration
| Field | Type | Constraints |
|---|---|---|
| `periodType` | `WEEKLY \| MONTHLY` | Required |
| `weeklyOrMonthlyHours` | positive float, ≤ 168 | Required; represents hours per week or per month |
| `workingDays` | array of day names | At least one of `MON TUE WED THU FRI SAT SUN`; fixed repeating pattern |
| `startDate` | `YYYY-MM-DD` | Any calendar day; no longer restricted to Mondays |
| `clientId` | UUID | Must belong to the authenticated user |
**One active target per client** — the unique `(userId, clientId)` constraint is preserved. To change period type, hours, or working days the user creates a new target with a new `startDate`; the old target is soft-deleted. History from the old target is retained as-is and is no longer recalculated.
---
## 2. Period Definitions
| `periodType` | Period start | Period end |
|---|---|---|
| `WEEKLY` | Monday 00:00 of the calendar week | Sunday 23:59 of that same calendar week |
| `MONTHLY` | 1st of the calendar month 00:00 | Last day of the calendar month 23:59 |
---
## 3. Balance Calculation — Overview
The total balance is the **sum of individual period balances** from the period containing `startDate` up to and including the **current period** (the period that contains today).
Each period is classified as either **completed** or **ongoing**.
```
total_balance_seconds = SUM( balance_seconds ) over all periods
```
Positive = overtime. Negative = undertime.
---
## 4. Completed Period Balance
A period is **completed** when its end date is strictly before today.
```
balance = tracked_hours + correction_hours - period_target_hours
```
- `period_target_hours` — see §5 (pro-ration) for the first period; full `weeklyOrMonthlyHours` for all subsequent periods.
- `tracked_hours` — sum of all time entries for this client whose date falls within `[period_start, period_end]`.
- `correction_hours` — sum of manual corrections whose `date` falls within `[period_start, period_end]`.
No working-day logic is applied to completed periods. The target is simply the (optionally pro-rated) hours for that period.
---
## 5. First Period Pro-ration
If `startDate` does not fall on the natural first day of a period (Monday for weekly, 1st for monthly), the target hours for that first period are pro-rated by calendar days.
### Monthly
```
full_period_days = total calendar days in that month
remaining_days = (last day of month) startDate + 1 // inclusive
period_target_hours = (remaining_days / full_period_days) × weeklyOrMonthlyHours
```
**Example:** startDate = Jan 25, target = 40 h/month, January has 31 days.
`remaining_days = 7`, `period_target_hours = (7 / 31) × 40 = 9.032 h`
### Weekly
```
full_period_days = 7
remaining_days = Sunday of that calendar week startDate + 1 // inclusive
period_target_hours = (remaining_days / 7) × weeklyOrMonthlyHours
```
**Example:** startDate = Wednesday, target = 40 h/week.
`remaining_days = 5 (WedSun)`, `period_target_hours = (5 / 7) × 40 = 28.571 h`
All periods after the first use the full `weeklyOrMonthlyHours`.
---
## 6. Ongoing Period Balance (Current Period)
The current period is **ongoing** when today falls within it. The balance reflects how the user is doing *so far* — future working days within the current period are not considered.
### Step 1 — Period target hours
Apply §5 if this is the first period; otherwise use full `weeklyOrMonthlyHours`.
### Step 2 — Daily rate
```
working_days_in_period = COUNT of days in [period_start, period_end]
that match the working day pattern
daily_rate_hours = period_target_hours / working_days_in_period
```
The rate is fixed at the start of the period and does not change as time passes.
### Step 3 — Elapsed working days
```
elapsed_working_days = COUNT of days in [period_start, TODAY] (both inclusive)
that match the working day pattern
```
- If today matches the working day pattern, it is counted as a **full** elapsed working day.
- If today does not match the working day pattern, it is not counted.
### Step 4 — Expected hours so far
```
expected_hours = elapsed_working_days × daily_rate_hours
```
### Step 5 — Balance
```
tracked_hours = SUM of time entries for this client in [period_start, today]
correction_hours = SUM of manual corrections whose date ∈ [period_start, today]
balance = tracked_hours + correction_hours expected_hours
```
### Worked example
> Target: 40 h/month. Working days: Mon + Wed.
> Current month has 4 Mondays and 4 Wednesdays → `working_days_in_period = 8`.
> `daily_rate_hours = 40 / 8 = 5 h`.
> 3 working days have elapsed → `expected_hours = 15 h`.
> Tracked so far: 13 h, no corrections.
> `balance = 13 15 = 2 h` (2 hours behind).
---
## 7. Manual Balance Corrections
| Field | Type | Constraints |
|---|---|---|
| `date` | `YYYY-MM-DD` | Must be ≥ `startDate`; not more than one period in the future |
| `hours` | signed float | Positive = extra credit (reduces deficit). Negative = reduces tracked credit |
| `description` | string | Optional, max 255 chars |
- The system automatically assigns a correction to the period that contains its `date`.
- Corrections in **completed periods** are included in the completed period formula (§4).
- Corrections in the **ongoing period** are included in the ongoing balance formula (§6).
- Corrections in a **future period** (not yet started) are stored and will be applied when that period becomes active.
- A correction whose `date` is before `startDate` is rejected with a validation error.
---
## 8. Edge Cases
| Scenario | Behaviour |
|---|---|
| `startDate` = 1st of month / Monday | No pro-ration; `period_target_hours = weeklyOrMonthlyHours` |
| `startDate` = last day of period | `remaining_days = 1`; target is heavily reduced (e.g. 1/31 × hours) |
| Working pattern has no matches in the partial first period | `elapsed_working_days = 0`; `expected_hours = 0`; balance = `tracked + corrections` |
| Current period has zero elapsed working days | `expected_hours = 0`; balance = `tracked + corrections` (cannot divide by zero — guard required) |
| `working_days_in_period = 0` | Impossible by validation (at least one day required), but system must guard: treat as `daily_rate_hours = 0` |
| Today is not a working day | `elapsed_working_days` does not include today |
| Correction date before `startDate` | Rejected with a validation error |
| Correction date in future period | Accepted and stored; applied when that period is ongoing or completed |
| User changes working days or period type | Must create a new target with a new `startDate`; old target history is frozen |
| Two periods with the same client exist (old soft-deleted, new active) | Only the active target's periods contribute to the displayed balance |
| A month with only partial working day coverage (e.g. all Mondays are public holidays) | No automatic holiday handling; user adds manual corrections to compensate |
---
## 9. Data Model Changes
### `ClientTarget` table — additions / changes
| Column | Change | Notes |
|---|---|---|
| `period_type` | **Add** | Enum: `WEEKLY`, `MONTHLY` |
| `working_days` | **Add** | Array/bitmask of day names: `MON TUE WED THU FRI SAT SUN` |
| `start_date` | **Modify** | Remove "must be Monday" validation constraint |
| `weekly_hours` | **Rename** | → `target_hours` (represents hours per week or per month depending on `period_type`) |
### `BalanceCorrection` table — no structural changes
Date-to-period assignment is computed at query time, not stored.
---
## 10. API Changes
### `ClientTargetWithBalance` response shape
```typescript
interface ClientTargetWithBalance {
id: string
clientId: string
clientName: string
userId: string
periodType: "weekly" | "monthly"
targetHours: number // renamed from weeklyHours
workingDays: string[] // e.g. ["MON", "WED"]
startDate: string // YYYY-MM-DD
createdAt: string
updatedAt: string
corrections: BalanceCorrection[]
totalBalanceSeconds: number // running total across all periods
currentPeriodTrackedSeconds: number // replaces currentWeekTrackedSeconds
currentPeriodTargetSeconds: number // replaces currentWeekTargetSeconds
periods: PeriodBalance[] // replaces weeks[]
}
interface PeriodBalance {
periodStart: string // YYYY-MM-DD (Monday or 1st of month)
periodEnd: string // YYYY-MM-DD (Sunday or last of month)
targetHours: number // pro-rated for first period
trackedSeconds: number
correctionHours: number
balanceSeconds: number
isOngoing: boolean
// only present when isOngoing = true
dailyRateHours?: number
workingDaysInPeriod?: number
elapsedWorkingDays?: number
expectedHours?: number
}
```
### Endpoint changes
| Method | Path | Change |
|---|---|---|
| `POST /client-targets` | Create | Accepts `periodType`, `workingDays`, `targetHours`; `startDate` unconstrained |
| `PUT /client-targets/:id` | Update | Accepts same new fields |
| `GET /client-targets` | List | Returns updated `ClientTargetWithBalance` shape |
| `POST /client-targets/:id/corrections` | Add correction | No change to signature |
| `DELETE /client-targets/:id/corrections/:corrId` | Delete correction | No change |
### Zod schema changes
- `CreateClientTargetSchema` / `UpdateClientTargetSchema`:
- Add `periodType: z.enum(["weekly", "monthly"])`
- Add `workingDays: z.array(z.enum(["MON","TUE","WED","THU","FRI","SAT","SUN"])).min(1)`
- Rename `weeklyHours``targetHours`
- Remove Monday-only regex constraint from `startDate`
---
## 11. Frontend Changes
### Types (`frontend/src/types/index.ts`)
- `ClientTargetWithBalance` — add `periodType`, `workingDays`, `targetHours`; replace `weeks``periods: PeriodBalance[]`; replace `currentWeek*``currentPeriod*`
- Add `PeriodBalance` interface
- `CreateClientTargetInput` / `UpdateClientTargetInput` — same field additions
### Hook (`frontend/src/hooks/useClientTargets.ts`)
- No structural changes; mutations pass through new fields
### API client (`frontend/src/api/clientTargets.ts`)
- No structural changes; payload shapes updated
### `ClientsPage` — `ClientTargetPanel`
- Working day selector (checkboxes: MonSun, at least one required)
- Period type selector (Weekly / Monthly)
- Label for hours input updates dynamically: "Hours/week" or "Hours/month"
- Start date picker: free date input (no week-picker)
- Balance display: label changes from "this week" to "this week" or "this month" based on `periodType`
- Expanded period list replaces the expanded week list
### `DashboardPage`
- "Weekly Targets" widget renamed to "Targets"
- "This week" label becomes "This week" / "This month" dynamically
- `currentWeek*` fields replaced with `currentPeriod*`

View File

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

@@ -20,6 +20,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
return { return {
startTime: getLocalISOString(new Date(entry.startTime)), startTime: getLocalISOString(new Date(entry.startTime)),
endTime: getLocalISOString(new Date(entry.endTime)), endTime: getLocalISOString(new Date(entry.endTime)),
breakMinutes: entry.breakMinutes,
description: entry.description || '', description: entry.description || '',
projectId: entry.projectId, projectId: entry.projectId,
}; };
@@ -29,6 +30,7 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
return { return {
startTime: getLocalISOString(oneHourAgo), startTime: getLocalISOString(oneHourAgo),
endTime: getLocalISOString(now), endTime: getLocalISOString(now),
breakMinutes: 0,
description: '', description: '',
projectId: projects?.[0]?.id || '', projectId: projects?.[0]?.id || '',
}; };
@@ -97,6 +99,16 @@ export function TimeEntryFormModal({ entry, onClose, createTimeEntry, updateTime
/> />
</div> </div>
</div> </div>
<div>
<label className="label">Break (minutes)</label>
<input
type="number"
min="0"
value={formData.breakMinutes ?? 0}
onChange={(e) => setFormData({ ...formData, breakMinutes: parseInt(e.target.value) || 0 })}
className="input"
/>
</div>
<div> <div>
<label className="label">Description</label> <label className="label">Description</label>
<textarea <textarea

View File

@@ -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); return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0; }, 0) ?? 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? []; // Only add the running timer if it started today (not a timer left running from yesterday)
const timerStartedToday =
ongoingTimer !== null &&
new Date(ongoingTimer.startTime) >= startOfDay(today);
const totalTodaySeconds = completedTodaySeconds + (timerStartedToday ? elapsedSeconds : 0);
const targetsWithData = targets?.filter(t => t.periods.length > 0) ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,6 +87,7 @@ export function DashboardPage() {
label="Today" label="Today"
value={formatDurationHoursMinutes(totalTodaySeconds)} value={formatDurationHoursMinutes(totalTodaySeconds)}
color="blue" color="blue"
indicator={timerStartedToday}
/> />
<StatCard <StatCard
icon={Calendar} icon={Calendar}
@@ -108,7 +118,7 @@ export function DashboardPage() {
<div className="card"> <div className="card">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" /> <Target className="h-5 w-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2> <h2 className="text-lg font-semibold text-gray-900">Targets</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{targetsWithData.map(target => { {targetsWithData.map(target => {
@@ -116,8 +126,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance); const absBalance = Math.abs(balance);
const isOver = balance > 0; const isOver = balance > 0;
const isEven = balance === 0; const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds); const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds); const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'This week' : 'This month';
return ( return (
<div <div
@@ -127,10 +138,17 @@ export function DashboardPage() {
<div> <div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p> <p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget} {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="flex items-center justify-end gap-1.5">
{target.hasOngoingTimer && (
<span
className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse"
title="Timer running — balance updates every 30 s"
/>
)}
<p <p
className={`text-sm font-bold ${ className={`text-sm font-bold ${
isEven isEven
@@ -144,6 +162,7 @@ export function DashboardPage() {
? '±0' ? '±0'
: (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)} : (isOver ? '+' : '') + formatDurationHoursMinutes(absBalance)}
</p> </p>
</div>
<p className="text-xs text-gray-400">running balance</p> <p className="text-xs text-gray-400">running balance</p>
</div> </div>
</div> </div>
@@ -216,7 +235,10 @@ export function DashboardPage() {
<div className="text-xs text-gray-400">{formatTime(entry.startTime)} {formatTime(entry.endTime)}</div> <div className="text-xs text-gray-400">{formatTime(entry.startTime)} {formatTime(entry.endTime)}</div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono"> <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-mono">
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)} {formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
{entry.breakMinutes > 0 && (
<span className="text-xs text-gray-400 ml-1">({entry.breakMinutes}m break)</span>
)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-right"> <td className="px-4 py-3 whitespace-nowrap text-right">
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button> <button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>

View File

@@ -78,7 +78,10 @@ export function TimeEntriesPage() {
</div> </div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900"> <td className="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900">
{formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime)} {formatDurationFromDatesHoursMinutes(entry.startTime, entry.endTime, entry.breakMinutes)}
{entry.breakMinutes > 0 && (
<span className="text-xs text-gray-400 ml-1">({entry.breakMinutes}m)</span>
)}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-right"> <td className="px-4 py-3 whitespace-nowrap text-right">
<button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button> <button onClick={() => handleOpenModal(entry)} className="p-1.5 text-gray-400 hover:text-gray-600 mr-1"><Edit2 className="h-4 w-4" /></button>

View File

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

View File

@@ -43,7 +43,14 @@ export function formatDurationHoursMinutes(totalSeconds: number): string {
return `${hours}h ${minutes}m`; return `${hours}h ${minutes}m`;
} }
export function calculateDuration(startTime: string, endTime: string): number { export function calculateDuration(startTime: string, endTime: string, breakMinutes: number = 0): number {
const start = parseISO(startTime);
const end = parseISO(endTime);
const totalSeconds = differenceInSeconds(end, start);
return totalSeconds - (breakMinutes * 60);
}
export function calculateGrossDuration(startTime: string, endTime: string): number {
const start = parseISO(startTime); const start = parseISO(startTime);
const end = parseISO(endTime); const end = parseISO(endTime);
return differenceInSeconds(end, start); return differenceInSeconds(end, start);
@@ -52,16 +59,18 @@ export function calculateDuration(startTime: string, endTime: string): number {
export function formatDurationFromDates( export function formatDurationFromDates(
startTime: string, startTime: string,
endTime: string, endTime: string,
breakMinutes: number = 0,
): string { ): string {
const seconds = calculateDuration(startTime, endTime); const seconds = calculateDuration(startTime, endTime, breakMinutes);
return formatDuration(seconds); return formatDuration(seconds);
} }
export function formatDurationFromDatesHoursMinutes( export function formatDurationFromDatesHoursMinutes(
startTime: string, startTime: string,
endTime: string, endTime: string,
breakMinutes: number = 0,
): string { ): string {
const seconds = calculateDuration(startTime, endTime); const seconds = calculateDuration(startTime, endTime, breakMinutes);
return formatDurationHoursMinutes(seconds); return formatDurationHoursMinutes(seconds);
} }

View File

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