Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
b0e036b953 Bump the major-updates group across 1 directory with 15 updates
Bumps the major-updates group with 15 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@floating-ui/vue](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/vue) | `1.1.11` | `2.0.1` |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.38` |
| [@tanstack/vue-table](https://github.com/TanStack/table/tree/HEAD/packages/vue-table) | `8.21.3` | `9.1.2` |
| [pinia](https://github.com/vuejs/pinia) | `3.0.4` | `4.0.2` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge) | `2.6.1` | `3.6.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.23` | `3.6.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.19` | `26.2.0` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.1.3` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `16.1.1` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.1` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.3.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `7.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.3` | `8.2.1` |
| [vite-plugin-dts](https://github.com/qmhc/unplugin-dts/tree/HEAD/packages/vite-plugin-dts) | `4.5.4` | `5.0.3` |



Updates `@floating-ui/vue` from 1.1.11 to 2.0.1
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/vue/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/vue@2.0.1/packages/vue)

Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.38
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.38/packages/vue-query-devtools)

Updates `@tanstack/vue-table` from 8.21.3 to 9.1.2
- [Release notes](https://github.com/TanStack/table/releases)
- [Changelog](https://github.com/TanStack/table/blob/main/packages/vue-table/CHANGELOG.md)
- [Commits](https://github.com/TanStack/table/commits/@tanstack/vue-table@9.1.2/packages/vue-table)

Updates `pinia` from 3.0.4 to 4.0.2
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/v3.0.4...v4.0.2)

Updates `tailwind-merge` from 2.6.1 to 3.6.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/compare/v2.6.1...v3.6.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.23 to 3.6.1
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.6.1/packages/vue3)

Updates `@types/node` from 22.19.19 to 26.2.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `laravel-vite-plugin` from 2.1.0 to 3.1.3
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.1.3)

Updates `postcss-import` from 15.1.0 to 16.1.1
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...16.1.1)

Updates `postcss-nesting` from 12.1.5 to 14.0.1
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

Updates `vite` from 7.3.3 to 8.2.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite)

Updates `vite-plugin-dts` from 4.5.4 to 5.0.3
- [Release notes](https://github.com/qmhc/unplugin-dts/releases)
- [Changelog](https://github.com/qmhc/unplugin-dts/blob/main/packages/vite-plugin-dts/CHANGELOG.md)
- [Commits](https://github.com/qmhc/unplugin-dts/commits/vite-plugin-dts@5.0.3/packages/vite-plugin-dts)

---
updated-dependencies:
- dependency-name: "@floating-ui/vue"
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.38
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@tanstack/vue-table"
  dependency-version: 9.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: pinia
  dependency-version: 4.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.6.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.1.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 16.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.2.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite-plugin-dts
  dependency-version: 5.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 08:25:15 +00:00
21 changed files with 1885 additions and 3274 deletions

View File

@@ -535,7 +535,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path,
config('filesystems.private'),
$format->getExportPackageType(),

View File

@@ -8,14 +8,12 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
class LocalizationService
{
@@ -154,30 +152,6 @@ class LocalizationService
return $date->format($this->dateFormat->toCarbonFormat());
}
/**
* Time group types have no server-side descriptor; Day and Week keys are ISO dates and
* formatted here instead. A Week key is the first day of that week, so it renders as the
* range it covers.
*/
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
{
if ($key === null) {
return null;
}
if ($groupType === TimeEntryAggregationType::Day) {
return $this->formatDate(Carbon::parse($key));
}
if ($groupType === TimeEntryAggregationType::Week) {
$weekStart = Carbon::parse($key);
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
}
return $key;
}
public function setDateFormat(DateFormat $dateFormat): void
{
$this->dateFormat = $dateFormat;

View File

@@ -6,7 +6,6 @@ namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Service\LocalizationService;
use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView;
@@ -49,8 +48,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private bool $showBillableRate;
private LocalizationService $localization;
/**
* @param array{
* grouped_type: string|null,
@@ -71,7 +68,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null
* } $data
*/
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization)
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
{
$this->data = $data;
$this->exportFormat = $exportFormat;
@@ -79,7 +76,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
$this->group = $group;
$this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate;
$this->localization = $localization;
}
public function view(): View
@@ -91,7 +87,6 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate,
'localization' => $this->localization,
]);
}

View File

@@ -10,7 +10,6 @@ import {
createTimeEntryWithTagViaApi,
createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi,
createTimeEntryOnDateViaApi,
createPublicProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
@@ -842,127 +841,6 @@ test('test that setting group by to current sub group triggers sub group fallbac
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
});
test('test that group by date groups the report by day and formats the date labels with organization settings', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by date',
duration: '1h',
});
// Go to reporting page
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
// Find the "Group by" selects within the reporting table
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
// Default state: group=Project
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
// Verify the API request contains the correct group parameter
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('day');
// The row label is rendered in the organization date format (D.M.YYYY)
await expect(
page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)
).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week requests week grouping and does not leak the raw group key', async ({
page,
ctx,
}) => {
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by week',
duration: '1h',
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('week');
// The raw group key is the first day of the week and must not leak through.
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week labels a week spanning new year with a range crossing the year', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'slash-separated-dd-mm-yyyy' });
for (const day of ['2025-12-22', '2025-12-29', '2026-01-05']) {
await createTimeEntryOnDateViaApi(ctx, {
date: new Date(`${day}T09:00:00Z`),
duration: '1h',
description: `Entry for ${day}`,
});
}
// The reporting page keeps its range in session storage, so seed a range spanning new year
// rather than driving the date picker.
await page.addInitScript(() => {
window.sessionStorage.setItem('reporting-start-date', '2025-12-15');
window.sessionStorage.setItem('reporting-end-date', '2026-01-15');
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const reportingView = page.getByTestId('reporting_view');
await expect(reportingView.getByText('22/12/2025 - 28/12/2025', { exact: true })).toBeVisible();
await expect(reportingView.getByText('29/12/2025 - 04/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText('05/01/2026 - 11/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Export Tests
// ──────────────────────────────────────────────────

View File

@@ -16,7 +16,6 @@ import {
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import {
goToReporting,
@@ -69,42 +68,6 @@ test('test that saving a report creates a shared report and its shareable link s
await expect(page.getByText('Total')).toBeVisible();
});
test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({
page,
ctx,
}) => {
const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000);
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for date grouping',
duration: '1h',
});
await goToReporting(page);
// Switch the grouping to "Date"
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Verify row labels are formatted correctly
await page.goto(shareableLink);
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible();
await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
test('test that shared report with invalid secret shows no data', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible();

4374
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,13 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@inertiajs/vue3": "^2.3.23",
"@eslint/js": "^10.0.1",
"@inertiajs/vue3": "^3.6.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.19.19",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
@@ -40,14 +40,14 @@
"axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0",
"laravel-vite-plugin": "^3.1.3",
"openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"postcss-import": "^16.1.1",
"postcss-nesting": "^14.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.1",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4",
"vue": "^3.5.34",
@@ -55,15 +55,15 @@
},
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11",
"@floating-ui/vue": "^2.0.1",
"@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.14.0",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-query-devtools": "^6.1.38",
"@tanstack/vue-table": "^9.1.2",
"@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
@@ -77,10 +77,10 @@
"echarts": "^6.0.0",
"focus-trap": "^8.2.0",
"parse-duration": "^2.1.6",
"pinia": "^3.0.4",
"pinia": "^4.0.2",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.1",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1",

View File

@@ -240,8 +240,7 @@ const groupedPieChartData = computed(() => {
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
aggregatedTableTimeEntries.value?.grouped_type ?? null
);
let color = getRandomColorWithSeed(entry.key ?? 'none');
if (
@@ -256,7 +255,11 @@ const groupedPieChartData = computed(() => {
}
return {
value: entry.seconds,
name: name ?? '',
name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
) ?? '',
color: color,
};
}) ?? []
@@ -266,25 +269,18 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
aggregatedTableTimeEntries.value?.grouped_type ?? null
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type,
organization?.value?.date_format
),
description: getNameForReportingRowEntry(el.key, entry.grouped_type),
};
}) ?? [],
};
@@ -425,8 +421,9 @@ const tableData = computed(() => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.key ?? 'none'"
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:show-cost="showBillableRate"
:entry="entry"></ReportingRow>
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">

View File

@@ -11,7 +11,6 @@ type AggregatedGroupedData = GroupedData & {
};
type GroupedData = {
key: string | null;
seconds: number;
cost: number | null;
description: string | null | undefined;
@@ -73,7 +72,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
<ReportingRow
v-for="subEntry in entry.grouped_data"
:key="subEntry.key ?? 'none'"
:key="subEntry.description ?? 'none'"
:currency="props.currency"
:show-cost="showCost"
indent

View File

@@ -95,24 +95,20 @@ const tableData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description: getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null,
organization?.value?.date_format
aggregatedTableTimeEntries.value?.grouped_type ?? null
),
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description: getNameForReportingRowEntry(
el.key,
entry.grouped_type ?? null,
organization?.value?.date_format
entry.grouped_type ?? null
),
};
}) ?? [],
@@ -168,7 +164,7 @@ const showBillableRate = computed(() => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.key ?? 'none'"
:key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()"
:show-cost="showBillableRate"
:entry="entry"></ReportingRow>

View File

@@ -116,43 +116,25 @@ const subGroup = computed(() => {
}
return 'project';
});
const { emptyPlaceholder, getNameForReportingRowEntry } = useReportingStore();
/**
* The public report endpoint has no descriptor for time group types, so their labels are
* derived from the raw group key.
*/
function resolveLabel(
description: string | null | undefined,
key: string | null | undefined,
groupedType: string
) {
if (description !== null && description !== undefined) {
return description;
}
return (
getNameForReportingRowEntry(key ?? null, groupedType, reportDateFormat.value) ??
emptyPlaceholder[groupedType] ??
''
);
}
const { emptyPlaceholder } = useReportingStore();
const groupedPieChartData = computed(() => {
return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const groupedType = aggregatedTableTimeEntries.value?.grouped_type ?? 'project';
const name = resolveLabel(entry.description, entry.key, groupedType);
if (name === emptyPlaceholder[groupedType]) {
if (entry.description === null) {
return {
value: entry.seconds,
name: name,
name:
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
] ?? '',
color: '#CCCCCC',
};
}
return {
value: entry.seconds,
name: name,
color: entry.color ?? getRandomColorWithSeed(name),
name: entry.description,
color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'),
};
}) ?? []
);
@@ -161,25 +143,21 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return {
key: entry.key,
seconds: entry.seconds,
cost: entry.cost,
description: resolveLabel(
entry.description,
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
),
description:
entry.description ??
emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ??
'',
grouped_data:
entry.grouped_data?.map((el) => {
return {
key: el.key,
seconds: el.seconds,
cost: el.cost,
description: resolveLabel(
el.description,
el.key,
entry.grouped_type ?? 'project'
),
description:
el.description ??
emptyPlaceholder[entry.grouped_type ?? 'project'] ??
'',
};
}) ?? [],
};
@@ -241,7 +219,7 @@ onMounted(async () => {
">
<ReportingRow
v-for="entry in tableData"
:key="entry.key ?? 'none'"
:key="entry.description ?? 'none'"
:currency="reportCurrency"
:currency-format="reportCurrencyFormat"
:show-cost="true"

View File

@@ -29,7 +29,7 @@
"author": "solidtime",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.5.4"
"vite-plugin-dts": "^5.0.3"
},
"peerDependencies": {
"@zodios/core": "^10.9.6",

View File

@@ -50,7 +50,7 @@
"devDependencies": {
"@types/chroma-js": "^3.1.2",
"@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^5.0.3",
"zod": "^3.25.76"
},
"peerDependencies": {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { formatHumanReadableDuration, formatReportingDuration, formatWeekRange } from './time';
import { formatHumanReadableDuration, formatReportingDuration } from './time';
const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s
@@ -42,21 +42,3 @@ describe('formatReportingDuration', () => {
);
});
});
describe('formatWeekRange', () => {
test('renders the six days following the given first day of the week', () => {
expect(formatWeekRange('2026-07-27', 'slash-separated-dd-mm-yyyy')).toBe(
'27/07/2026 - 02/08/2026'
);
});
test('spans a month boundary', () => {
expect(formatWeekRange('2026-07-27')).toBe('27.7.2026 - 2.8.2026');
});
test('spans a year boundary', () => {
expect(formatWeekRange('2025-12-29', 'slash-separated-dd-mm-yyyy')).toBe(
'29/12/2025 - 04/01/2026'
);
});
});

View File

@@ -236,15 +236,6 @@ export function formatWeek(date: string | null): string {
return 'Week ' + getDayJsInstance()(date).week();
}
/*
* Returns the range covered by the week starting on the given day.
* @param date - first day of a week, in the format of 'YYYY-MM-DD'
*/
export function formatWeekRange(date: string, format?: DateFormat): string {
const end = getDayJsInstance()(date).add(6, 'day').format('YYYY-MM-DD');
return `${formatDate(date, format)} - ${formatDate(end, format)}`;
}
/*
* Returns a human readable date format.
* @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -7,15 +7,9 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import {
CalendarDaysIcon,
CalendarIcon,
DocumentTextIcon,
FolderIcon,
} from '@heroicons/vue/16/solid';
import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
import { Coffee } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { type DateFormat, formatDate, formatWeekRange } from '@/packages/ui/src/utils/time';
export type GroupingOption =
| 'project'
@@ -25,9 +19,7 @@ export type GroupingOption =
| 'client'
| 'description'
| 'tag'
| 'type'
| 'day'
| 'week';
| 'type';
export const useReportingStore = defineStore('reporting', () => {
// Cache query composables to avoid creating new subscriptions on every call
@@ -48,11 +40,7 @@ export const useReportingStore = defineStore('reporting', () => {
type: 'Work time',
} as Record<string, string>;
function getNameForReportingRowEntry(
key: string | null,
type: string | null,
dateFormat?: DateFormat
) {
function getNameForReportingRowEntry(key: string | null, type: string | null) {
if (type === null) {
return null;
}
@@ -88,12 +76,6 @@ export const useReportingStore = defineStore('reporting', () => {
if (type === 'type') {
return key === 'break' ? 'Break' : 'Work time';
}
if (type === 'day') {
return formatDate(key, dateFormat);
}
if (type === 'week') {
return formatWeekRange(key, dateFormat);
}
return key;
}
@@ -142,16 +124,6 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'tag',
icon: DocumentTextIcon,
},
{
label: 'Date',
value: 'day',
icon: CalendarIcon,
},
{
label: 'Week',
value: 'week',
icon: CalendarDaysIcon,
},
];
return {

View File

@@ -194,7 +194,7 @@
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif
</span>
</td>
@@ -239,7 +239,7 @@
<span style="color: #a1a1aa;">
{{ $group->description() }}:
</span>
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }}
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif
</h2>
@@ -278,7 +278,7 @@
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
@endif
</td>
<td>
@@ -318,7 +318,7 @@
series: [
{
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group, $localization): object {
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
$color = $data['color'];
if ($color === null) {
$color = $colorService->getRandomColor($data['key']);
@@ -328,7 +328,7 @@
}
return (object)[
'value' => $data['seconds'],
'name' => $data['description'] ?? $localization->formatTimeGroupKey($data['key'], $group) ?? 'No '.Str::lower($group->description()),
'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
'color' => $color,
'itemStyle' => (object) [
'color' => $color,

View File

@@ -44,7 +44,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
</td>
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
@@ -53,7 +53,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@@ -74,7 +74,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }}
{{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
</td>
@endif
@if ($subGroup === TimeEntryAggregationType::Billable)
@@ -83,7 +83,7 @@
</td>
@else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }}
{{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td>
@endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\DateFormat;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TagMatchType;
@@ -17,7 +16,6 @@ use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
@@ -1643,212 +1641,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_date(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_pdf_renders_date_group_labels_in_organization_date_format(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'debug' => 'true',
]));
// Assert
$this->assertResponseCode($response, 200);
$html = $response->json('html');
$this->assertIsString($html);
$this->assertStringContainsString('14/03/2024', $html);
$this->assertStringNotContainsString('2024-03-14', $html);
}
public function test_aggregate_export_csv_renders_date_group_labels_in_organization_date_format(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$disk = Storage::disk(config('filesystems.private'));
$files = $disk->files('exports');
$this->assertCount(1, $files);
$csv = $disk->get($files[0]);
$this->assertIsString($csv);
$this->assertStringContainsString('14/03/2024', $csv);
$this->assertStringNotContainsString('2024-03-14', $csv);
}
/**
* @return array{0: Organization, 1: Member, 2: User}
*/
private function createWeeksSpanningNewYear(): array
{
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
// Note: the user factory pins the week start to Monday, so these land in predictable buckets
foreach (['2025-12-22', '2025-12-29', '2026-01-05'] as $day) {
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 3600)->create();
}
return [$data->organization, $data->member, $data->user];
}
public function test_aggregate_export_csv_labels_a_week_group_with_its_date_range(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$disk = Storage::disk(config('filesystems.private'));
$files = $disk->files('exports');
$this->assertCount(1, $files);
$csv = $disk->get($files[0]);
$this->assertIsString($csv);
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $csv);
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $csv);
$this->assertStringContainsString('05/01/2026 - 11/01/2026', $csv);
$this->assertStringNotContainsString('2025-12-29', $csv);
$this->assertStringNotContainsString('2025-12-22', $csv);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_week_spanning_new_year(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: a week 1 label carries a comma and reaches the echarts series in a <script> tag
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_pdf_labels_a_week_group_with_its_date_range(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
'debug' => 'true',
]));
// Assert
$this->assertResponseCode($response, 200);
$html = $response->json('html');
$this->assertIsString($html);
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $html);
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $html);
$this->assertStringNotContainsString('2025-12-29', $html);
}
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
{
// Arrange

View File

@@ -8,7 +8,6 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Service\LocalizationService;
use Brick\Money\Currency;
@@ -304,74 +303,4 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Assert
$this->assertSame('14:09', $formatted);
}
public function test_format_time_group_key_formats_a_day_key_with_the_date_format(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Day);
// Assert
$this->assertSame('03/02/2001', $formatted);
}
public function test_format_time_group_key_returns_null_for_a_null_key(): void
{
// Act
$formatted = $this->localizationService->formatTimeGroupKey(null, TimeEntryAggregationType::Day);
// Assert
$this->assertNull($formatted);
}
public function test_format_time_group_key_formats_a_week_key_as_the_range_it_covers(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2026-07-27', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('27/07/2026 - 02/08/2026', $formatted);
}
public function test_format_time_group_key_formats_a_week_range_from_the_weekday_of_its_own_key(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$sundayStart = $this->localizationService->formatTimeGroupKey('2026-07-26', TimeEntryAggregationType::Week);
$mondayStart = $this->localizationService->formatTimeGroupKey('2026-07-20', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('26/07/2026 - 01/08/2026', $sundayStart);
$this->assertSame('20/07/2026 - 26/07/2026', $mondayStart);
}
public function test_format_time_group_key_formats_a_week_range_spanning_new_year(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2025-12-29', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('29/12/2025 - 04/01/2026', $formatted);
}
public function test_format_time_group_key_does_not_change_month_year_and_entity_group_types(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act & Assert
$this->assertSame('2001-02', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year));
$this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project));
}
}

View File

@@ -1302,111 +1302,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
],
], $result);
}
public function test_aggregate_time_entries_by_week_keys_each_group_by_the_first_day_of_the_week(): void
{
// Arrange
// 2026-07-20 is a Monday, 2026-07-26 is the Sunday of that week.
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame([
'seconds' => 30,
'cost' => 0,
'grouped_type' => 'week',
'grouped_data' => [
[
'key' => '2026-07-20',
'seconds' => 20,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => '2026-07-27',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
public function test_aggregate_time_entries_by_week_aligns_the_group_key_to_the_organization_week_start(): void
{
// Arrange
// With a Sunday week start the Sunday entry belongs to the following week instead.
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Sunday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame(['2026-07-19', '2026-07-26'], array_column($result['grouped_data'], 'key'));
$this->assertSame([10, 20], array_column($result['grouped_data'], 'seconds'));
}
public function test_aggregate_time_entries_by_week_keys_a_week_spanning_new_year_by_its_start_in_the_earlier_year(): void
{
// Arrange
// Note: 2025-12-29 is the Monday of the week containing 1 January 2026
foreach (['2025-12-29', '2025-12-31', '2026-01-01', '2026-01-05'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame(['2025-12-29', '2026-01-05'], array_column($result['grouped_data'], 'key'));
// Three of the four entries fall in the week that straddles new year.
$this->assertSame([30, 10], array_column($result['grouped_data'], 'seconds'));
}
}