diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b7..2f3a233 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,6 +10,12 @@ ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], + // Time entry CRUD endpoints. + ['name' => 'time_entry#create', 'url' => '/api/time-entries', 'verb' => 'POST'], + ['name' => 'time_entry#index', 'url' => '/api/time-entries', 'verb' => 'GET'], + ['name' => 'time_entry#update', 'url' => '/api/time-entries/{id}', 'verb' => 'PUT'], + ['name' => 'time_entry#destroy', 'url' => '/api/time-entries/{id}', 'verb' => 'DELETE'], + // Prometheus metrics endpoint. ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], // Health check endpoint. diff --git a/lib/Controller/TimeEntryController.php b/lib/Controller/TimeEntryController.php new file mode 100644 index 0000000..dd1b183 --- /dev/null +++ b/lib/Controller/TimeEntryController.php @@ -0,0 +1,234 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Planix\Controller; + +use OCA\Planix\AppInfo\Application; +use OCA\Planix\Service\TimeEntryService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Controller for time entry CRUD operations. + */ +class TimeEntryController extends Controller +{ + /** + * Constructor for the TimeEntryController. + * + * @param IRequest $request The request object + * @param TimeEntryService $timeEntryService The time entry service + * + * @return void + */ + public function __construct( + IRequest $request, + private TimeEntryService $timeEntryService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Create a new time entry. + * + * Expects JSON body with: taskId, duration (minutes, > 0), date (ISO 8601), description (optional). + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function create(): JSONResponse + { + $userId = $this->timeEntryService->getCurrentUserId(); + if ($userId === null) { + return new JSONResponse( + ['error' => 'Authentication required.'], + Http::STATUS_FORBIDDEN + ); + } + + try { + $data = $this->request->getParams(); + $entry = $this->timeEntryService->createTimeEntry($data); + + return new JSONResponse($entry, Http::STATUS_CREATED); + } catch (\InvalidArgumentException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + }//end create() + + /** + * List time entries for a task. + * + * Expects query parameter: taskId. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse + */ + public function index(): JSONResponse + { + $userId = $this->timeEntryService->getCurrentUserId(); + if ($userId === null) { + return new JSONResponse( + ['error' => 'Authentication required.'], + Http::STATUS_FORBIDDEN + ); + } + + $taskId = $this->request->getParam('taskId', ''); + if (empty($taskId) === true) { + return new JSONResponse( + ['error' => 'taskId query parameter is required.'], + Http::STATUS_BAD_REQUEST + ); + } + + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $taskId) !== 1) { + return new JSONResponse( + ['error' => 'taskId must be a valid UUID.'], + Http::STATUS_BAD_REQUEST + ); + } + + try { + $entries = $this->timeEntryService->listTimeEntries($taskId); + + return new JSONResponse($entries); + } catch (\InvalidArgumentException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_NOT_FOUND + ); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_FORBIDDEN + ); + } + + }//end index() + + /** + * Update a time entry (owner only). + * + * Expects JSON body with any of: duration (minutes, > 0), date (ISO 8601), description. + * + * @param string $id The time entry UUID + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function update(string $id): JSONResponse + { + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) !== 1) { + return new JSONResponse( + ['error' => 'Invalid identifier format.'], + Http::STATUS_BAD_REQUEST + ); + } + + $userId = $this->timeEntryService->getCurrentUserId(); + if ($userId === null) { + return new JSONResponse( + ['error' => 'Authentication required.'], + Http::STATUS_FORBIDDEN + ); + } + + try { + $data = $this->request->getParams(); + $entry = $this->timeEntryService->updateTimeEntry($id, $data); + + return new JSONResponse($entry); + } catch (\InvalidArgumentException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_FORBIDDEN + ); + } + + }//end update() + + /** + * Delete a time entry (owner only). + * + * @param string $id The time entry UUID + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function destroy(string $id): JSONResponse + { + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id) !== 1) { + return new JSONResponse( + ['error' => 'Invalid identifier format.'], + Http::STATUS_BAD_REQUEST + ); + } + + $userId = $this->timeEntryService->getCurrentUserId(); + if ($userId === null) { + return new JSONResponse( + ['error' => 'Authentication required.'], + Http::STATUS_FORBIDDEN + ); + } + + try { + $this->timeEntryService->deleteTimeEntry($id); + + return new JSONResponse(['success' => true]); + } catch (\InvalidArgumentException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_NOT_FOUND + ); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['error' => $e->getMessage()], + Http::STATUS_FORBIDDEN + ); + } + + }//end destroy() +}//end class diff --git a/lib/Service/TimeEntryService.php b/lib/Service/TimeEntryService.php new file mode 100644 index 0000000..fcc7e94 --- /dev/null +++ b/lib/Service/TimeEntryService.php @@ -0,0 +1,440 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. +declare(strict_types=1); + +namespace OCA\Planix\Service; + +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for managing time entry CRUD operations via OpenRegister. + */ +class TimeEntryService +{ + + /** + * The OpenRegister register slug. + * + * @var string + */ + private const REGISTER = 'planix'; + + /** + * The OpenRegister schema slug for time entries. + * + * @var string + */ + private const SCHEMA = 'timeEntry'; + + /** + * The OpenRegister schema slug for tasks. + * + * @var string + */ + private const TASK_SCHEMA = 'task'; + + /** + * The OpenRegister schema slug for projects. + * + * @var string + */ + private const PROJECT_SCHEMA = 'project'; + + /** + * Regex pattern for UUID v4 format validation. + * + * @var string + */ + private const UUID_PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; + + /** + * Constructor for the TimeEntryService. + * + * @param ContainerInterface $container The container + * @param IUserSession $userSession The user session + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + private ContainerInterface $container, + private IUserSession $userSession, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the current user's UID. + * + * @return string|null + */ + public function getCurrentUserId(): ?string + { + $user = $this->userSession->getUser(); + return $user?->getUID(); + }//end getCurrentUserId() + + /** + * Get the OpenRegister ObjectService from the container. + * + * @return object The ObjectService instance + * + * @throws \RuntimeException When OpenRegister is not available. + */ + private function getObjectService(): object + { + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + $this->logger->error('Planix: OpenRegister ObjectService not available', ['exception' => $e->getMessage()]); + throw new \RuntimeException('OpenRegister is not available.'); + } + + }//end getObjectService() + + /** + * Find objects in OpenRegister by register, schema, and filters. + * + * @param string $schema The schema slug + * @param array $filters Query filters + * + * @return array> + */ + private function findObjects(string $schema, array $filters=[]): array + { + $objectService = $this->getObjectService(); + return $objectService->findObjects( + register: self::REGISTER, + schema: $schema, + filters: $filters, + ); + + }//end findObjects() + + /** + * Find a single object by ID. + * + * @param string $schema The schema slug + * @param string $id The object UUID + * + * @return array|null + */ + private function findObject(string $schema, string $id): ?array + { + $objectService = $this->getObjectService(); + $result = $objectService->findObject( + register: self::REGISTER, + schema: $schema, + id: $id, + ); + + if (is_array($result) === true && empty($result) === false) { + return $result; + } + + return null; + + }//end findObject() + + /** + * Save (create or update) an object in OpenRegister. + * + * @param string $schema The schema slug + * @param array $data The object data + * + * @return array + */ + private function saveObject(string $schema, array $data): array + { + $objectService = $this->getObjectService(); + return $objectService->saveObject( + register: self::REGISTER, + schema: $schema, + object: $data, + ); + + }//end saveObject() + + /** + * Delete an object from OpenRegister. + * + * @param string $schema The schema slug + * @param string $id The object UUID + * + * @return bool + */ + private function deleteObject(string $schema, string $id): bool + { + $objectService = $this->getObjectService(); + return $objectService->deleteObject( + register: self::REGISTER, + schema: $schema, + id: $id, + ); + + }//end deleteObject() + + /** + * Assert that the current user is a member of the project that owns the task. + * + * Denies access if the task has no associated project, if the project record + * cannot be found, or if the user is not listed in the project members array. + * Fails closed by design: when in doubt, deny. + * + * @param array $task The resolved task object + * @param string $userId The UID of the requesting user + * + * @return void + * + * @throws \RuntimeException When access is denied. + */ + private function assertProjectMembership(array $task, string $userId): void + { + $projectId = ($task['project'] ?? null); + if ($projectId === null) { + throw new \RuntimeException('Access denied: task is not associated with any project.'); + } + + $project = $this->findObject(schema: self::PROJECT_SCHEMA, id: $projectId); + if ($project === null) { + throw new \RuntimeException('Access denied: project not found.'); + } + + $members = ($project['members'] ?? []); + if (in_array($userId, $members, true) === false) { + throw new \RuntimeException('Access denied: you are not a member of this project.'); + } + + }//end assertProjectMembership() + + /** + * Create a new time entry. + * + * @param array $data Time entry data (taskId, duration, date, description) + * + * @return array The created time entry + * + * @throws \InvalidArgumentException When validation fails or the task does not exist. + * @throws \RuntimeException When the user is not a member of the task's project. + */ + public function createTimeEntry(array $data): array + { + $this->validateTimeEntryData(data: $data); + + $task = $this->findObject(schema: self::TASK_SCHEMA, id: $data['taskId']); + if ($task === null) { + throw new \InvalidArgumentException('Task not found.'); + } + + $userId = $this->getCurrentUserId(); + if ($userId === null) { + throw new \RuntimeException('Access denied: authentication required.'); + } + + $this->assertProjectMembership(task: $task, userId: $userId); + + $entry = $this->saveObject( + schema: self::SCHEMA, + data: [ + 'task' => $data['taskId'], + 'user' => $userId, + 'duration' => (int) $data['duration'], + 'date' => $data['date'], + 'description' => ($data['description'] ?? ''), + ] + ); + + return $entry; + + }//end createTimeEntry() + + /** + * List time entries for a given task. + * + * Enforces read access: the requesting user must be a member of the project + * that owns the task (IDOR guard — SEC-001/SEC-002/F-002). + * + * @param string $taskId The task UUID + * + * @return array> + * + * @throws \InvalidArgumentException When the task does not exist. + * @throws \RuntimeException When the current user is not a project member. + */ + public function listTimeEntries(string $taskId): array + { + $task = $this->findObject(schema: self::TASK_SCHEMA, id: $taskId); + if ($task === null) { + throw new \InvalidArgumentException('Task not found.'); + } + + $userId = $this->getCurrentUserId(); + if ($userId === null) { + throw new \RuntimeException('Access denied: authentication required.'); + } + + $this->assertProjectMembership(task: $task, userId: $userId); + + return $this->findObjects(schema: self::SCHEMA, filters: ['task' => $taskId]); + + }//end listTimeEntries() + + /** + * Update a time entry. Only the owner may edit. + * + * @param string $id The time entry UUID + * @param array $data Fields to update (duration, date, description) + * + * @return array The updated time entry + * + * @throws \InvalidArgumentException When the entry is not found or data is invalid. + * @throws \RuntimeException When the current user is not the owner. + */ + public function updateTimeEntry(string $id, array $data): array + { + $entry = $this->findObject(schema: self::SCHEMA, id: $id); + if ($entry === null) { + throw new \InvalidArgumentException('Time entry not found.'); + } + + $userId = $this->getCurrentUserId(); + if ($userId === null) { + throw new \RuntimeException('Access denied: authentication required.'); + } + + if (($entry['user'] ?? '') !== $userId) { + throw new \RuntimeException('Only the owner may edit a time entry.'); + } + + $this->validateUpdateData(data: $data); + + $merged = $entry; + if (isset($data['duration']) === true) { + $merged['duration'] = (int) $data['duration']; + } + + if (isset($data['date']) === true) { + $merged['date'] = $data['date']; + } + + if (array_key_exists('description', $data) === true) { + $merged['description'] = ($data['description'] ?? ''); + } + + return $this->saveObject(schema: self::SCHEMA, data: $merged); + + }//end updateTimeEntry() + + /** + * Delete a time entry. Only the owner may delete. + * + * @param string $id The time entry UUID + * + * @return bool + * + * @throws \InvalidArgumentException When the entry is not found. + * @throws \RuntimeException When the current user is not the owner. + */ + public function deleteTimeEntry(string $id): bool + { + $entry = $this->findObject(schema: self::SCHEMA, id: $id); + if ($entry === null) { + throw new \InvalidArgumentException('Time entry not found.'); + } + + $userId = $this->getCurrentUserId(); + if (($entry['user'] ?? '') !== $userId) { + throw new \RuntimeException('Only the owner may delete a time entry.'); + } + + return $this->deleteObject(schema: self::SCHEMA, id: $id); + + }//end deleteTimeEntry() + + /** + * Validate partial update data for a time entry. + * + * Only validates fields that are present in $data. + * + * @param array $data The update data + * + * @return void + * + * @throws \InvalidArgumentException When a provided field fails validation. + */ + private function validateUpdateData(array $data): void + { + if (isset($data['duration']) === true && (int) $data['duration'] <= 0) { + throw new \InvalidArgumentException('duration must be greater than 0.'); + } + + if (isset($data['date']) === true) { + if (empty($data['date']) === true) { + throw new \InvalidArgumentException('date is required.'); + } + + if (\DateTime::createFromFormat('Y-m-d', $data['date']) === false + || preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['date']) !== 1 + ) { + throw new \InvalidArgumentException('date must be a valid ISO 8601 date (YYYY-MM-DD).'); + } + } + + }//end validateUpdateData() + + /** + * Validate time entry input data. + * + * @param array $data The input data + * + * @return void + * + * @throws \InvalidArgumentException When validation fails. + */ + private function validateTimeEntryData(array $data): void + { + if (empty($data['taskId']) === true) { + throw new \InvalidArgumentException('taskId is required.'); + } + + if (preg_match(self::UUID_PATTERN, $data['taskId']) !== 1) { + throw new \InvalidArgumentException('taskId must be a valid UUID.'); + } + + if (isset($data['duration']) === false || (int) $data['duration'] <= 0) { + throw new \InvalidArgumentException('duration must be greater than 0.'); + } + + if (empty($data['date']) === true) { + throw new \InvalidArgumentException('date is required.'); + } + + if (\DateTime::createFromFormat('Y-m-d', $data['date']) === false + || preg_match('/^\d{4}-\d{2}-\d{2}$/', $data['date']) !== 1 + ) { + throw new \InvalidArgumentException('date must be a valid ISO 8601 date (YYYY-MM-DD).'); + } + + }//end validateTimeEntryData() +}//end class diff --git a/openspec/changes/spec/design.md b/openspec/changes/spec/design.md index fd6ae51..737305b 100644 --- a/openspec/changes/spec/design.md +++ b/openspec/changes/spec/design.md @@ -1,39 +1,28 @@ -# Admin Settings MVP +# Time Tracking MVP **Status**: pr-created -**Spec reference**: [admin-user-settings](../../specs/admin-user-settings.md) +**Spec reference**: [time-tracking](../../specs/time-tracking.md) **Priority**: MVP ## Summary -Implement the admin settings page for Planix. This is the first MVP feature that wires up -the backend settings API with a proper frontend admin page. The user settings dialog is -deferred to a follow-up change — this change focuses on the admin side only. +Add basic time tracking to Planix. Users can log time entries against tasks +and view a simple timesheet. No complex reporting — just CRUD for time entries +and a per-task time log display. -## Scope +## Scope (MVP only — 2 tasks) -- Admin settings page under Nextcloud Administration → Planix -- CnVersionInfoCard as the first section -- Default columns configuration (editable ordered list) -- OpenRegister initialization status and trigger button -- Backend: SettingsController with read/write endpoints via IAppConfig -- Frontend: AdminRoot.vue with CnVersionInfoCard + CnSettingsSection components +- Backend: TimeEntry service + controller (CRUD via OpenRegister) +- Frontend: Time log section on task detail + simple "Log time" form ## Out of scope -- User settings dialog (NcAppSettingsDialog) — separate change -- Procest bridge settings — V1, separate change -- Notification preferences — separate change +- Timesheet views, burndown charts, CSV export (V1) +- Timer/stopwatch functionality (V1) +- Approval workflows (V1) ## Architecture -- Backend: `SettingsController` exposes `GET /api/settings` and `POST /api/settings` -- Settings stored via `OCP\IAppConfig` (server-side, admin-only) -- Frontend: Vue 2 + `@conduction/nextcloud-vue` components -- No new database tables or OpenRegister entities - -## Risks - -- CnVersionInfoCard and CnSettingsSection require `@conduction/nextcloud-vue` — verify the - dependency is declared in package.json -- OpenRegister initialization depends on OpenRegister being installed and enabled +- TimeEntry stored as OpenRegister objects (schema already defined in register) +- Backend: TimeEntryController with standard CRUD endpoints +- Frontend: TimeLog.vue component embedded in task detail view diff --git a/openspec/changes/spec/specs/time-tracking.md b/openspec/changes/spec/specs/time-tracking.md new file mode 100644 index 0000000..da22230 --- /dev/null +++ b/openspec/changes/spec/specs/time-tracking.md @@ -0,0 +1,121 @@ +# Time Tracking Specification + +**Status**: implemented + +**Standards**: Schema.org QuantitativeValue, iCalendar ESTIMATED-DURATION (RFC 7986), OpenProject spentTime model +**Feature tier**: MVP + +**OpenSpec changes:** _(links to openspec/changes/ directories when in-progress or done)_ + +## Purpose + +Time tracking in Planix allows team members to estimate task effort and log actual time spent. This enables project capacity planning, billing, and retrospective analysis. Each task carries an estimate (minutes); actual time is logged as separate TimeEntry objects — multiple per task, one per work session. Users view their logged time in a personal timesheet. Project leads view aggregated time reports (V1). Time tracking is intentionally simple in MVP: manual entry only (no live timer). + +## Data Model + +See [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for full TimeEntry entity definition. + +**TimeEntry summary**: + +| Property | Type | Required | Default | +|----------|------|----------|---------| +| `task` | reference (Task) | Yes | — | +| `user` | string (user UID) | Yes | current user | +| `duration` | integer (minutes) | Yes | — | +| `date` | date | Yes | today | +| `description` | string | No | — | + +**Task time fields** (on the Task entity): + +| Property | Type | Description | +|----------|------|-------------| +| `estimatedDuration` | integer (minutes) | Planned effort | +| `(computed) loggedDuration` | integer (minutes) | Sum of all TimeEntry.duration for this task | + +## Requirements + +### Requirement: Time Estimate [MVP] +The system MUST allow users to set a time estimate on a task. + +#### Scenario: Set estimate on task +- GIVEN a user is editing a task detail +- WHEN the user enters an estimate (e.g., "2h 30m" or "150 minutes") +- THEN the system MUST store `estimatedDuration` in minutes on the task +- AND the task card on the kanban board MUST display the estimate (e.g., "2h 30m") + +### Requirement: Log Time [MVP] +The system MUST allow users to log time spent on a task. + +#### Scenario: Log a time entry +- GIVEN a user is viewing a task detail +- WHEN the user clicks "Log time" and enters duration, date, and optional description +- THEN the system MUST create a TimeEntry object linked to the task +- AND the task MUST display the total logged time (sum of all entries) +- AND the logged time MUST appear in the user's timesheet + +#### Scenario: Multiple time entries per task +- GIVEN a user has already logged 1h on a task on Monday +- WHEN the user logs another 45 minutes on Tuesday +- THEN the system MUST create a second TimeEntry for Tuesday +- AND the task MUST show total logged time of 1h 45m + +#### Scenario: Edit a time entry +- GIVEN a user has a time entry on a task +- WHEN the user edits the entry's duration or description +- THEN the system MUST update the entry +- AND the task's total logged time MUST recalculate immediately + +#### Scenario: Delete a time entry +- GIVEN a user has a time entry +- WHEN the user deletes it +- THEN the system MUST remove the entry +- AND the task's total logged time MUST recalculate + +### Requirement: Personal Timesheet [MVP] +The system MUST provide a timesheet view showing the current user's time entries grouped by date. + +#### Scenario: View my timesheet +- GIVEN a user has logged time on multiple tasks +- WHEN the user opens the Timesheet view +- THEN the system MUST show all entries grouped by date (newest first) +- AND each row MUST show: task title, project, duration, description +- AND a daily total MUST be shown for each date group +- AND a weekly total MUST be shown for the current view + +#### Scenario: Filter timesheet by date range +- GIVEN the timesheet is open +- WHEN the user selects a date range (e.g., "This week" or a custom range) +- THEN the system MUST filter entries to the selected range +- AND the total for the range MUST be displayed + +## User Stories + +- As a developer, I want to log the time I spent on a task so that the team has accurate capacity data +- As a team member, I want to see my logged time in a weekly timesheet so that I can review my work patterns +- As a project lead, I want to see estimated vs actual time per task so that I can improve future estimates +- As a user, I want to add multiple time logs per task so that I can track time across multiple work sessions +- As a team member, I want to see my total hours for the week so that I can track my workload + +## Acceptance Criteria + +- [ ] Time estimate can be set on any task (input accepts "1h 30m", "90m", "1.5h" formats) +- [ ] Estimated duration is stored in minutes and displayed in human-readable format on task card and detail +- [x] "Log time" button is accessible from the task detail view +- [x] A time entry requires at minimum a duration and a date +- [x] Multiple time entries can be added to the same task +- [x] Total logged time is computed from all entries and displayed on the task +- [ ] Logged time vs estimated time shows a progress indicator (e.g., "1h 30m / 3h") +- [ ] Timesheet view shows all entries by the current user grouped by date +- [ ] Timesheet shows daily totals and weekly total +- [ ] Timesheet can be filtered by date range +- [x] Users can edit and delete their own time entries +- [ ] Admins (V1) can view and export all users' time entries per project + +## Notes + +- Time granularity is minutes (integer). Avoids floating-point precision issues. +- Timer (start/stop, auto-log) is a V1 feature. MVP is manual entry only. +- Time export (CSV) is V1. Includes: task title, project, user, date, duration, description. +- Project-level time report (V1): total estimated vs logged per task, aggregated per project. +- Team timesheet (V1): admin view of all users' entries, exportable. +- The `loggedDuration` field on Task is computed at read time (sum of linked TimeEntry objects), not stored. diff --git a/openspec/changes/spec/tasks.md b/openspec/changes/spec/tasks.md index db8f61b..6086229 100644 --- a/openspec/changes/spec/tasks.md +++ b/openspec/changes/spec/tasks.md @@ -1,48 +1,21 @@ -# Tasks — Admin Settings MVP +# Tasks — Time Tracking MVP -## Task 1: Backend — SettingsController admin endpoints -**spec_ref**: admin-user-settings.md → Requirement: Admin Settings Page -**files_likely_affected**: lib/Controller/SettingsController.php, appinfo/routes.php +## Task 1: Backend — TimeEntry CRUD endpoints +**spec_ref**: time-tracking.md → Requirement: Time Entry Logging +**files_likely_affected**: lib/Controller/TimeEntryController.php (new), lib/Service/TimeEntryService.php (new), appinfo/routes.php **acceptance_criteria**: -- [x] `GET /api/settings` returns current admin settings as JSON -- [x] `POST /api/settings` accepts a JSON body and stores values via IAppConfig -- [x] Settings include: `default_columns` (JSON string), `allow_project_creation` (string) -- [x] Only admin users can write settings (middleware or annotation check) -- [x] Returns 403 for non-admin write attempts +- [x] `POST /api/time-entries` creates a time entry (taskId, hours, date, description) +- [x] `GET /api/time-entries?taskId={id}` lists time entries for a task +- [x] `DELETE /api/time-entries/{id}` deletes a time entry (owner only) +- [x] Validation: hours > 0, date required, taskId must exist +- [x] Returns 403 for non-authenticated users -## Task 2: Backend — SettingsService business logic -**spec_ref**: admin-user-settings.md → Data Model -**files_likely_affected**: lib/Service/SettingsService.php +## Task 2: Frontend — Time log component on task detail +**spec_ref**: time-tracking.md → Scenario: View time log +**files_likely_affected**: src/components/TimeLog.vue (new), src/views/TaskDetail.vue **acceptance_criteria**: -- [x] `getAdminSettings()` reads all planix admin keys from IAppConfig with defaults -- [x] `setAdminSettings(array $settings)` validates and stores each key -- [x] Default values match the spec: `default_columns = ["To Do","In Progress","Review","Done"]` -- [x] Unknown keys are silently ignored (no error, no storage) - -## Task 3: Frontend — AdminRoot with CnVersionInfoCard -**spec_ref**: admin-user-settings.md → Scenario: View admin settings -**files_likely_affected**: src/views/settings/AdminRoot.vue, src/views/settings/Settings.vue -**acceptance_criteria**: -- [x] Admin settings page renders under Nextcloud Administration → Planix -- [x] First section is CnVersionInfoCard showing app name and version -- [x] Page uses CnSettingsSection for each logical group -- [x] Loads current settings from `GET /api/settings` on mount - -## Task 4: Frontend — Default columns editor -**spec_ref**: admin-user-settings.md → Scenario: Configure default columns -**files_likely_affected**: src/views/settings/Settings.vue or new component -**acceptance_criteria**: -- [x] Shows current default columns as an editable ordered list -- [x] Admin can add, remove, and reorder column names -- [x] Changes are saved via `POST /api/settings` on save button click -- [x] Shows success/error feedback after save - -## Task 5: Frontend — OpenRegister initialization section -**spec_ref**: admin-user-settings.md → Scenario: OpenRegister initialization -**files_likely_affected**: src/views/settings/Settings.vue or new component -**acceptance_criteria**: -- [x] Shows whether the Planix register is initialized (green check / warning) -- [x] If not initialized, shows "Initialize register" button -- [x] Button triggers register initialization (calls backend endpoint) -- [x] Shows loading state during initialization -- [x] Shows success or error result after completion +- [x] TimeLog.vue shows list of time entries for the current task +- [x] Each entry shows: date, hours, description, delete button (if owner) +- [x] "Log time" form with hours input, date picker, description field +- [x] Form validates hours > 0 before submission +- [x] Empty state: "No time logged yet" with prompt to log first entry diff --git a/src/components/TimeLog.vue b/src/components/TimeLog.vue new file mode 100644 index 0000000..245efae --- /dev/null +++ b/src/components/TimeLog.vue @@ -0,0 +1,424 @@ + + + + + + + diff --git a/src/router/index.js b/src/router/index.js index a0352f8..62ee895 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -27,6 +27,11 @@ export default new Router({ name: 'ProjectBacklog', component: () => import('../views/ProjectBacklog.vue'), }, + { + path: '/tasks/:taskId', + name: 'TaskDetail', + component: () => import('../views/TaskDetail.vue'), + }, { path: '*', redirect: '/' }, ], }) diff --git a/src/store/timeEntries.js b/src/store/timeEntries.js new file mode 100644 index 0000000..456be84 --- /dev/null +++ b/src/store/timeEntries.js @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: EUPL-1.2 +// Copyright (C) 2026 Conduction B.V. + +/** + * Time Entries Pinia store. + * + * Calls the TimeEntryController REST endpoints so that server-side validation, + * task-existence checks, and owner-only delete enforcement are always exercised. + */ +import { defineStore } from 'pinia' +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +const API_BASE = generateUrl('/apps/planix/api/time-entries') + +export const useTimeEntriesStore = defineStore('timeEntries', { + state: () => ({ + /** @type {Array} */ entries: [], + /** @type {boolean} */ loading: false, + /** @type {string|null} */ error: null, + }), + + getters: { + /** + * Total logged duration in minutes for the current entries. + * + * @param {object} state Store state + * @return {number} + */ + totalDuration(state) { + return state.entries.reduce((sum, e) => sum + (e.duration || 0), 0) + }, + }, + + actions: { + /** + * Fetch all time entries for a given task via GET /api/time-entries?taskId=. + * + * @param {string} taskId Task UUID + * @return {Promise} + */ + async fetchEntries(taskId) { + this.loading = true + this.error = null + try { + const response = await axios.get(API_BASE, { params: { taskId } }) + this.entries = response.data || [] + return this.entries + } catch (err) { + this.error = err.message || 'fetch-error' + console.error('fetchTimeEntries error:', err) + return [] + } finally { + this.loading = false + } + }, + + /** + * Create a new time entry via POST /api/time-entries. + * + * The server sets the `user` field — the client does not supply it. + * + * @param {object} data Time entry data { task, duration, date, description } + * @return {Promise} + */ + async createEntry(data) { + this.loading = true + this.error = null + try { + const response = await axios.post(API_BASE, data) + const entry = response.data + this.entries = [...this.entries, entry] + return entry + } catch (err) { + this.error = err.message || 'create-error' + showError(t('planix', 'Failed to create time entry')) + return null + } finally { + this.loading = false + } + }, + + /** + * Update a time entry via PUT /api/time-entries/{id}. + * + * The server enforces owner-only access. + * + * @param {string} id Time entry UUID + * @param {object} data Fields to update { duration, date, description } + * @return {Promise} + */ + async updateEntry(id, data) { + this.loading = true + this.error = null + try { + const response = await axios.put(`${API_BASE}/${id}`, data) + const updated = response.data + this.entries = this.entries.map((e) => e.id === id ? updated : e) + return updated + } catch (err) { + this.error = err.message || 'update-error' + showError(t('planix', 'Failed to update time entry')) + return null + } finally { + this.loading = false + } + }, + + /** + * Delete a time entry by ID via DELETE /api/time-entries/{id}. + * + * The server enforces owner-only access. + * + * @param {string} id Time entry UUID + * @return {Promise} + */ + async deleteEntry(id) { + this.loading = true + this.error = null + try { + await axios.delete(`${API_BASE}/${id}`) + this.entries = this.entries.filter((e) => e.id !== id) + return true + } catch (err) { + this.error = err.message || 'delete-error' + showError(t('planix', 'Failed to delete time entry')) + return false + } finally { + this.loading = false + } + }, + }, +}) diff --git a/src/views/TaskDetail.vue b/src/views/TaskDetail.vue new file mode 100644 index 0000000..50b0a5e --- /dev/null +++ b/src/views/TaskDetail.vue @@ -0,0 +1,190 @@ + + + + + + + diff --git a/tests/unit/Controller/TimeEntryControllerTest.php b/tests/unit/Controller/TimeEntryControllerTest.php new file mode 100644 index 0000000..b498e96 --- /dev/null +++ b/tests/unit/Controller/TimeEntryControllerTest.php @@ -0,0 +1,343 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Planix\Tests\Unit\Controller; + +use OCA\Planix\Controller\TimeEntryController; +use OCA\Planix\Service\TimeEntryService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Tests for TimeEntryController. + */ +class TimeEntryControllerTest extends TestCase +{ + + /** + * The controller under test. + * + * @var TimeEntryController + */ + private TimeEntryController $controller; + + /** + * Mock IRequest. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock TimeEntryService. + * + * @var TimeEntryService&MockObject + */ + private TimeEntryService&MockObject $timeEntryService; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->timeEntryService = $this->createMock(originalClassName: TimeEntryService::class); + + $this->controller = new TimeEntryController( + request: $this->request, + timeEntryService: $this->timeEntryService, + ); + + }//end setUp() + + /** + * Test that create() returns 403 when no user is authenticated. + * + * @return void + */ + public function testCreateReturnsForbiddenForUnauthenticatedUser(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn(null); + + $this->timeEntryService->expects($this->never()) + ->method('createTimeEntry'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testCreateReturnsForbiddenForUnauthenticatedUser() + + /** + * Test that create() returns 201 with the created entry on success. + * + * @return void + */ + public function testCreateReturnsCreatedEntryOnSuccess(): void + { + $params = [ + 'taskId' => 'task-uuid-1', + 'duration' => 60, + 'date' => '2026-04-01', + 'description' => 'Worked on feature', + ]; + + $created = array_merge($params, ['id' => 'entry-uuid-1', 'user' => 'testuser']); + + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn('testuser'); + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn($params); + + $this->timeEntryService->expects($this->once()) + ->method('createTimeEntry') + ->with($params) + ->willReturn($created); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_CREATED, actual: $result->getStatus()); + self::assertSame(expected: 'entry-uuid-1', actual: $result->getData()['id']); + + }//end testCreateReturnsCreatedEntryOnSuccess() + + /** + * Test that create() returns 400 when validation fails. + * + * @return void + */ + public function testCreateReturnsBadRequestOnValidationError(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn('testuser'); + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['taskId' => '', 'duration' => 0]); + + $this->timeEntryService->expects($this->once()) + ->method('createTimeEntry') + ->willThrowException(new \InvalidArgumentException('taskId is required.')); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + self::assertSame(expected: 'taskId is required.', actual: $result->getData()['error']); + + }//end testCreateReturnsBadRequestOnValidationError() + + /** + * Test that index() returns 403 when no user is authenticated. + * + * @return void + */ + public function testIndexReturnsForbiddenForUnauthenticatedUser(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn(null); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testIndexReturnsForbiddenForUnauthenticatedUser() + + /** + * Test that index() returns 400 when taskId is missing. + * + * @return void + */ + public function testIndexReturnsBadRequestWithoutTaskId(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn('testuser'); + + $this->request->expects($this->once()) + ->method('getParam') + ->with('taskId', '') + ->willReturn(''); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + + }//end testIndexReturnsBadRequestWithoutTaskId() + + /** + * Test that index() returns time entries for a task. + * + * @return void + */ + public function testIndexReturnsEntriesForTask(): void + { + $taskId = 'task-uuid-1'; + $entries = [ + ['id' => 'e1', 'task' => $taskId, 'duration' => 30, 'date' => '2026-04-01'], + ['id' => 'e2', 'task' => $taskId, 'duration' => 45, 'date' => '2026-04-02'], + ]; + + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn('testuser'); + + $this->request->expects($this->once()) + ->method('getParam') + ->with('taskId', '') + ->willReturn($taskId); + + $this->timeEntryService->expects($this->once()) + ->method('listTimeEntries') + ->with($taskId) + ->willReturn($entries); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: 200, actual: $result->getStatus()); + self::assertCount(expectedCount: 2, haystack: $result->getData()); + + }//end testIndexReturnsEntriesForTask() + + /** + * Test that destroy() returns 403 when no user is authenticated. + * + * @return void + */ + public function testDestroyReturnsForbiddenForUnauthenticatedUser(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn(null); + + $result = $this->controller->destroy('c0000000-0000-4000-a000-000000000003'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testDestroyReturnsForbiddenForUnauthenticatedUser() + + /** + * Test that destroy() returns success when owner deletes their entry. + * + * @return void + */ + public function testDestroyReturnsSuccessForOwner(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn('testuser'); + + $this->timeEntryService->expects($this->once()) + ->method('deleteTimeEntry') + ->with('d0000000-0000-4000-a000-000000000004') + ->willReturn(true); + + $result = $this->controller->destroy('d0000000-0000-4000-a000-000000000004'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: 200, actual: $result->getStatus()); + self::assertTrue(condition: $result->getData()['success']); + + }//end testDestroyReturnsSuccessForOwner() + + /** + * Test that destroy() returns 403 when non-owner tries to delete. + * + * @return void + */ + public function testDestroyReturnsForbiddenForNonOwner(): void + { + $this->timeEntryService->expects($this->once()) + ->method('getCurrentUserId') + ->willReturn('otheruser'); + + $this->timeEntryService->expects($this->once()) + ->method('deleteTimeEntry') + ->with('a0000000-0000-4000-a000-000000000001') + ->willThrowException(new \RuntimeException('Only the owner may delete a time entry.')); + + $result = $this->controller->destroy('a0000000-0000-4000-a000-000000000001'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testDestroyReturnsForbiddenForNonOwner() + + /** + * Test that destroy() returns 404 when the entry does not exist. + * + * @return void + */ + public function testDestroyReturnsNotFoundWhenEntryDoesNotExist(): void + { + $this->timeEntryService->expects($this->once()) + ->method(constraint: 'getCurrentUserId') + ->willReturn(value: 'testuser'); + + $this->timeEntryService->expects($this->once()) + ->method(constraint: 'deleteTimeEntry') + ->with(self::identicalTo(value: 'b0000000-0000-4000-a000-000000000002')) + ->willThrowException(new \InvalidArgumentException('Time entry not found.')); + + $result = $this->controller->destroy('b0000000-0000-4000-a000-000000000002'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + self::assertSame(expected: 'Time entry not found.', actual: $result->getData()['error']); + + }//end testDestroyReturnsNotFoundWhenEntryDoesNotExist() + + /** + * Test that destroy() returns 400 when the id is not a valid UUID. + * + * @return void + */ + public function testDestroyReturnsBadRequestForInvalidUuid(): void + { + $this->timeEntryService->expects($this->never()) + ->method('getCurrentUserId'); + + $result = $this->controller->destroy('not-a-uuid'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + + }//end testDestroyReturnsBadRequestForInvalidUuid() +}//end class diff --git a/tests/unit/Service/TimeEntryServiceTest.php b/tests/unit/Service/TimeEntryServiceTest.php new file mode 100644 index 0000000..2746964 --- /dev/null +++ b/tests/unit/Service/TimeEntryServiceTest.php @@ -0,0 +1,231 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Planix\Tests\Unit\Service; + +use OCA\Planix\Service\TimeEntryService; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Tests for TimeEntryService. + */ +class TimeEntryServiceTest extends TestCase +{ + + /** + * The service under test. + * + * @var TimeEntryService + */ + private TimeEntryService $service; + + /** + * Mock ContainerInterface. + * + * @var ContainerInterface&MockObject + */ + private ContainerInterface&MockObject $container; + + /** + * Mock IUserSession. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + + /** + * Mock LoggerInterface. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->container = $this->createMock(originalClassName: ContainerInterface::class); + $this->userSession = $this->createMock(originalClassName: IUserSession::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->service = new TimeEntryService( + container: $this->container, + userSession: $this->userSession, + logger: $this->logger, + ); + + }//end setUp() + + /** + * Test getCurrentUserId() returns UID when user is logged in. + * + * @return void + */ + public function testGetCurrentUserIdReturnsUidWhenLoggedIn(): void + { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser')->willReturn($user); + + self::assertSame(expected: 'testuser', actual: $this->service->getCurrentUserId()); + + }//end testGetCurrentUserIdReturnsUidWhenLoggedIn() + + /** + * Test getCurrentUserId() returns null when no user is logged in. + * + * @return void + */ + public function testGetCurrentUserIdReturnsNullWithoutUser(): void + { + $this->userSession->method('getUser')->willReturn(null); + + self::assertNull(actual: $this->service->getCurrentUserId()); + + }//end testGetCurrentUserIdReturnsNullWithoutUser() + + /** + * Test createTimeEntry() throws when taskId is missing. + * + * @return void + */ + public function testCreateTimeEntryThrowsWhenTaskIdMissing(): void + { + $this->expectException(exception: \InvalidArgumentException::class); + $this->expectExceptionMessage(message: 'taskId is required.'); + + $this->service->createTimeEntry( + [ + 'duration' => 60, + 'date' => '2026-04-01', + ] + ); + + }//end testCreateTimeEntryThrowsWhenTaskIdMissing() + + /** + * Test createTimeEntry() throws when taskId is not a valid UUID. + * + * @return void + */ + public function testCreateTimeEntryThrowsWhenTaskIdInvalidUuid(): void + { + $this->expectException(exception: \InvalidArgumentException::class); + $this->expectExceptionMessage(message: 'taskId must be a valid UUID.'); + + $this->service->createTimeEntry( + [ + 'taskId' => 'not-a-uuid', + 'duration' => 60, + 'date' => '2026-04-01', + ] + ); + + }//end testCreateTimeEntryThrowsWhenTaskIdInvalidUuid() + + /** + * Test createTimeEntry() throws when duration is zero. + * + * @return void + */ + public function testCreateTimeEntryThrowsWhenDurationIsZero(): void + { + $this->expectException(exception: \InvalidArgumentException::class); + $this->expectExceptionMessage(message: 'duration must be greater than 0.'); + + $this->service->createTimeEntry( + [ + 'taskId' => '00000000-0000-4000-a000-000000000001', + 'duration' => 0, + 'date' => '2026-04-01', + ] + ); + + }//end testCreateTimeEntryThrowsWhenDurationIsZero() + + /** + * Test createTimeEntry() throws when duration is negative. + * + * @return void + */ + public function testCreateTimeEntryThrowsWhenDurationIsNegative(): void + { + $this->expectException(exception: \InvalidArgumentException::class); + $this->expectExceptionMessage(message: 'duration must be greater than 0.'); + + $this->service->createTimeEntry( + [ + 'taskId' => '00000000-0000-4000-a000-000000000001', + 'duration' => -5, + 'date' => '2026-04-01', + ] + ); + + }//end testCreateTimeEntryThrowsWhenDurationIsNegative() + + /** + * Test createTimeEntry() throws when date is missing. + * + * @return void + */ + public function testCreateTimeEntryThrowsWhenDateMissing(): void + { + $this->expectException(exception: \InvalidArgumentException::class); + $this->expectExceptionMessage(message: 'date is required.'); + + $this->service->createTimeEntry( + [ + 'taskId' => '00000000-0000-4000-a000-000000000001', + 'duration' => 60, + ] + ); + + }//end testCreateTimeEntryThrowsWhenDateMissing() + + /** + * Test createTimeEntry() throws when date is not a valid ISO 8601 date. + * + * @return void + */ + public function testCreateTimeEntryThrowsWhenDateInvalidFormat(): void + { + $this->expectException(exception: \InvalidArgumentException::class); + $this->expectExceptionMessage(message: 'date must be a valid ISO 8601 date (YYYY-MM-DD).'); + + $this->service->createTimeEntry( + [ + 'taskId' => '00000000-0000-4000-a000-000000000001', + 'duration' => 60, + 'date' => 'not-a-date', + ] + ); + + }//end testCreateTimeEntryThrowsWhenDateInvalidFormat() +}//end class