From 4d0d541f0bf5f2783298d5b0b1c1c8dbc145c557 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 30 Jun 2026 22:21:42 +0200 Subject: [PATCH 1/4] feat: add timesheetTotals endpoint for hours by day or week New /apidata/api/timesheetTotals endpoint returns SUM(hours) grouped by day or week (groupBy), filterable by workDate range (from/to) and projectIds. It applies the same filters as the timesheets endpoint so consumers can sum their synced hours per period and reconcile against the source to detect drift. --- CHANGELOG.md | 3 +++ Controllers/API.php | 28 ++++++++++++++++++++++++++++ Model/TimesheetTotalData.php | 12 ++++++++++++ README.md | 28 ++++++++++++++++++++++++++++ Repositories/ApiDataRepository.php | 21 +++++++++++++++++++++ Services/APIData.php | 16 ++++++++++++++++ 6 files changed, 108 insertions(+) create mode 100644 Model/TimesheetTotalData.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bfa34e..c7122c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +* [PR-XX](https://github.com/ITK-Leantime/data-api/pull/XX) + * Added timesheetTotals endpoint returning logged hours grouped by day or week. + ## [0.1.2] - 2026-03-06 * [PR-12](https://github.com/ITK-Leantime/data-api/pull/12) diff --git a/Controllers/API.php b/Controllers/API.php index 548c1e6..2ec8628 100644 --- a/Controllers/API.php +++ b/Controllers/API.php @@ -49,6 +49,34 @@ public function workers(array $input): JsonResponse return new JsonResponse($this->getResults($input, APIData::TYPE_WORKERS)); } + public function timesheetTotals(array $input): JsonResponse + { + return new JsonResponse($this->getTimesheetTotals($input)); + } + + private function getTimesheetTotals(array $input): array + { + $groupBy = ($input['groupBy'] ?? null) === APIData::GROUP_BY_WEEK + ? APIData::GROUP_BY_WEEK + : APIData::GROUP_BY_DAY; + $from = isset($input['from']) ? (int) $input['from'] : null; + $to = isset($input['to']) ? (int) $input['to'] : null; + $projectIds = $input['projectIds'] ?? null; + + $results = $this->dataAPIService->getTimesheetTotals($groupBy, $from, $to, $projectIds); + + return (new ResponseData( + [ + 'groupBy' => $groupBy, + 'from' => $from, + 'to' => $to, + 'projectIds' => $projectIds, + ], + count($results), + $results, + ))->toArray(); + } + private function getDeleted(array $input): array { $types = $input['types']; diff --git a/Model/TimesheetTotalData.php b/Model/TimesheetTotalData.php new file mode 100644 index 0000000..2981237 --- /dev/null +++ b/Model/TimesheetTotalData.php @@ -0,0 +1,12 @@ +toArray(); } + public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null): array + { + // ISO-8601 week (%x-W%v, Monday based) so consumers can reproduce the bucket. + $periodExpr = $groupBy === APIData::GROUP_BY_WEEK + ? "DATE_FORMAT(timesheet.workDate, '%x-W%v')" + : "DATE_FORMAT(timesheet.workDate, '%Y-%m-%d')"; + + return $this->query() + ->from("zp_timesheets", "timesheet") + ->selectRaw("$periodExpr as period, SUM(timesheet.hours) as hours, COUNT(*) as count") + ->whereNotNull("timesheet.hours") + ->leftJoin('zp_tickets as ticket', "ticket.id", "=", "timesheet.ticketId") + ->when($from !== null, fn ($query) => $query->where("timesheet.workDate", ">=", CarbonImmutable::createFromTimestamp($from)->format(APIData::DATE_FORMAT))) + ->when($to !== null, fn ($query) => $query->where("timesheet.workDate", "<=", CarbonImmutable::createFromTimestamp($to)->format(APIData::DATE_FORMAT))) + ->when($projectIds != null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) + ->groupByRaw($periodExpr) + ->orderBy("period", "ASC") + ->get() + ->toArray(); + } + public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null): array { return $this->query() diff --git a/Services/APIData.php b/Services/APIData.php index 8929f87..9767f01 100644 --- a/Services/APIData.php +++ b/Services/APIData.php @@ -10,6 +10,7 @@ use Leantime\Plugins\APIData\Model\ProjectData; use Leantime\Plugins\APIData\Model\TicketData; use Leantime\Plugins\APIData\Model\TimesheetData; +use Leantime\Plugins\APIData\Model\TimesheetTotalData; use Leantime\Plugins\APIData\Model\WorkerData; use Leantime\Plugins\APIData\Repositories\ApiDataRepository; @@ -20,6 +21,8 @@ class APIData public const TYPE_TICKETS = 'tickets'; public const TYPE_TIMESHEETS = 'timesheets'; public const TYPE_WORKERS = 'users'; + public const GROUP_BY_DAY = 'day'; + public const GROUP_BY_WEEK = 'week'; public const DATE_FORMAT = 'Y-m-d H:i:s'; public function __construct( @@ -182,6 +185,19 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu }, $values); } + public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null): array + { + $values = $this->apiDataRepository->getTimesheetTotals($groupBy, $from, $to, $projectIds); + + return array_map(function ($value) { + return new TimesheetTotalData( + (string) $value->period, + round((float) $value->hours, 2), + (int) $value->count, + ); + }, $values); + } + public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array { $values = $this->apiDataRepository->getWorkers($startId, $limit, $modifiedAfter, $ids, $projectIds); From 141189676c932b2623290ea550e02c50c2276ad6 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 30 Jun 2026 22:22:19 +0200 Subject: [PATCH 2/4] docs: fill in changelog PR number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7122c0..25f4fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -* [PR-XX](https://github.com/ITK-Leantime/data-api/pull/XX) +* [PR-15](https://github.com/ITK-Leantime/data-api/pull/15) * Added timesheetTotals endpoint returning logged hours grouped by day or week. ## [0.1.2] - 2026-03-06 From 9db648dbb0f6d649b5fc359de4f8413859ec9ed1 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 30 Jun 2026 22:31:24 +0200 Subject: [PATCH 3/4] feat: add workDate (year / year-month) filter to timesheetTotals Accepts workDate as "2026" or "2026-06" and matches timesheet.workDate by prefix, so consumers can scope totals to a year or month without computing unix timestamps. Invalid values are ignored. --- CHANGELOG.md | 1 + Controllers/API.php | 7 ++++++- README.md | 4 +++- Repositories/ApiDataRepository.php | 3 ++- Services/APIData.php | 4 ++-- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f4fbc..12af86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * [PR-15](https://github.com/ITK-Leantime/data-api/pull/15) * Added timesheetTotals endpoint returning logged hours grouped by day or week. + * Added workDate filter (year or year-month) to the timesheetTotals endpoint. ## [0.1.2] - 2026-03-06 diff --git a/Controllers/API.php b/Controllers/API.php index 2ec8628..e59ee6e 100644 --- a/Controllers/API.php +++ b/Controllers/API.php @@ -62,8 +62,12 @@ private function getTimesheetTotals(array $input): array $from = isset($input['from']) ? (int) $input['from'] : null; $to = isset($input['to']) ? (int) $input['to'] : null; $projectIds = $input['projectIds'] ?? null; + // Accept a year ("2026") or year-month ("2026-06") to filter on workDate. + $workDate = isset($input['workDate']) && preg_match('/^\d{4}(-\d{2})?$/', (string) $input['workDate']) + ? (string) $input['workDate'] + : null; - $results = $this->dataAPIService->getTimesheetTotals($groupBy, $from, $to, $projectIds); + $results = $this->dataAPIService->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workDate); return (new ResponseData( [ @@ -71,6 +75,7 @@ private function getTimesheetTotals(array $input): array 'from' => $from, 'to' => $to, 'projectIds' => $projectIds, + 'workDate' => $workDate, ], count($results), $results, diff --git a/README.md b/README.md index 3202f6a..f3d15ef 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Attach query/body parameters to the request: * from: Unix timestamp. Only include entries with a workDate later than or equal to from. * to: Unix timestamp. Only include entries with a workDate earlier than or equal to to. * projectIds: Array of projectIds. Limits the totals to entities attached to projects in projectIds. +* workDate: A year (`2026`) or year-month (`2026-06`). Only include entries whose workDate + falls in that year or month. Invalid values are ignored. The same filters as the timesheets endpoint are applied (entries without hours are excluded), so the totals reconcile against the synced timesheets. Each result has a period (`YYYY-MM-DD` @@ -88,7 +90,7 @@ Example request: curl https://leantime.local.itkdev.dk/apidata/api/timesheetTotals -H "x-api-key: lt_1234567890" -H "Content-Type: application/json" - -d '{"groupBy":"week","from":1700000000,"projectIds":[12,13,14]}' + -d '{"groupBy":"week","workDate":"2026","projectIds":[12,13,14]}' ``` ## API Key diff --git a/Repositories/ApiDataRepository.php b/Repositories/ApiDataRepository.php index 4fd49d2..8397951 100644 --- a/Repositories/ApiDataRepository.php +++ b/Repositories/ApiDataRepository.php @@ -79,7 +79,7 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu ->toArray(); } - public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null): array + public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null, ?string $workDate = null): array { // ISO-8601 week (%x-W%v, Monday based) so consumers can reproduce the bucket. $periodExpr = $groupBy === APIData::GROUP_BY_WEEK @@ -94,6 +94,7 @@ public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to ->when($from !== null, fn ($query) => $query->where("timesheet.workDate", ">=", CarbonImmutable::createFromTimestamp($from)->format(APIData::DATE_FORMAT))) ->when($to !== null, fn ($query) => $query->where("timesheet.workDate", "<=", CarbonImmutable::createFromTimestamp($to)->format(APIData::DATE_FORMAT))) ->when($projectIds != null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) + ->when($workDate !== null, fn ($query) => $query->where("timesheet.workDate", "like", $workDate . '%')) ->groupByRaw($periodExpr) ->orderBy("period", "ASC") ->get() diff --git a/Services/APIData.php b/Services/APIData.php index 9767f01..5d43427 100644 --- a/Services/APIData.php +++ b/Services/APIData.php @@ -185,9 +185,9 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu }, $values); } - public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null): array + public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null, ?string $workDate = null): array { - $values = $this->apiDataRepository->getTimesheetTotals($groupBy, $from, $to, $projectIds); + $values = $this->apiDataRepository->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workDate); return array_map(function ($value) { return new TimesheetTotalData( From c7f964c591fce38b99819c7f5ce5542701d191b8 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 30 Jun 2026 22:42:52 +0200 Subject: [PATCH 4/4] feat: replace workDate filter with workYear/workMonth range Filter timesheetTotals by workYear and/or workMonth, resolved to a half-open workDate range (>= start AND < end). When only workMonth is given the current year is assumed. The range form avoids wrapping workDate in a function and is index-friendly if a workDate index is ever added. --- CHANGELOG.md | 2 +- Controllers/API.php | 27 ++++++++++++++++++++++----- README.md | 7 ++++--- Repositories/ApiDataRepository.php | 5 +++-- Services/APIData.php | 4 ++-- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12af86f..65bb4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ * [PR-15](https://github.com/ITK-Leantime/data-api/pull/15) * Added timesheetTotals endpoint returning logged hours grouped by day or week. - * Added workDate filter (year or year-month) to the timesheetTotals endpoint. + * Added workYear and workMonth filters to the timesheetTotals endpoint. ## [0.1.2] - 2026-03-06 diff --git a/Controllers/API.php b/Controllers/API.php index e59ee6e..b2508a4 100644 --- a/Controllers/API.php +++ b/Controllers/API.php @@ -2,6 +2,7 @@ namespace Leantime\Plugins\APIData\Controllers; +use Carbon\CarbonImmutable; use Leantime\Core\Controller\Controller; use Leantime\Plugins\APIData\Model\ResponseData; use Leantime\Plugins\APIData\Services\APIData; @@ -62,12 +63,27 @@ private function getTimesheetTotals(array $input): array $from = isset($input['from']) ? (int) $input['from'] : null; $to = isset($input['to']) ? (int) $input['to'] : null; $projectIds = $input['projectIds'] ?? null; - // Accept a year ("2026") or year-month ("2026-06") to filter on workDate. - $workDate = isset($input['workDate']) && preg_match('/^\d{4}(-\d{2})?$/', (string) $input['workDate']) - ? (string) $input['workDate'] + + // workYear/workMonth select a year or month on workDate as a half-open range. + // If only workMonth is given, the current year is assumed. + $workYear = isset($input['workYear']) && preg_match('/^\d{4}$/', (string) $input['workYear']) + ? (int) $input['workYear'] + : null; + $workMonth = isset($input['workMonth']) && is_numeric($input['workMonth']) && (int) $input['workMonth'] >= 1 && (int) $input['workMonth'] <= 12 + ? (int) $input['workMonth'] : null; - $results = $this->dataAPIService->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workDate); + $workStart = null; + $workEnd = null; + if ($workYear !== null || $workMonth !== null) { + $workYear ??= (int) CarbonImmutable::now()->format('Y'); + $start = CarbonImmutable::create($workYear, $workMonth ?? 1, 1, 0, 0, 0); + $end = $workMonth !== null ? $start->addMonth() : $start->addYear(); + $workStart = $start->format(APIData::DATE_FORMAT); + $workEnd = $end->format(APIData::DATE_FORMAT); + } + + $results = $this->dataAPIService->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workStart, $workEnd); return (new ResponseData( [ @@ -75,7 +91,8 @@ private function getTimesheetTotals(array $input): array 'from' => $from, 'to' => $to, 'projectIds' => $projectIds, - 'workDate' => $workDate, + 'workYear' => $workYear, + 'workMonth' => $workMonth, ], count($results), $results, diff --git a/README.md b/README.md index f3d15ef..ce40a32 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,9 @@ Attach query/body parameters to the request: * from: Unix timestamp. Only include entries with a workDate later than or equal to from. * to: Unix timestamp. Only include entries with a workDate earlier than or equal to to. * projectIds: Array of projectIds. Limits the totals to entities attached to projects in projectIds. -* workDate: A year (`2026`) or year-month (`2026-06`). Only include entries whose workDate - falls in that year or month. Invalid values are ignored. +* workYear: A year (e.g. `2025`). Only include entries whose workDate falls in that year. +* workMonth: A month, 1-12 (e.g. `06`). Only include entries whose workDate falls in that + month. If workMonth is given without workYear, the current year is assumed. The same filters as the timesheets endpoint are applied (entries without hours are excluded), so the totals reconcile against the synced timesheets. Each result has a period (`YYYY-MM-DD` @@ -90,7 +91,7 @@ Example request: curl https://leantime.local.itkdev.dk/apidata/api/timesheetTotals -H "x-api-key: lt_1234567890" -H "Content-Type: application/json" - -d '{"groupBy":"week","workDate":"2026","projectIds":[12,13,14]}' + -d '{"groupBy":"week","workYear":2025,"workMonth":6,"projectIds":[12,13,14]}' ``` ## API Key diff --git a/Repositories/ApiDataRepository.php b/Repositories/ApiDataRepository.php index 8397951..9812ce7 100644 --- a/Repositories/ApiDataRepository.php +++ b/Repositories/ApiDataRepository.php @@ -79,7 +79,7 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu ->toArray(); } - public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null, ?string $workDate = null): array + public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null, ?string $workStart = null, ?string $workEnd = null): array { // ISO-8601 week (%x-W%v, Monday based) so consumers can reproduce the bucket. $periodExpr = $groupBy === APIData::GROUP_BY_WEEK @@ -94,7 +94,8 @@ public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to ->when($from !== null, fn ($query) => $query->where("timesheet.workDate", ">=", CarbonImmutable::createFromTimestamp($from)->format(APIData::DATE_FORMAT))) ->when($to !== null, fn ($query) => $query->where("timesheet.workDate", "<=", CarbonImmutable::createFromTimestamp($to)->format(APIData::DATE_FORMAT))) ->when($projectIds != null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) - ->when($workDate !== null, fn ($query) => $query->where("timesheet.workDate", "like", $workDate . '%')) + ->when($workStart !== null, fn ($query) => $query->where("timesheet.workDate", ">=", $workStart)) + ->when($workEnd !== null, fn ($query) => $query->where("timesheet.workDate", "<", $workEnd)) ->groupByRaw($periodExpr) ->orderBy("period", "ASC") ->get() diff --git a/Services/APIData.php b/Services/APIData.php index 5d43427..9b92199 100644 --- a/Services/APIData.php +++ b/Services/APIData.php @@ -185,9 +185,9 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu }, $values); } - public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null, ?string $workDate = null): array + public function getTimesheetTotals(string $groupBy, ?int $from = null, ?int $to = null, ?array $projectIds = null, ?string $workStart = null, ?string $workEnd = null): array { - $values = $this->apiDataRepository->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workDate); + $values = $this->apiDataRepository->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workStart, $workEnd); return array_map(function ($value) { return new TimesheetTotalData(