Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

* [PR-15](https://github.com/ITK-Leantime/data-api/pull/15)
* Added timesheetTotals endpoint returning logged hours grouped by day or week.
* Added workYear and workMonth filters to the timesheetTotals endpoint.

## [0.1.2] - 2026-03-06

* [PR-12](https://github.com/ITK-Leantime/data-api/pull/12)
Expand Down
50 changes: 50 additions & 0 deletions Controllers/API.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +50,55 @@ 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;

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

$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(
[
'groupBy' => $groupBy,
'from' => $from,
'to' => $to,
'projectIds' => $projectIds,
'workYear' => $workYear,
'workMonth' => $workMonth,
],
count($results),
$results,
))->toArray();
}

private function getDeleted(array $input): array
{
$types = $input['types'];
Expand Down
12 changes: 12 additions & 0 deletions Model/TimesheetTotalData.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Leantime\Plugins\APIData\Model;

readonly class TimesheetTotalData
{
public function __construct(
public string $period,
public float $hours,
public int $count,
) {}
}
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The API consists of the following endpoints:

* Get list of entities
* Get list of deleted entities
* Get timesheet hour totals

### Get list of entities

Expand Down Expand Up @@ -63,6 +64,36 @@ curl https://leantime.local.itkdev.dk/apidata/api/deleted
-d '{"deleted":1759906882,"types":["projects","milestones","tickets","timesheets"]}'
```

### Get timesheet hour totals

GET/POST: `https://{{YOUR_DOMAIN}}/apidata/api/timesheetTotals`

Returns the sum of logged hours grouped by day or week. Use it as a sanity check: sum the
synced hours per period yourself and compare to the totals to detect drift.

Attach query/body parameters to the request:

* groupBy: day (default) or week. Weeks are ISO-8601 (Monday based).
* 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.
* 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`
for days, `YYYY-Www` for weeks), the summed hours (rounded to 2 decimals) and the entry count.

Example request:

```shell
curl https://leantime.local.itkdev.dk/apidata/api/timesheetTotals
-H "x-api-key: lt_1234567890"
-H "Content-Type: application/json"
-d '{"groupBy":"week","workYear":2025,"workMonth":6,"projectIds":[12,13,14]}'
```

## API Key

To use the plugin you need an API key for leantime.
Expand Down
23 changes: 23 additions & 0 deletions Repositories/ApiDataRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,29 @@ 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 $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
? "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))
->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()
->toArray();
}

public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null): array
{
return $this->query()
Expand Down
16 changes: 16 additions & 0 deletions Services/APIData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(
Expand Down Expand Up @@ -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, ?string $workStart = null, ?string $workEnd = null): array
{
$values = $this->apiDataRepository->getTimesheetTotals($groupBy, $from, $to, $projectIds, $workStart, $workEnd);

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);
Expand Down
Loading