mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 05:43:18 +01:00
Compare commits
3 Commits
feature/er
...
feature/ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bddd49364 | ||
|
|
ab5dbfef36 | ||
|
|
2eb9ae699c |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -18,7 +18,6 @@ bufferhead-code
|
||||
candideu
|
||||
kasparrosin
|
||||
korridor
|
||||
nikbucher tasks table sorting
|
||||
onatcer
|
||||
shrootbuck
|
||||
smilebeda
|
||||
|
||||
@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
|
||||
}
|
||||
|
||||
return $request->user()
|
||||
? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
|
||||
: Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
|
||||
? Limit::perMinute(200)->by($request->user()->id)
|
||||
: Limit::perMinute(60)->by($request->ip());
|
||||
});
|
||||
|
||||
$this->routes(function (): void {
|
||||
|
||||
@@ -155,10 +155,9 @@ class LocalizationService
|
||||
}
|
||||
|
||||
/**
|
||||
* Time group types have no server-side descriptor; their keys are ISO dates and are
|
||||
* 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. A Year key is already a bare year, so it is returned unchanged - it must
|
||||
* not be parsed, Carbon reads a four digit string as a time of day.
|
||||
* range it covers.
|
||||
*/
|
||||
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
|
||||
{
|
||||
@@ -176,13 +175,6 @@ class LocalizationService
|
||||
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
|
||||
}
|
||||
|
||||
if ($groupType === TimeEntryAggregationType::Month) {
|
||||
// Note: the leading "!" resets all fields the format does not name. Without it the
|
||||
// day of the month is taken from today, and a day that the parsed month does not
|
||||
// have overflows the date into the next month.
|
||||
return Carbon::createFromFormat('!Y-m', $key)->format('F Y');
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,21 +158,6 @@ return [
|
||||
|
||||
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The number of API requests allowed per minute, counted per user for
|
||||
| authenticated requests and per IP address for guest requests. These
|
||||
| limits are only enforced when the application runs in production.
|
||||
|
|
||||
*/
|
||||
|
||||
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
|
||||
|
||||
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
use function Laravel\Prompts\info as consoleInfo;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
|
||||
* Keeping this migration non-transactional prevents long write locks in production.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $withinTransaction = false;
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
|
||||
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
|
||||
|
||||
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
|
||||
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
|
||||
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
|
||||
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
|
||||
|
||||
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
|
||||
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
|
||||
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
|
||||
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
|
||||
|
||||
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
|
||||
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
|
||||
|
||||
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
|
||||
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
|
||||
|
||||
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
|
||||
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
|
||||
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
|
||||
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
|
||||
|
||||
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
|
||||
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
|
||||
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
|
||||
|
||||
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
|
||||
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
|
||||
|
||||
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
|
||||
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
|
||||
|
||||
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
|
||||
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
|
||||
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
|
||||
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
|
||||
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
|
||||
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
|
||||
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
|
||||
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
|
||||
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
|
||||
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
|
||||
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
|
||||
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
|
||||
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
|
||||
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
|
||||
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
|
||||
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
|
||||
|
||||
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
|
||||
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
|
||||
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
|
||||
|
||||
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
|
||||
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
|
||||
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
|
||||
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$indexes = [
|
||||
'clients_organization_created_id_index',
|
||||
'projects_organization_created_id_index',
|
||||
'projects_client_id_index',
|
||||
'tasks_organization_created_id_index',
|
||||
'tasks_project_id_index',
|
||||
'tags_organization_created_id_index',
|
||||
'reports_organization_created_id_index',
|
||||
'members_organization_created_id_index',
|
||||
'members_user_id_index',
|
||||
'project_members_member_id_index',
|
||||
'project_members_user_id_index',
|
||||
'users_current_team_id_index',
|
||||
'audits_created_at_index',
|
||||
'time_entries_organization_start_id_index',
|
||||
'time_entries_created_at_index',
|
||||
'time_entries_user_organization_start_index',
|
||||
'time_entries_member_start_index',
|
||||
'time_entries_project_id_index',
|
||||
'time_entries_task_id_index',
|
||||
'time_entries_client_id_index',
|
||||
'time_entries_active_member_index',
|
||||
'time_entries_tags_gin_index',
|
||||
'oauth_access_tokens_client_id_index',
|
||||
'oauth_auth_codes_client_id_index',
|
||||
];
|
||||
|
||||
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
|
||||
foreach ($indexes as $index) {
|
||||
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
|
||||
}
|
||||
|
||||
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
|
||||
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
|
||||
}
|
||||
|
||||
private function runIndexOperation(string $operation, string $index, string $statement): void
|
||||
{
|
||||
$indexState = $this->indexState($index);
|
||||
|
||||
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
|
||||
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operation === 'drop' && $indexState === null) {
|
||||
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($operation === 'create' && $indexState !== null) {
|
||||
$this->writeProgress(sprintf(
|
||||
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
|
||||
$index,
|
||||
$indexState['valid'] ? 'true' : 'false',
|
||||
$indexState['ready'] ? 'true' : 'false',
|
||||
));
|
||||
$this->executeIndexStatement(
|
||||
'drop incomplete',
|
||||
$index,
|
||||
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
|
||||
);
|
||||
}
|
||||
|
||||
$this->executeIndexStatement($operation, $index, $statement);
|
||||
}
|
||||
|
||||
private function executeIndexStatement(string $operation, string $index, string $statement): void
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
|
||||
|
||||
try {
|
||||
DB::statement($statement);
|
||||
} catch (Throwable $exception) {
|
||||
$this->writeProgress(sprintf(
|
||||
'Failed to %s index [%s] after %.2f seconds: %s',
|
||||
$operation,
|
||||
$index,
|
||||
microtime(true) - $startedAt,
|
||||
$exception->getMessage(),
|
||||
));
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->writeProgress(sprintf(
|
||||
'Finished %s index [%s] in %.2f seconds',
|
||||
$operation === 'create' ? 'creating' : 'dropping',
|
||||
$index,
|
||||
microtime(true) - $startedAt,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{valid: bool, ready: bool}|null
|
||||
*/
|
||||
private function indexState(string $index): ?array
|
||||
{
|
||||
$state = DB::selectOne(
|
||||
<<<'SQL'
|
||||
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
|
||||
FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
|
||||
WHERE pg_namespace.nspname = current_schema()
|
||||
AND pg_class.relname = ?
|
||||
SQL,
|
||||
[$index],
|
||||
);
|
||||
|
||||
if ($state === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => (bool) $state->valid,
|
||||
'ready' => (bool) $state->ready,
|
||||
];
|
||||
}
|
||||
|
||||
private function quoteIdentifier(string $identifier): string
|
||||
{
|
||||
return DB::connection()->getQueryGrammar()->wrap($identifier);
|
||||
}
|
||||
|
||||
private function writeProgress(string $message): void
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
|
||||
Log::info($message);
|
||||
consoleInfo($message);
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.5"
|
||||
"ref": "v0.0.3"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "v0.0.2"
|
||||
"ref": "v0.0.1"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
|
||||
@@ -198,7 +198,7 @@ const showResultModal = ref(false);
|
||||
for="file-upload"
|
||||
class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
|
||||
<span v-if="files">{{ filenames }}</span>
|
||||
<span v-else>Upload the Export file</span>
|
||||
<span v-else>Upload a Toggl/Clockify Export</span>
|
||||
<input
|
||||
id="file-upload"
|
||||
ref="importFile"
|
||||
|
||||
@@ -25,21 +25,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed top-0 left-0 z-50 w-screen h-screen [height:100dvh] backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
||||
class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
|
||||
<div class="absolute inset-0 bg-default-background opacity-30" />
|
||||
<div
|
||||
class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</div>
|
||||
</DialogOverlay>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
|
||||
)
|
||||
">
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
<slot />
|
||||
</DialogContent>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
formatHumanReadableDuration,
|
||||
formatMonth,
|
||||
formatReportingDuration,
|
||||
formatWeekRange,
|
||||
} from './time';
|
||||
import { formatHumanReadableDuration, formatReportingDuration, formatWeekRange } from './time';
|
||||
|
||||
const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s
|
||||
|
||||
@@ -65,9 +60,3 @@ describe('formatWeekRange', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMonth', () => {
|
||||
test('renders the name of the month that the key covers', () => {
|
||||
expect(formatMonth('2001-02')).toBe('February 2001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,15 +245,6 @@ export function formatWeekRange(date: string, format?: DateFormat): string {
|
||||
return `${formatDate(date, format)} - ${formatDate(end, format)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the month that the given key falls in. There is no `DateFormat` variant for a
|
||||
* month, so this is not affected by the organization date format.
|
||||
* @param date - a month, in the format of 'YYYY-MM'
|
||||
*/
|
||||
export function formatMonth(date: string): string {
|
||||
return getDayJsInstance()(date).format('MMMM YYYY');
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns a human readable date format.
|
||||
* @param date - date in the format of 'YYYY-MM-DD'
|
||||
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import { Coffee } from '@lucide/vue';
|
||||
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
|
||||
import {
|
||||
type DateFormat,
|
||||
formatDate,
|
||||
formatMonth,
|
||||
formatWeekRange,
|
||||
} from '@/packages/ui/src/utils/time';
|
||||
import { type DateFormat, formatDate, formatWeekRange } from '@/packages/ui/src/utils/time';
|
||||
|
||||
export type GroupingOption =
|
||||
| 'project'
|
||||
@@ -32,9 +27,7 @@ export type GroupingOption =
|
||||
| 'tag'
|
||||
| 'type'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year';
|
||||
| 'week';
|
||||
|
||||
export const useReportingStore = defineStore('reporting', () => {
|
||||
// Cache query composables to avoid creating new subscriptions on every call
|
||||
@@ -101,10 +94,6 @@ export const useReportingStore = defineStore('reporting', () => {
|
||||
if (type === 'week') {
|
||||
return formatWeekRange(key, dateFormat);
|
||||
}
|
||||
if (type === 'month') {
|
||||
return formatMonth(key);
|
||||
}
|
||||
// A `year` key is already a bare year, so it falls through unchanged.
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
@@ -364,27 +364,14 @@ class LocalizationServiceTest extends TestCaseWithDatabase
|
||||
$this->assertSame('29/12/2025 - 04/01/2026', $formatted);
|
||||
}
|
||||
|
||||
public function test_format_time_group_key_formats_a_month_key_and_does_not_change_year_and_entity_group_types(): void
|
||||
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('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
|
||||
$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));
|
||||
}
|
||||
|
||||
public function test_format_time_group_key_formats_a_month_key_independently_of_the_current_day_of_month(): void
|
||||
{
|
||||
// Arrange
|
||||
// Note: the current day of the month must not leak into the parsed month. The 31st does
|
||||
// not exist in April, so a leaked day overflows the date into the following month.
|
||||
$this->travelTo(Carbon::create(2026, 8, 31, 12, 0, 0, 'UTC'));
|
||||
|
||||
// Act & Assert
|
||||
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
|
||||
$this->assertSame('April 2026', $this->localizationService->formatTimeGroupKey('2026-04', TimeEntryAggregationType::Month));
|
||||
$this->assertSame('December 2026', $this->localizationService->formatTimeGroupKey('2026-12', TimeEntryAggregationType::Month));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user