Compare commits

..

31 Commits

Author SHA1 Message Date
Gregor Vostrak
f6311eddfd unify display values for work and break time totals 2026-07-27 15:48:05 +02:00
Gregor Vostrak
2639b62744 bump ui to 0.0.22 and api to 0.0.7 2026-07-24 18:09:21 +02:00
Gregor Vostrak
8a68673a48 promote breaks feature in sidebar banner 2026-07-24 16:44:45 +02:00
Gregor Vostrak
34f8eb0970 default time entry type filter to work for legacy reports 2026-07-24 15:36:51 +02:00
Gregor Vostrak
64ca1e9115 add break time entries and simplified time tracker ui 2026-07-21 18:54:05 +02:00
Constantin Graf
c07c62bfab Add extensions manifest 2026-07-21 16:39:27 +02:00
Constantin Graf
f599757761 Add .dockerignore file 2026-07-21 16:39:27 +02:00
dependabot[bot]
e54fd2460c Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:33 +02:00
dependabot[bot]
e997a2b39f Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:15 +02:00
Constantin Graf
f0de89b0e8 Prevent failed jobs for no longer existing projects and tasks 2026-07-11 16:00:34 +02:00
Constantin Graf
024283273a Updated email validation to stricter config 2026-07-11 16:00:34 +02:00
Candide
f01ab5fd9d Fix awkward wording for time tracker alert email
I just received this email today, and found the "you do that in solidtime" wording to be a bit awkward.
2026-07-11 15:57:33 +02:00
Gregor Vostrak
4fb18f343f allow missing client columns in clockify importer, fixes #1149 2026-07-09 16:16:25 +02:00
Gregor Vostrak
ba374c0371 add client name to projecttaskselect dropdown trigger 2026-07-09 13:53:08 +02:00
Gregor Vostrak
e302421765 make sure dropdown/combobox lists stay visible during close animation to
avoid layout shifts
2026-07-09 13:01:23 +02:00
Gregor Vostrak
c90c1ed8fa add stable secondary sorting based on id to the tests to avoid flakyness 2026-07-09 13:01:23 +02:00
Gregor Vostrak
75f5144b45 use pinned selection instead of fix items on dropdown open so async
loaded collections update the dropdown properly
2026-07-09 13:01:23 +02:00
Gregor Vostrak
53ecc58509 add validationa for project names in toggl importer 2026-07-09 13:01:23 +02:00
Gregor Vostrak
ac68091d3e add secondary name sorting as tie breaker in client and project tables 2026-07-09 13:01:23 +02:00
Gregor Vostrak
a97c02542b add unique id tiebreaker to paginated index endpoints to make pagination
stable (#1138)
2026-07-09 13:01:23 +02:00
Gregor Vostrak
a56942abcf Add virtualizer to ProjectDropdown, ClientDropdown and Reporting
Comboboxes; Remove redundant focus loop on Project/ClientDropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
61a0a7db5a remove the measure row logic and rely on static values only for the
virtualizer and remove duplicated focus trap to avoid infinite loop in
project task dropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
bc5af14a75 keep tasks visible when search term matches project or client name 2026-07-09 13:01:23 +02:00
Gregor Vostrak
95bfb45978 add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
2026-07-09 13:01:23 +02:00
Gregor Vostrak
2207f676ee add pagination to client and project table 2026-07-09 13:01:23 +02:00
Gregor Vostrak
981bcbe091 add tests for tag match type (backend + e2e) 2026-06-26 14:53:37 +02:00
Gregor Vostrak
ccf3cec8c0 migrate tag match type buttons to reka-ui radio group for accessibility 2026-06-26 14:53:37 +02:00
Gregor Vostrak
23ea1500b0 Use an enum for tag match type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
4bbde04e28 Rename tag_filter parameter to tag_match_type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
caa36d2875 Fix not-contains tag filter dropping entries where tags is null 2026-06-26 14:53:37 +02:00
Beda Schmid
58d7b33366 Add filter to include/exclude tags
[Added]
- Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
2026-06-26 14:53:37 +02:00
170 changed files with 7360 additions and 522 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
.git
.gitmodules
.github
.DS_Store
.fleet
.idea
.vscode
*.log
npm-debug.log
yarn-error.log
.phpunit.cache
.phpunit.result.cache
coverage
test-results
playwright-report
blob-report
playwright/.cache
node_modules
extensions/*/node_modules
Homestead.json
Homestead.yaml
auth.json
.env.backup
.rnd
_ide_helper.php
.phpstorm.meta.php
storage/logs/*

View File

@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
@@ -35,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -91,14 +92,23 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-invoicing
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,6 +8,7 @@ on:
pull_request:
paths:
- '.github/workflows/build-private.yml'
- 'extensions/manifest.json'
- 'docker/prod/**'
workflow_dispatch:
permissions:
@@ -22,7 +23,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,14 +69,27 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-billing
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
@@ -93,9 +107,10 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-services
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -111,9 +126,10 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: solidtime-io/extension-invoicing
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -29,7 +29,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'

View File

@@ -13,10 +13,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist
- name: "Use Node.js"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '20.x'

3
.gitignore vendored
View File

@@ -26,9 +26,10 @@ yarn-error.log
/blob-report/
/playwright/.cache/
/coverage
/extensions
/extensions/*
!/extensions/.gitkeep
!/extensions/extensions_autoload.php
!/extensions/manifest.json
/auth.json
/modules_statuses.json
/k8s

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TagMatchType: string
{
use LaravelEnumHelper;
case Contains = 'contains';
case NotContains = 'not_contains';
}

View File

@@ -21,6 +21,7 @@ enum TimeEntryAggregationType: string
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
case Type = 'type';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryType: string
{
use LaravelEnumHelper;
case Work = 'work';
case Break = 'break';
}

View File

@@ -66,7 +66,7 @@ class UserResource extends Resource
->ignore($record?->getKey()),
])
->rule([
'email',
'email:rfc,strict',
])
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')

View File

@@ -78,6 +78,9 @@ class OrganizationController extends Controller
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
if ($request->getBreaksEnabled() !== null) {
$organization->breaks_enabled = $request->getBreaksEnabled();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -57,9 +57,10 @@ class ReportController extends Controller
$filter->addEnd($properties->end);
$filter->addActive($properties->active);
$filter->addBillable($properties->billable);
$filter->addType($properties->timeEntryType);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();

View File

@@ -97,6 +97,7 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;
@@ -111,6 +112,7 @@ class ReportController extends Controller
$properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$properties->timeEntryType = $request->getPropertyTimeEntryType();
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
@@ -204,10 +205,11 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$filter->addTypeFilter($request->input('type'));
return $filter->get();
}
@@ -560,10 +562,11 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$filter->addTypeFilter($request->input('type'));
return $filter->get();
}
@@ -746,6 +749,19 @@ class TimeEntryController extends Controller
continue;
}
// Changing time entries to Break entries is only allowed when breaks are enabled in the org settings
$resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type;
if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) {
$error->push($id);
continue;
}
// Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted)
if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

View File

@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
return [
'email' => [
'required',
'email',
'email:rfc,strict',
],
'role' => [
'required',

View File

@@ -51,6 +51,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'prevent_overlapping_time_entries' => [
'boolean',
],
'breaks_enabled' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -125,4 +128,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
public function getBreaksEnabled(): ?bool
{
return $this->has('breaks_enabled') ? $this->boolean('breaks_enabled') : null;
}
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
@@ -124,6 +126,11 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -171,6 +178,12 @@ class ReportStoreRequest extends BaseFormRequest
'numeric',
'integer',
],
// Filter by time entry type
'properties.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
@@ -234,6 +247,15 @@ class ReportStoreRequest extends BaseFormRequest
return null;
}
public function getPropertyTimeEntryType(): ?TimeEntryType
{
if (! $this->has('properties.time_entry_type') || $this->input('properties.time_entry_type') === null) {
return null;
}
return TimeEntryType::from($this->input('properties.time_entry_type'));
}
public function getPropertyGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.group'));
@@ -249,6 +271,15 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyTagMatchType(): ?TagMatchType
{
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->input('properties.tag_match_type'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -139,6 +141,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -178,6 +184,11 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
@@ -246,6 +257,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -125,6 +127,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -164,6 +170,11 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
@@ -208,6 +219,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -110,6 +112,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -150,6 +156,11 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
@@ -215,6 +226,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
@@ -103,6 +105,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -143,6 +149,11 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
'in:true,false',
],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
@@ -190,6 +201,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -14,6 +15,7 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -24,7 +26,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
@@ -42,6 +44,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
'prohibited_if:type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -60,6 +63,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'task_id' => [
'nullable',
'string',
'prohibited_if:type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -85,6 +89,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
'billable' => [
'required',
'boolean',
'declined_if:type,break',
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) {
$fail('Breaks are disabled for this organization.');
}
},
],
// Description of time entry
'description' => [
@@ -96,6 +110,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
'prohibited_if:type,break',
],
'tags.*' => [
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
@@ -14,6 +15,7 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -24,7 +26,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
@@ -54,6 +56,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -72,6 +75,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.task_id' => [
'nullable',
'string',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -84,7 +88,13 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
],
// Whether time entry is billable
'changes.billable' => [
'sometimes',
'boolean',
'declined_if:changes.type,break',
],
// Type of the time entry (work time or a break)
'changes.type' => [
Rule::enum(TimeEntryType::class),
],
// Description of time entry
'changes.description' => [
@@ -96,6 +106,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.tags' => [
'nullable',
'array',
'prohibited_if:changes.type,break',
],
'changes.tags.*' => [
'string',

View File

@@ -4,16 +4,21 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\ProhibitedIf;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
@@ -24,10 +29,19 @@ class TimeEntryUpdateRequest extends BaseFormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
*/
public function rules(): array
{
// Break restrictions need to apply based on the type the entry will have after the
// update, not only when the payload itself contains type=break.
$timeEntry = $this->route('timeEntry');
$timeEntry = $timeEntry instanceof TimeEntry ? $timeEntry : null;
$resultingType = $this->has('type')
? TimeEntryType::tryFrom((string) $this->input('type'))
: $timeEntry?->type;
$isBreak = $resultingType === TimeEntryType::Break;
return [
// ID of the organization member that the time entry should belong to
'member_id' => [
@@ -42,6 +56,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'nullable',
'string',
'required_with:task_id',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -60,6 +75,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'task_id' => [
'nullable',
'string',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
@@ -82,7 +98,22 @@ class TimeEntryUpdateRequest extends BaseFormRequest
],
// Whether time entry is billable
'billable' => [
'sometimes',
'boolean',
Rule::when($isBreak, ['declined']),
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail) use ($timeEntry): void {
// While breaks are disabled, entries that already are breaks may stay
// breaks, but converting a work entry to a break is not allowed.
if ($value === TimeEntryType::Break->value
&& ! $this->organization->breaks_enabled
&& $timeEntry?->type !== TimeEntryType::Break) {
$fail('Breaks are disabled for this organization.');
}
},
],
// Description of time entry
'description' => [
@@ -94,6 +125,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'tags' => [
'nullable',
'array',
Rule::prohibitedIf($isBreak),
],
'tags.*' => [
'string',

View File

@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
'max:255',
],
'email' => [
'email',
'email:rfc,strict',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */

View File

@@ -57,6 +57,8 @@ class OrganizationResource extends BaseResource
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -50,12 +50,16 @@ class DetailedReportResource extends BaseResource
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -47,6 +47,8 @@ class TimeEntryResource extends BaseResource
'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable,
/** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value,
];
}
}

View File

@@ -20,6 +20,11 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Project $project;
/**

View File

@@ -20,6 +20,11 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
use Queueable;
use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Task $task;
/**

View File

@@ -34,6 +34,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property bool $breaks_enabled
* @property User $owner
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@@ -70,6 +71,7 @@ class Organization extends Model implements AuditableContract
'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'breaks_enabled' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Enums\TimeEntryType;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use App\Service\BillableRateService;
@@ -28,6 +29,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable
* @property TimeEntryType $type
* @property array<string> $tags
* @property string $user_id
* @property string $member_id
@@ -71,12 +73,20 @@ class TimeEntry extends Model implements AuditableContract
'start' => 'datetime',
'end' => 'datetime',
'billable' => 'bool',
'type' => TimeEntryType::class,
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
'still_active_email_sent_at' => 'datetime',
];
/**
* @var array<string, string>
*/
protected $attributes = [
'type' => 'work',
];
public const array SELECT_COLUMNS = [
'id',
'description',
@@ -84,6 +94,7 @@ class TimeEntry extends Model implements AuditableContract
'end',
'billable_rate',
'billable',
'type',
'user_id',
'organization_id',
'project_id',
@@ -117,6 +128,21 @@ class TimeEntry extends Model implements AuditableContract
'billable_rate',
];
protected static function booted(): void
{
// Break entries can never be billable, have tags or belong to a project/task.
static::saving(function (TimeEntry $timeEntry): void {
if ($timeEntry->type === TimeEntryType::Break) {
$timeEntry->billable = false;
$timeEntry->billable_rate = null;
$timeEntry->project_id = null;
$timeEntry->task_id = null;
$timeEntry->client_id = null;
$timeEntry->tags = [];
}
});
}
public function getBillableRateComputed(): ?int
{
return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
@@ -173,6 +199,16 @@ class TimeEntry extends Model implements AuditableContract
$builder->whereJsonContains('tags', $tag->getKey());
}
/**
* Only work entries breaks do not count toward tracked/billable time.
*
* @param Builder<TimeEntry> $builder
*/
public function scopeWorkTime(Builder $builder): void
{
$builder->where('type', '=', TimeEntryType::Work);
}
/**
* @return BelongsTo<User, $this>
*/

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\Project;
@@ -154,6 +155,7 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -195,6 +197,7 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date');
@@ -222,7 +225,8 @@ class DashboardService
$query = TimeEntry::query()
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey());
->where('organization_id', '=', $organization->getKey())
->workTime();
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */
@@ -290,6 +294,7 @@ class DashboardService
->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy('project_id');
$query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start);
@@ -433,7 +438,8 @@ class DashboardService
JOIN time_entries ON time_entries.start < time_ranges."end"
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
WHERE time_entries.user_id = :user_id and
time_entries.organization_id = :organization_id
time_entries.organization_id = :organization_id and
time_entries.type = :work_type
GROUP BY time_ranges.start
ORDER BY time_ranges.start
', [
@@ -442,6 +448,7 @@ class DashboardService
'user_id' => $user->getKey(),
'organization_id' => $organization->getKey(),
'now' => Carbon::now()->toDateTimeString(),
'work_type' => TimeEntryType::Work->value,
]))->pluck('aggregate', 'start');
$response = [];

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday;
use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable;
@@ -56,6 +58,8 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
@@ -65,6 +69,8 @@ class ReportPropertiesDto implements Castable
public ?int $roundingMinutes = null;
public ?TimeEntryType $timeEntryType = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
@@ -115,6 +121,7 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -125,6 +132,12 @@ class ReportPropertiesDto implements Castable
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
// Note: timeEntryType was added later, reports persisted before that are missing the value and default to "work"
if (property_exists($data, 'timeEntryType')) {
$dto->timeEntryType = $data->timeEntryType !== null ? TimeEntryType::from($data->timeEntryType) : null;
} else {
$dto->timeEntryType = TimeEntryType::Work;
}
return $dto;
}
@@ -144,6 +157,7 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
@@ -152,6 +166,7 @@ class ReportPropertiesDto implements Castable
'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes,
'timeEntryType' => $value->timeEntryType?->value,
];
$jsonString = json_encode($data);
@@ -216,6 +231,11 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/**
* @param array<mixed>|null $taskIds
*/

View File

@@ -107,6 +107,7 @@ class ExportService
'end',
'billable_rate',
'billable',
'type',
'member_id',
'user_id',
'organization_id',
@@ -131,6 +132,7 @@ class ExportService
$timeEntry->end?->toIso8601ZuluString() ?? '',
$timeEntry->billable_rate ?? '',
$timeEntry->billable ? 'true' : 'false',
$timeEntry->type->value,
$timeEntry->member_id,
$timeEntry->user_id,
$timeEntry->organization_id,

View File

@@ -29,7 +29,8 @@ class ClockifyProjectsImporter extends DefaultImporter
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if ($record['Client'] !== '') {
// Newer Clockify exports no longer contain a "Client" column.
if (($record['Client'] ?? '') !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -45,7 +46,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]);
}
@@ -80,7 +81,6 @@ class ClockifyProjectsImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Status',
'Visibility',
'Billability',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -71,8 +72,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
// Clockify allows a project/task/client/tags/billable on breaks, but those are
// meaningless for non-work time. Detect breaks up front and skip creating any of
// that so a break can't spawn an orphan project/tag or inflate the import counts.
$isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break';
$clientId = null;
if ($record['Client'] !== '') {
if (! $isBreak && ($record['Client'] ?? '') !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -81,7 +86,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
if (! $isBreak && $record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
@@ -97,7 +102,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if ($taskKey !== null && $record[$taskKey] !== '') {
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey],
'project_id' => $projectId,
@@ -123,7 +128,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
}
$timeEntry->tags = $this->getTags($record['Tags']);
if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above)
$timeEntry->type = TimeEntryType::Break;
$timeEntry->billable = false;
}
$timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
// Start
@@ -215,7 +225,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Description',
'User',
'Group',

View File

@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
}, validate: [
'email' => [
'required',
'email',
'email:rfc,strict',
'max:255',
],
]);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -255,6 +256,14 @@ class SolidtimeImporter extends DefaultImporter
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $timeEntryRow['billable'] === 'true';
// The type column does not exist in old exports
if (($timeEntryRow['type'] ?? '') !== '') {
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
if ($type === null) {
throw new ImportException('Invalid type value');
}
$timeEntry->type = $type;
}
$timeEntry->tags = $this->getTags($timeEntryRow['tags']);
$timeEntry->is_imported = true;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry;
use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder;
@@ -25,6 +26,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration',
'Duration (decimal)',
'Billable',
'Break',
'Tags',
];
@@ -58,6 +60,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No',
'Break' => $model->type === TimeEntryType::Break ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry;
use App\Service\LocalizationService;
use Illuminate\Database\Eloquent\Builder;
@@ -106,6 +107,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
'Duration',
'Duration (decimal)',
'Billable',
'Break',
'Tags',
];
}
@@ -130,6 +132,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} elseif ($this->exportFormat === ExportFormat::ODS) {
@@ -144,6 +147,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
} else {

View File

@@ -353,6 +353,13 @@ class TimeEntryAggregationService
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Type) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === 'break' ? 'Break' : 'Work time',
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query()
->whereIn('id', $keys)
@@ -504,6 +511,8 @@ class TimeEntryAggregationService
return 'client_id';
} elseif ($group === TimeEntryAggregationType::Billable) {
return 'billable';
} elseif ($group === TimeEntryAggregationType::Type) {
return 'type';
} elseif ($group === TimeEntryAggregationType::Description) {
return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) {

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Member;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
@@ -143,6 +145,32 @@ class TimeEntryFilter
return $this;
}
public function addTypeFilter(?string $type): self
{
if ($type === null) {
return $this;
}
$typeEnum = TimeEntryType::tryFrom($type);
if ($typeEnum === null) {
Log::warning('Invalid type filter value', ['value' => $type]);
return $this;
}
$this->addType($typeEnum);
return $this;
}
public function addType(?TimeEntryType $type): self
{
if ($type === null) {
return $this;
}
$this->builder->where('type', '=', $type->value);
return $this;
}
/**
* @param array<string>|null $clientIds
*/
@@ -192,15 +220,21 @@ class TimeEntryFilter
/**
* @param array<string>|null $tagIds
*/
public function addTagIdsFilter(?array $tagIds): self
public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
{
if ($tagIds === null) {
return $this;
}
$tagMatchType ??= TagMatchType::Contains;
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
if (count($tagIds) === 0 && ! $includeNone) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
$tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId);
}
@@ -209,7 +243,18 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
});
}
});
};
if ($tagMatchType === TagMatchType::NotContains) {
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
$builder->whereNot($tagCondition);
if (! $includeNone) {
$builder->orWhereNull('tags');
}
});
} else {
$this->builder->where($tagCondition);
}
return $this;
}

View File

@@ -33,6 +33,7 @@ class OrganizationFactory extends Factory
'user_id' => User::factory(),
'personal_team' => true,
'employees_can_see_billable_rates' => false,
'breaks_enabled' => false,
'number_format' => $this->faker->randomElement(NumberFormat::values()),
'currency_format' => $this->faker->randomElement(CurrencyFormat::values()),
'date_format' => $this->faker->randomElement(DateFormat::values()),
@@ -55,6 +56,13 @@ class OrganizationFactory extends Factory
]);
}
public function withBreaksEnabled(): self
{
return $this->state(fn (array $attributes) => [
'breaks_enabled' => true,
]);
}
public function withOwner(?User $owner = null): self
{
return $this->state(fn (array $attributes) => [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Database\Factories;
use App\Enums\TimeEntryType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -33,6 +34,7 @@ class TimeEntryFactory extends Factory
'start' => $start,
'end' => $this->faker->dateTimeBetween($start, 'now'),
'billable' => $this->faker->boolean(),
'type' => TimeEntryType::Work,
'is_imported' => false,
'tags' => [],
'user_id' => User::factory(),
@@ -44,6 +46,18 @@ class TimeEntryFactory extends Factory
];
}
public function isBreak(): self
{
return $this->state(function (array $attributes): array {
return [
'type' => TimeEntryType::Break,
'billable' => false,
'project_id' => null,
'task_id' => null,
];
});
}
public function notBillable(): self
{
return $this->state(function (array $attributes): array {

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('type')->default('work');
});
}
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->dropColumn('type');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('breaks_enabled')->default(false)->after('prevent_overlapping_time_entries');
});
}
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('breaks_enabled');
});
}
};

278
e2e/breaks.spec.ts Normal file
View File

@@ -0,0 +1,278 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { createTimeEntryViaApi, updateOrganizationSettingViaApi } from './utils/api';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
function visibleBreakButton(page: Page) {
return page.getByRole('button', { name: 'Take a break' }).locator('visible=true').first();
}
// Breaks are disabled by default for new organizations, so enable them for the break flows.
// The tests that assert the disabled behaviour turn them back off explicitly.
test.beforeEach(async ({ ctx }) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
});
test('test that switching to a break stops the work timer and starts a break entry', async ({
page,
}) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Work before break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Switch to break: stops the work entry and starts a break entry
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
visibleBreakButton(page).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// The break bar offers a one-click resume that stops the break and restores
// the interrupted work context
await page.waitForTimeout(1500);
const resumeButton = page.getByRole('button', { name: 'Resume "Work before break"' });
await expect(resumeButton).toBeVisible();
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
resumeButton.click(),
]);
await assertThatTimerHasStarted(page);
await expect(page.getByTestId('time_entry_description')).toHaveValue('Work before break');
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that stopping a break returns to an idle tracker where a fresh entry starts normally', async ({
page,
}) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Work before break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Switch to a break
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
visibleBreakButton(page).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// Stopping the break just ends it — no modal, the tracker returns to the
// empty idle input with focus so typing starts a fresh entry
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
await expect(page.getByTestId('time_entry_description')).toHaveValue('');
await expect(page.getByTestId('time_entry_description')).toBeFocused();
// A fresh entry is the normal start flow: type + Enter
await page.getByTestId('time_entry_description').fill('Fresh after break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that the more options dropdown can start a break directly', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
// Start a break straight from the more options dropdown (no create modal)
await page.getByRole('button', { name: 'Time entry actions' }).click();
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
page.getByRole('menuitem', { name: 'Start Break' }).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// Without interrupted work there is nothing to resume, so no resume button is offered
await expect(page.getByRole('button', { name: /^Resume/ })).toHaveCount(0);
// Cleanup: stop the break
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that disabling breaks hides every break-creation entry point', async ({ page, ctx }) => {
// Breaks disabled for the organization (delivered to the client via the organization endpoint)
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work' });
// Calendar: the empty-slot context menu offers "Create Time Entry" but no "Add Break",
// and the edit modal drops the work-time/break type selector
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible();
const event = page.locator('.fc-event').filter({ hasText: 'Regular work' }).first();
await event.scrollIntoViewIfNeeded();
await expect(event).toBeVisible();
const box = await event.boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 40, { button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Create Time Entry' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Add Break' })).toHaveCount(0);
await page.keyboard.press('Escape');
await event.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Work time' })
).toHaveCount(0);
await page.keyboard.press('Escape');
// Timesheet: no break row is shown
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
await expect(page.getByRole('button', { name: 'Add row' }).first()).toBeVisible();
await expect(page.getByText('Break', { exact: true })).toHaveCount(0);
// Dashboard tracker: no "Start Break" in the more options dropdown
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await expect(page.getByRole('menuitem', { name: 'Switch to simple mode' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
});
// The employee fixture registers a second user and accepts an invitation via Mailpit,
// which does not fit into the default per-test timeout.
test.describe('Org-level breaks setting', () => {
test.describe.configure({ timeout: 60000 });
test('test that the org-level breaks setting is respected for employees', async ({
ctx,
employee,
}) => {
const employeePage = employee.page;
// Breaks enabled (via beforeEach): the employee sees "Start Break" in the more options dropdown
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
await expect(
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
).toBeVisible();
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toBeVisible();
await employeePage.keyboard.press('Escape');
// The owner disables breaks for the whole organization
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
// The employee reloads: "Start Break" is gone from the dropdown
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
await expect(
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
).toBeVisible();
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
await employeePage.keyboard.press('Escape');
// With an active timer the break (coffee) button is not shown either
await employeePage.getByTestId('time_entry_description').fill('Employee work');
await Promise.all([
newTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
employeePage.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(employeePage);
await expect(employeePage.getByRole('button', { name: 'Take a break' })).toHaveCount(0);
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
startOrStopTimerWithButton(employeePage),
]);
await assertThatTimerIsStopped(employeePage);
});
});
test('test that mass update warns about selected breaks and reports skipped entries instead of success', async ({
page,
ctx,
}) => {
// One work entry and one break: a billable mass update applies to the work
// entry but the server skips the break entirely — the UI must say so.
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Mass update work entry' });
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2);
await page.getByLabel('Select All').click();
await expect(page.getByText('2 selected')).toBeVisible();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// No warning while the changeset is compatible with breaks
await expect(page.getByTestId('mass_update_break_warning')).not.toBeVisible();
// Making the entries billable is break-incompatible → warning appears
await page
.getByRole('dialog')
.getByRole('combobox')
.filter({ hasText: 'Set billable status' })
.click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await expect(page.getByTestId('mass_update_break_warning')).toBeVisible();
await expect(page.getByTestId('mass_update_break_warning')).toContainText('skipped entirely');
// Submit: the work entry updates, the break is skipped, and the toast
// reports the skip instead of claiming success for all entries
const [massUpdateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PATCH' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entries' }).click(),
]);
const massUpdateBody = await massUpdateResponse.json();
expect(massUpdateBody.success.length).toBe(1);
expect(massUpdateBody.error.length).toBe(1);
await expect(page.getByText('1 of 2 time entries was skipped')).toBeVisible();
});

View File

@@ -2874,3 +2874,54 @@ test.describe('Daily Total After Create', () => {
}).toPass({ timeout: 5000 });
});
});
test('test that calendar context menu can add a break that fills the gap between two entries', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
// Two work entries today (09:00-10:00 and 11:00-12:00 UTC) with a one hour gap
const today = new Date().toISOString().slice(0, 10);
const gapStart = `${today}T10:00:00Z`;
const gapEnd = `${today}T11:00:00Z`;
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${today}T09:00:00Z`,
end: gapStart,
description: 'Gap work A',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: gapEnd,
end: `${today}T12:00:00Z`,
description: 'Gap work B',
});
await goToCalendar(page);
const eventA = page.locator('.fc-event').filter({ hasText: 'Gap work A' }).first();
await eventA.scrollIntoViewIfNeeded();
await expect(eventA).toBeVisible();
// Right-click just below entry A (inside the gap, in the same day column)
const box = await eventA.boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 15, {
button: 'right',
});
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Add Break' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The break is prefilled to fill the gap exactly
const [createResponse] = await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.type === 'break'
),
page.getByRole('button', { name: 'Add Break' }).click(),
]);
const body = await createResponse.json();
expect(body.data.start).toBe(gapStart);
expect(body.data.end).toBe(gapEnd);
});

View File

@@ -4,7 +4,7 @@ import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.locator('[data-testid="organization_switcher"]:visible').click();
await page.getByText('Organization Settings').click();
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
}
async function createTimeEntry(page, duration: string) {

View File

@@ -0,0 +1,69 @@
import { expect } from '@playwright/test';
import { test } from '../playwright/fixtures';
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
import { createTimeEntryWithTagViaApi } from './utils/api';
// Each test registers a new user and creates test data via the API
test.describe.configure({ timeout: 30000 });
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
page,
ctx,
}) => {
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
// The entry with tagA is excluded; the entry with tagB remains
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
});
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
page,
ctx,
}) => {
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
// "Contains" tagA -> only the tagA entry is listed
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
// "Does Not Contain" tagA -> flips to the tagB entry
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
});

View File

@@ -1019,3 +1019,24 @@ test.describe('Employee Reporting Restrictions', () => {
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
});
});
test('test that reporting has a type filter that can show only breaks', async ({ page, ctx }) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work entry' });
await createTimeEntryViaApi(ctx, { duration: '20min', type: 'break' });
await goToReporting(page);
// The type filter defaults to "Work time"; switching it to "Breaks" re-aggregates.
const typeFilter = page.getByRole('combobox').filter({ hasText: 'Work time' });
await expect(typeFilter).toBeVisible();
await typeFilter.click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('type=break') &&
response.status() === 200
),
page.getByRole('option', { name: 'Breaks' }).click(),
]);
});

View File

@@ -50,7 +50,7 @@ async function goToTimeOverview(page: Page) {
async function goToOrganizationSettings(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.locator('[data-testid="organization_switcher"]:visible').click();
await page.getByText('Organization Settings').click();
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
}
async function createEmptyTimeEntry(page: Page) {
@@ -2303,3 +2303,21 @@ test('test that aggregate row context menu delete removes all grouped entries',
page.locator('[data-testid="time_entry_row"]').filter({ hasText: description })
).not.toBeVisible();
});
test('test that break entries show a break badge and split day total on the time page', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
await createTimeEntryViaApi(ctx, { duration: '2h', description: 'Some work' });
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break', description: '' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.getByTestId('break_badge').first()).toBeVisible();
await expect(page.getByTestId('break_badge').first()).toContainText('Break');
// Day heading shows worked time first, then the break portion
await expect(page.getByTestId('day_break_duration').first()).toBeVisible();
await expect(page.getByTestId('day_break_duration').first().locator('..')).toContainText(
'2h 00min work · 0h 30min break'
);
});

View File

@@ -2,7 +2,14 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api';
import {
createProjectViaApi,
createTaskViaApi,
createTimeEntryOnDateViaApi,
createTimeEntryWithTimestampsViaApi,
getTimeEntriesViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
@@ -639,3 +646,279 @@ test('cell accepts various duration input formats', async ({ page, ctx }) => {
// 1.5 hours = 1h 30min
await expect(mondayInput).toHaveValue('1h 30min');
});
test('test that adding a timesheet break to a full day splits the work entry via the placement modal', async ({
page,
ctx,
}) => {
// A single work entry filling the day leaves no gap for a break, so the placement
// modal must offer to split it (the only entry) and drop the break in the middle.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Split me',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
// The break row is always present — enter a 30m break on Monday
const breakRow = page
.locator('[data-testid="timesheet_row"]')
.filter({ has: page.getByText('Break', { exact: true }) });
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
await breakCell.click();
await breakCell.fill('0.5');
await breakCell.press('Enter');
// The placement modal opens with the split preview
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await Promise.all([
page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'break'
),
page.getByRole('button', { name: 'Add break' }).click(),
]);
// The day now has two work halves and one break, none overlapping
const entries = await getTimeEntriesViaApi(ctx);
const dayEntries = entries
.filter((e) => e.start.startsWith(day))
.sort((a, b) => a.start.localeCompare(b.start));
expect(dayEntries).toHaveLength(3);
expect(dayEntries.map((e) => e.type)).toEqual(['work', 'break', 'work']);
// The break sits flush between the two halves
expect(dayEntries[0].end).toBe(dayEntries[1].start);
expect(dayEntries[1].end).toBe(dayEntries[2].start);
});
test('test that adding a break into an oversized gap places it without moving other entries', async ({
page,
ctx,
}) => {
// 09-12 and 15-17 leave a 3h gap — wider than the placement tolerance allows,
// but easily big enough to hold the break. Such a gap is deliberate (the app
// itself never creates one), so the break goes flush after the morning entry
// and nothing else moves — no placement modal.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T12:00:00Z`,
description: 'Morning',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T15:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Afternoon',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakRow = page
.locator('[data-testid="timesheet_row"]')
.filter({ has: page.getByText('Break', { exact: true }) });
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
await breakCell.click();
await breakCell.fill('0.5');
await Promise.all([
page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'break'
),
breakCell.press('Enter'),
]);
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
const entries = await getTimeEntriesViaApi(ctx);
const dayEntries = entries
.filter((e) => e.start.startsWith(day))
.sort((a, b) => a.start.localeCompare(b.start));
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
['break', `${day}T12:00:00Z`, `${day}T12:30:00Z`],
['work', `${day}T15:00:00Z`, `${day}T17:00:00Z`],
]);
});
test('test that the placement modal warns when the chosen time would leave the break misaligned', async ({
page,
ctx,
}) => {
// Back-to-back 09-12 and 12-17 leave no gap, so the placement modal opens.
// The suggested slot (flush at 12:00) is aligned — no warning. Moving the
// break to 07:00, before any work, keeps the plan feasible but the result
// would immediately carry the misaligned hint, so the modal warns upfront.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T12:00:00Z`,
description: 'Morning',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T12:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Afternoon',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakRow = page
.locator('[data-testid="timesheet_row"]')
.filter({ has: page.getByText('Break', { exact: true }) });
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
await breakCell.click();
await breakCell.fill('0.5');
await breakCell.press('Enter');
// Default suggestion sits flush between work → no warning
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await expect(page.getByTestId('break_placement_misaligned_warning')).not.toBeVisible();
// Move the break to 07:00-07:30, before all work
const modal = page.getByRole('dialog');
const startTimeInput = modal.getByTestId('time_picker_input').first();
await startTimeInput.fill('07:00');
await startTimeInput.press('Tab');
const endTimeInput = modal.getByTestId('time_picker_input').nth(1);
await endTimeInput.fill('07:30');
await endTimeInput.press('Tab');
// Feasible (nothing has to move), but flagged as misaligned beforehand
await expect(page.getByTestId('break_placement_misaligned_warning')).toBeVisible();
await expect(page.getByTestId('break_placement_summary')).toContainText(
'No entries need to move.'
);
// The warning is non-blocking: the break can still be added as chosen
await Promise.all([
page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'break'
),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const entries = await getTimeEntriesViaApi(ctx);
const dayEntries = entries
.filter((e) => e.start.startsWith(day))
.sort((a, b) => a.start.localeCompare(b.start));
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['break', `${day}T07:00:00Z`, `${day}T07:30:00Z`],
['work', `${day}T09:00:00Z`, `${day}T12:00:00Z`],
['work', `${day}T12:00:00Z`, `${day}T17:00:00Z`],
]);
// ...and the timesheet now shows the misaligned-break hint for that day
await expect(
page.getByRole('button', { name: 'does not align with your work entries' })
).toBeVisible();
});
test('test that a misplaced break shows a warning on its timesheet day cell', async ({
page,
ctx,
}) => {
// Work ends at 10:00 and the break starts hours later with no work after it,
// so it is misplaced and its day header should carry the warning hint.
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T10:00:00Z`,
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T14:00:00Z`,
end: `${day}T14:30:00Z`,
type: 'break',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
// Exactly one warning, sitting in Monday's day header
const hint = page.getByRole('button', {
name: 'does not align with your work entries',
});
await expect(hint).toHaveCount(1);
await expect(
page.getByTestId('timesheet_day_header').first().getByRole('button', {
name: 'does not align with your work entries',
})
).toBeVisible();
// The hint links to the calendar on the affected date
await hint.click();
await expect(page.getByRole('link', { name: 'Fix in calendar' })).toHaveAttribute(
'href',
`/calendar?date=${day}`
);
});
test('test that editing a timesheet break re-places it as one entry instead of fragmenting it', async ({
page,
ctx,
}) => {
// Two work entries with a 1h gap, and a 30m break created directly inside it (12:1512:45).
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T12:00:00Z`,
description: 'Work',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T13:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Work',
});
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T12:15:00Z`,
end: `${day}T12:45:00Z`,
type: 'break',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakRow = page
.locator('[data-testid="timesheet_row"]')
.filter({ has: page.getByText('Break', { exact: true }) });
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
await breakCell.click();
await breakCell.fill('0.75'); // 45 minutes — still fits the 1h gap, so it stays anchored
await Promise.all([
// A break that still fits its gap is re-placed in place (PUT on the same entry),
// not deleted and recreated — that's what keeps it a single entry.
page.waitForResponse(
async (resp) =>
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
resp.request().method() === 'PUT' &&
resp.status() === 200 &&
(await resp.json()).data.type === 'break'
),
breakCell.press('Enter'),
]);
// Still exactly one break on the day (not fragmented). It stays anchored at its current
// start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes.
const after = await getTimeEntriesViaApi(ctx);
const breaks = after.filter((e) => e.start.startsWith(day) && e.type === 'break');
expect(breaks).toHaveLength(1);
expect(breaks[0].duration).toBe(2700);
expect(breaks[0].start).toBe(`${day}T12:15:00Z`);
expect(breaks[0].end).toBe(`${day}T13:00:00Z`);
});

View File

@@ -13,6 +13,7 @@ import {
createProjectViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
archiveProjectViaApi,
markTaskDoneViaApi,
updateOrganizationCurrencyViaWeb,
@@ -375,6 +376,66 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
await assertThatTimerIsStopped(page);
});
test('test that picking a recently tracked entry starts a timer with its fields', async ({
page,
ctx,
}) => {
const project = await createProjectViaApi(ctx, {
name: `RecentProj ${Math.floor(Math.random() * 100000)}`,
is_billable: false,
});
await createTimeEntryViaApi(ctx, {
description: 'Recent work item',
duration: '1h',
projectId: project.id,
});
await goToDashboard(page);
const description = page.getByTestId('time_entry_description');
await expect(description).toBeEditable();
// Focusing the description opens the "Recently Tracked" dropdown listing the finished entry.
await description.click();
const recentEntry = page.getByText('Recent work item').first();
await expect(recentEntry).toBeVisible();
// Clicking it (mousedown) copies its fields — including the project — into a new running entry.
await Promise.all([
page.waitForResponse(async (response) => {
if (
!response.url().includes('/time-entries') ||
response.request().method() !== 'POST' ||
response.status() !== 201
) {
return false;
}
const body = await response.json();
return (
body.data.description === 'Recent work item' &&
body.data.project_id === project.id &&
body.data.end === null
);
}),
recentEntry.click(),
]);
await assertThatTimerHasStarted(page);
await expect(description).toHaveValue('Recent work item');
await expect(page.getByRole('button', { name: project.name })).toBeVisible();
// Cleanup: stop the running (project-bearing) entry
await Promise.all([
page.waitForResponse(async (response) => {
if (response.status() !== 200 || !response.url().includes('/time-entries/')) {
return false;
}
const body = await response.json();
return body.data.description === 'Recent work item' && body.data.end !== null;
}),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
page,
ctx,
@@ -681,3 +742,39 @@ test.describe('Project Task Dropdown', () => {
await expect(page.getByRole('button', { name: projectName })).toBeVisible();
});
});
test('test that simple mode hides the project, tag and billable controls', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
// Project mode shows the project and billable controls
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Non Billable' }).first()).toBeVisible();
// Switch to simple mode via the more options dropdown (client-side preference, no request)
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Switch to simple mode' }).click();
// Simple mode is the project tracker without the project/tag/billable selectors; the
// description input and clock-in/out stay.
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await expect(page.getByRole('button', { name: 'No Project' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Non Billable' })).toHaveCount(0);
// Clock in and out
await Promise.all([
newTimeEntryResponse(page, { type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
// Switch back to project mode: the controls return
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Switch to project mode' }).click();
await expect(page.getByRole('button', { name: 'No Project' })).toBeVisible();
});

View File

@@ -406,6 +406,7 @@ export async function createTimeEntryViaApi(
taskId?: string | null;
tags?: string[];
billable?: boolean;
type?: 'work' | 'break';
}
) {
const { start, end } = createTimestamps(data.duration);
@@ -421,6 +422,7 @@ export async function createTimeEntryViaApi(
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
type: data.type ?? 'work',
},
}
);
@@ -754,6 +756,7 @@ export async function getTimeEntriesViaApi(
project_id: string | null;
task_id: string | null;
description: string;
type: 'work' | 'break';
}>
> {
const params = new URLSearchParams();
@@ -779,6 +782,7 @@ export async function createTimeEntryWithTimestampsViaApi(
taskId?: string | null;
tags?: string[];
billable?: boolean;
type?: 'work' | 'break';
}
) {
const response = await ctx.request.post(
@@ -793,12 +797,19 @@ export async function createTimeEntryWithTimestampsViaApi(
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
type: data.type ?? 'work',
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
return body.data as {
id: string;
start: string;
end: string;
description: string;
type: 'work' | 'break';
};
}
// ──────────────────────────────────────────────────
@@ -903,3 +914,71 @@ export async function createReportViaApi(
public_until: string | null;
};
}
// ──────────────────────────────────────────────────
// Invoices
// ──────────────────────────────────────────────────
export async function createInvoiceViaApi(
ctx: TestContext,
data: {
reference: string;
buyer_name?: string;
seller_name?: string;
currency?: string;
date?: string;
tax_rate?: number;
}
) {
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`,
{
data: {
seller_name: data.seller_name ?? 'Test Seller',
buyer_name: data.buyer_name ?? 'Test Buyer',
reference: data.reference,
currency: data.currency ?? 'EUR',
date: data.date ?? new Date().toISOString().split('T')[0],
// Mirror the UI create form, which always sends a tax rate (default 0).
// Invoices with a null tax_rate currently crash PDF rendering.
tax_rate: data.tax_rate ?? 0,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; reference: string; buyer_name: string };
}
export async function updateInvoiceSettingsViaApi(ctx: TestContext, data: Record<string, unknown>) {
const response = await ctx.request.put(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`,
{ data }
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Record<string, unknown>;
}
export async function getInvoiceSettingsViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoice-settings`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Record<string, unknown>;
}
export async function getInvoicesViaApi(ctx: TestContext) {
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/invoices`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data as Array<{
id: string;
reference: string;
buyer_name: string;
paid_date: string | null;
}>;
}

View File

@@ -20,7 +20,17 @@ export async function assertThatTimerHasStarted(page: Page) {
export function newTimeEntryResponse(
page: Page,
{ description = '', status = 201, tags = [] } = {}
{
description = '',
status = 201,
tags = [],
type,
}: {
description?: string;
status?: number;
tags?: string[];
type?: 'work' | 'break';
} = {}
) {
return page.waitForResponse(async (response) => {
return (
@@ -34,6 +44,7 @@ export function newTimeEntryResponse(
(await response.json()).data.description === description &&
(await response.json()).data.task_id === null &&
(await response.json()).data.user_id !== null &&
(type === undefined || (await response.json()).data.type === type) &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
);
});
@@ -48,7 +59,18 @@ export async function assertThatTimerIsStopped(page: Page) {
).toHaveClass(/bg-accent-300\/70/);
}
export async function stoppedTimeEntryResponse(page: Page, { description = '', tags = [] } = {}) {
export async function stoppedTimeEntryResponse(
page: Page,
{
description = '',
tags = [],
type,
}: {
description?: string;
tags?: string[];
type?: 'work' | 'break';
} = {}
) {
return page.waitForResponse(async (response) => {
return (
response.status() === 200 &&
@@ -62,6 +84,7 @@ export async function stoppedTimeEntryResponse(page: Page, { description = '', t
(await response.json()).data.task_id === null &&
(await response.json()).data.duration !== null &&
(await response.json()).data.user_id !== null &&
(type === undefined || (await response.json()).data.type === type) &&
JSON.stringify((await response.json()).data.tags) === JSON.stringify(tags)
);
});

14
extensions/manifest.json Normal file
View File

@@ -0,0 +1,14 @@
{
"Billing": {
"repository": "solidtime-io/extension-billing",
"ref": "v0.0.1"
},
"Services": {
"repository": "solidtime-io/extension-services",
"ref": "v0.0.1"
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.1"
}
}

4
package-lock.json generated
View File

@@ -8396,7 +8396,7 @@
},
"resources/js/packages/api": {
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.7",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.5.4"
@@ -8411,7 +8411,7 @@
},
"resources/js/packages/ui": {
"name": "@solidtime/ui",
"version": "0.0.21",
"version": "0.0.22",
"license": "AGPL-3.0",
"devDependencies": {
"@types/chroma-js": "^3.1.2",

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check, Coffee } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
@@ -14,6 +17,7 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -22,7 +26,9 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
const entryType = defineModel<'work' | 'break' | null>('entryType', { required: true });
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
const roundingMinutes = defineModel<number>('roundingMinutes', { required: true });
@@ -33,8 +39,20 @@ const emit = defineEmits<{
submit: [];
}>();
const breaksEnabled = useBreaksEnabled();
const { tags } = useTagsQuery();
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Does Not Contain' },
];
function selectTagMatchType(value: AcceptableValue) {
tagMatchType.value = value as TagMatchType;
emit('submit');
}
async function createTag(name: string) {
return await useTagsStore().createTag(name);
}
@@ -93,6 +111,34 @@ async function createTag(name: string) {
title="Tags"
:icon="TagIcon" />
</template>
<template #content-before-list>
<div class="mt-2 border-b border-card-background-separator pb-2">
<div
id="tag-match-type-label"
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
Match
</div>
<RadioGroupRoot
:model-value="tagMatchType"
aria-labelledby="tag-match-type-label"
class="space-y-1"
@update:model-value="selectTagMatchType">
<RadioGroupItem
v-for="option in tagMatchOptions"
:key="option.value"
:value="option.value"
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
{{ option.label }}
<span
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadioGroupIndicator>
<Check class="h-4 w-4" />
</RadioGroupIndicator>
</span>
</RadioGroupItem>
</RadioGroupRoot>
</div>
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">
@@ -120,6 +166,38 @@ async function createTag(name: string) {
<SelectItem value="false">Non Billable</SelectItem>
</SelectContent>
</Select>
<Select
v-if="breaksEnabled"
v-model="entryType"
@update:model-value="emit('submit')">
<SelectTrigger
size="sm"
variant="outline"
:active="entryType !== null"
:show-chevron="false">
<SelectValue class="flex items-center gap-2">
<Coffee
class="h-4 w-4"
:class="
entryType !== null
? 'dark:text-accent-300/80 text-accent-400/80'
: 'text-text-quaternary'
" />
<span class="text-text-secondary">{{
entryType === null
? 'Type'
: entryType === 'break'
? 'Breaks'
: 'Work time'
}}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Both</SelectItem>
<SelectItem value="work">Work time</SelectItem>
<SelectItem value="break">Breaks</SelectItem>
</SelectContent>
</Select>
<ReportingRoundingControls
v-model:enabled="roundingEnabled"
v-model:type="roundingType"

View File

@@ -49,6 +49,7 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -67,8 +68,10 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null);
const entryType = ref<'work' | 'break' | null>('work');
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
@@ -122,7 +125,9 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
type: entryType.value !== null ? entryType.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
@@ -157,7 +162,7 @@ const aggregatedTableTimeEntries = computed<AggregatedTimeEntries | undefined>((
});
const reportProperties = computed(() => {
const { billable: billableFilter, ...rest } = filterParams.value;
const { billable: billableFilter, type: typeFilter, ...rest } = filterParams.value;
let billableValue: boolean | null = null;
if (billableFilter === 'true') {
@@ -169,6 +174,7 @@ const reportProperties = computed(() => {
return {
...rest,
billable: billableValue,
time_entry_type: typeFilter ?? null,
group: group.value,
sub_group: subGroup.value,
history_group: getOptimalGroupingOption(startDate.value, endDate.value),
@@ -366,7 +372,9 @@ const tableData = computed(() => {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable"
v-model:entry-type="entryType"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"
v-model:rounding-minutes="roundingMinutes"

View File

@@ -28,6 +28,7 @@ const {
},
queries: {
member_id: getCurrentMembershipId(),
type: 'work',
},
});
},

View File

@@ -62,6 +62,8 @@ const queryParams = computed<AggregatedTimeEntriesQueryParams>(() => {
group: group.value,
sub_group: subGroup.value,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
// Breaks are excluded from all dashboard stats (see DashboardService workTime())
type: 'work',
};
});

View File

@@ -4,13 +4,13 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
import { usePage } from '@inertiajs/vue3';
import { type User } from '@/types/models';
import { computed, onMounted, watch } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import duration from 'dayjs/plugin/duration';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { getLastWorkTimeEntry, useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { storeToRefs } from 'pinia';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { useLocalStorage } from '@vueuse/core';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { switchOrganization } from '@/utils/useOrganization';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
@@ -20,6 +20,7 @@ import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsStore } from '@/utils/useTags';
import { useProjectsStore } from '@/utils/useProjects';
import TimeTrackerControls from '@/packages/ui/src/TimeTracker/TimeTrackerControls.vue';
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
import type {
CreateClientBody,
CreateProjectBody,
@@ -44,15 +45,15 @@ const page = usePage<{
user: User;
};
}>();
dayjs.extend(duration);
dayjs.extend(utc);
const dayjs = getDayJsInstance();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const breaksEnabled = useBreaksEnabled(organization);
const currentTimeEntryStore = useCurrentTimeEntryStore();
const { currentTimeEntry, isActive, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState } = currentTimeEntryStore;
const { currentTimeEntry, isActive, isOnBreak, now } = storeToRefs(currentTimeEntryStore);
const { startLiveTimer, stopLiveTimer, setActiveState, startBreak, resumeWorkAfterBreak } =
currentTimeEntryStore;
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
@@ -67,6 +68,8 @@ const showManualTimeEntryModal = ref(false);
const { createTimeEntry: createTimeEntryMutation, deleteTimeEntry } = useTimeEntriesMutations();
const { data: timeEntriesData } = useTimeEntriesInfiniteQuery();
const timeEntries = computed(() => timeEntriesData.value?.pages.flatMap((page) => page.data) || []);
const lastWorkTimeEntry = computed(() => getLastWorkTimeEntry(timeEntries.value));
const canResumeAfterBreak = computed(() => lastWorkTimeEntry.value !== null);
watch(isActive, () => {
if (isActive.value) {
@@ -123,6 +126,14 @@ async function createTimeEntry(timeEntry: Omit<CreateTimeEntryBody, 'member_id'>
showManualTimeEntryModal.value = false;
}
async function resumePreviousWorkAfterBreak() {
const timeEntry = lastWorkTimeEntry.value;
if (!timeEntry) {
return;
}
await resumeWorkAfterBreak(timeEntry);
}
async function createTimeEntryFromCurrentEntry() {
const { start, end, description, project_id, task_id, billable, tags } = currentTimeEntry.value;
await createTimeEntry({ start, end, description, project_id, task_id, billable, tags });
@@ -142,6 +153,16 @@ async function discardCurrentTimeEntry() {
}
}
// Time tracker UI mode is a per-device UI preference, stored client-side and keyed by organization
const timeTrackerMode = useLocalStorage<TimeTrackerMode>(
`solidtime/time-tracker-mode/${getCurrentOrganizationId()}`,
'project'
);
function toggleTimeTrackerMode() {
timeTrackerMode.value = timeTrackerMode.value === 'simple' ? 'project' : 'simple';
}
const { tags } = useTagsQuery();
</script>
@@ -186,17 +207,29 @@ const { tags } = useTagsQuery();
:time-entries
:create-tag
:is-active
:is-on-break="isOnBreak"
:breaks-enabled="breaksEnabled"
:can-resume-after-break="canResumeAfterBreak"
:resume-description="lastWorkTimeEntry?.description ?? null"
:time-tracker-mode="timeTrackerMode"
:currency="getOrganizationCurrencyString()"
@start-live-timer="startLiveTimer"
@stop-live-timer="stopLiveTimer"
@start-timer="setActiveState(true)"
@stop-timer="setActiveState(false)"
@start-break="startBreak"
@resume-after-break="resumePreviousWorkAfterBreak"
@update-time-entry="updateTimeEntry"
@create-time-entry="createTimeEntryFromCurrentEntry"></TimeTrackerControls>
</div>
<TimeTrackerMoreOptionsDropdown
:has-active-timer="isActive"
:time-tracker-mode="timeTrackerMode"
:is-on-break="isOnBreak"
:breaks-enabled="breaksEnabled"
@manual-entry="showManualTimeEntryModal = true"
@start-break="startBreak"
@toggle-time-tracker-mode="toggleTimeTrackerMode"
@discard="discardCurrentTimeEntry"></TimeTrackerMoreOptionsDropdown>
</div>
</div>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import { formatTime, getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { Coffee } from '@lucide/vue';
import { computed, inject, ref, watch, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
import {
BREAK_GAP_TOLERANCE_SECONDS,
placementMode,
planMoveInsert,
planSplitEntry,
type BreakPlacementRequest,
} from '@/utils/timesheet/breakPlacementMath';
import { BREAK_GAP_TOLERANCE_MINUTES } from '@/packages/ui/src/utils/breakPlacement';
const props = defineProps<{
request: BreakPlacementRequest | null;
apply: (breakStart: string, durationSeconds: number) => Promise<void>;
}>();
const emit = defineEmits<{ cancel: [] }>();
const organization = inject<ComputedRef<Organization>>('organization');
const show = computed(() => props.request !== null);
const mode = computed(() => (props.request ? placementMode(props.request) : null));
const saving = ref(false);
const localStart = ref('');
const localEnd = ref('');
// Seed the pickers from the suggested placement whenever a new request arrives.
watch(
() => props.request,
(request) => {
if (!request) return;
localStart.value = getLocalizedDayJs(request.defaultBreakStart).format();
localEnd.value = getLocalizedDayJs(request.defaultBreakStart)
.add(request.durationSeconds, 'second')
.format();
},
{ immediate: true }
);
const utcStart = computed(() => getLocalizedDayJs(localStart.value).utc().format());
const durationSeconds = computed(() =>
getLocalizedDayJs(localEnd.value)
.utc()
.diff(getLocalizedDayJs(localStart.value).utc(), 'second')
);
const splitPlan = computed(() => {
if (!props.request || mode.value !== 'split' || durationSeconds.value <= 0) return null;
return planSplitEntry(props.request.workEntries[0]!, durationSeconds.value, utcStart.value);
});
const movePlan = computed(() => {
if (!props.request || mode.value !== 'move' || durationSeconds.value <= 0) return null;
return planMoveInsert(
[...props.request.workEntries, ...props.request.otherEntries],
props.request.dayStart,
props.request.dayEnd,
utcStart.value,
durationSeconds.value
);
});
// Non-blocking heads-up: the placement is feasible but the break would end up
// further than the tolerance from work on either side, so it would carry the
// misaligned warning right after being created. Mirrors getBreakPlacementHint,
// but computed against the planned (post-shift) layout.
const resultMisaligned = computed<boolean>(() => {
const req = props.request;
const plan = movePlan.value;
if (!req || mode.value !== 'move' || !plan) return false;
const dayjs = getDayJsInstance();
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
const breakStartMs = toMs(plan.breakSlot.start);
const breakEndMs = toMs(plan.breakSlot.end);
const shiftedById = new Map(plan.shifted.map((s) => [s.id, s]));
let prevWorkEndMs: number | null = null;
let nextWorkStartMs: number | null = null;
for (const entry of req.workEntries) {
const planned = shiftedById.get(entry.id) ?? entry;
const startMs = toMs(planned.start);
const endMs = toMs(planned.end);
if (endMs <= breakStartMs && (prevWorkEndMs === null || endMs > prevWorkEndMs)) {
prevWorkEndMs = endMs;
}
if (startMs >= breakEndMs && (nextWorkStartMs === null || startMs < nextWorkStartMs)) {
nextWorkStartMs = startMs;
}
}
const toleranceMs = BREAK_GAP_TOLERANCE_SECONDS * 1000;
return (
prevWorkEndMs === null ||
breakStartMs - prevWorkEndMs > toleranceMs ||
nextWorkStartMs === null ||
nextWorkStartMs - breakEndMs > toleranceMs
);
});
const feasible = computed(() =>
mode.value === 'split' ? splitPlan.value !== null : movePlan.value !== null
);
function fmt(iso: string): string {
return formatTime(iso, organization?.value?.time_format);
}
const explanation = computed(() => {
if (!props.request) return '';
return mode.value === 'split'
? "There's no free gap that fits this break, so the work entry will be split and the break placed inside it."
: "There's no free gap that fits this break, so the surrounding entries will be shifted to make room.";
});
// Human-readable summary of what will change, so the user can confirm the edit.
const changeSummary = computed<string[]>(() => {
if (mode.value === 'split') {
const plan = splitPlan.value;
if (!plan) return [];
return [
`${fmt(plan.firstHalf.start)}${fmt(plan.firstHalf.end)} (work)`,
`${fmt(plan.breakSlot.start)}${fmt(plan.breakSlot.end)} (break)`,
`${fmt(plan.secondHalf.start)}${fmt(plan.secondHalf.end)} (work)`,
];
}
const plan = movePlan.value;
if (!plan) return [];
if (plan.shifted.length === 0) return ['No entries need to move.'];
return plan.shifted.map((shift) => {
const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id);
const original =
props.request!.workEntries.find((e) => e.id === shift.id) ??
props.request!.otherEntries.find((e) => e.id === shift.id)!;
const label = `${fmt(original.start)}${fmt(original.end)}${fmt(shift.start)}${fmt(shift.end)}`;
return isBreak ? `${label} (break)` : label;
});
});
async function submit() {
if (!feasible.value || durationSeconds.value <= 0) return;
saving.value = true;
try {
await props.apply(utcStart.value, durationSeconds.value);
} catch {
// apply surfaces its own error toast; keep the modal open so the user can retry
} finally {
saving.value = false;
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="emit('cancel')">
<template #title>
<div class="flex items-center space-x-2">
<Coffee class="w-5 h-5 text-text-secondary" />
<span>Add break</span>
</div>
</template>
<template #content>
<div class="space-y-4">
<p class="text-sm text-text-secondary">{{ explanation }}</p>
<TimeRangeFields
v-model:start="localStart"
v-model:end="localEnd"
date-picker-size="sm"></TimeRangeFields>
<div
v-if="feasible"
data-testid="break_placement_summary"
class="rounded-lg border border-card-border bg-secondary/40 px-3 py-2 text-sm text-text-secondary space-y-1">
<div class="text-xs uppercase tracking-wide text-text-tertiary">
{{ mode === 'split' ? 'Result' : 'Entries that move' }}
</div>
<div v-for="(line, index) in changeSummary" :key="index" class="tabular-nums">
{{ line }}
</div>
</div>
<div
v-if="feasible && resultMisaligned"
data-testid="break_placement_misaligned_warning"
class="rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
At this time the break would sit more than
{{ BREAK_GAP_TOLERANCE_MINUTES }} minutes away from your work entries and will
be flagged as misaligned.
</div>
<!-- `request` guard (not just !feasible): when the request is cleared on save,
the dialog fades out with content still mounted don't flash the error then -->
<div
v-if="!feasible && request"
data-testid="break_placement_infeasible"
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
{{
mode === 'split'
? "This break doesn't fit there it must lie inside the work entry, leaving at least a minute of work on each side."
: "This break doesn't fit at that time without pushing an entry outside the day. Try a shorter break or a different time."
}}
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="emit('cancel')">Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving || !feasible }"
:disabled="saving || !feasible"
@click="submit">
Add break
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -93,4 +93,26 @@ describe('TimesheetCell', () => {
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
});
it('renders read-only and emits nothing when the row is read-only', async () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
readonly: true,
},
});
const input = wrapper.get('input');
expect((input.element as HTMLInputElement).disabled).toBe(true);
await input.trigger('focus');
await input.setValue('4h');
await input.trigger('blur');
expect(wrapper.emitted('update')).toBeUndefined();
});
});

View File

@@ -18,6 +18,7 @@ const props = defineProps<{
date: string;
isToday: boolean;
hasRunningEntry: boolean;
readonly?: boolean;
saveStatus?: CellSaveStatus;
pendingSeconds?: number;
}>();
@@ -30,6 +31,16 @@ const emit = defineEmits<{
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
const isSaving = computed(() => props.saveStatus === 'saving');
// A cell is non-editable while its entry is running or when the row itself is
// read-only (e.g. a leftover break row after breaks were disabled). Both render
// the same disabled input, differing only in the tooltip explanation.
const isReadonly = computed(() => props.hasRunningEntry || props.readonly === true);
const readonlyTooltip = computed(() =>
props.hasRunningEntry
? 'Stop the running time entry to edit the timesheet'
: 'Breaks are disabled for this organization'
);
// Swap the border color (don't layer) to avoid same-specificity fights.
const inputClass = computed(() => {
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
@@ -51,7 +62,7 @@ const inputClass = computed(() => {
data-testid="timesheet_cell"
class="flex items-center justify-center border-t border-default-background-separator"
:class="{ 'bg-default-background': isToday }">
<TooltipProvider v-if="hasRunningEntry" :delay-duration="100">
<TooltipProvider v-if="isReadonly" :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-block cursor-not-allowed">
@@ -68,7 +79,7 @@ const inputClass = computed(() => {
disabled:opacity-50 disabled:cursor-not-allowed" />
</span>
</TooltipTrigger>
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
<TooltipContent>{{ readonlyTooltip }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<template v-else>

View File

@@ -2,6 +2,9 @@
import { inject, type ComputedRef } from 'vue';
import { Button } from '@/packages/ui/src/Buttons';
import { PlusIcon } from '@heroicons/vue/20/solid';
import { ExclamationTriangleIcon, ArrowRightIcon } from '@heroicons/vue/16/solid';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
import { Link } from '@inertiajs/vue3';
import TimesheetRow from '@/Components/Timesheet/TimesheetRow.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
@@ -26,6 +29,8 @@ defineProps<{
todayDate: string;
dayTotals: number[];
weekTotalFormatted: string;
breakDayTotals: number[];
breakGrandTotal: number;
projects: Project[];
tasks: Task[];
clients: Client[];
@@ -39,6 +44,7 @@ defineProps<{
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
misplacedBreakDates?: Set<string>;
}>();
const emit = defineEmits<{
@@ -74,9 +80,34 @@ const emit = defineEmits<{
<div
v-for="day in weekDays"
:key="day"
data-testid="timesheet_day_header"
class="bg-background dark:bg-secondary px-2 py-1 text-center">
<div class="text-xs font-medium text-text-secondary">
{{ dayjs(day).format('ddd D') }}
<div
class="flex items-center justify-center gap-1 text-xs font-medium text-text-secondary">
<span>{{ dayjs(day).format('ddd D') }}</span>
<DropdownMenu v-if="misplacedBreakDates?.has(day)">
<DropdownMenuTrigger as-child>
<button
type="button"
title="A break on this day does not align with your work entries"
class="flex items-center justify-center shrink-0 rounded-full p-0.5 text-amber-500 hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ExclamationTriangleIcon class="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[240px]" align="start">
<div class="px-3 py-2 space-y-1.5">
<p class="text-xs text-text-secondary">
A break on this day is not directly between work entries.
</p>
<Link
:href="`/calendar?date=${day}`"
class="inline-flex items-center gap-1 text-sm font-medium text-accent-400 hover:underline">
Fix in calendar
<ArrowRightIcon class="w-3.5 h-3.5" />
</Link>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
@@ -85,7 +116,7 @@ const emit = defineEmits<{
</div>
<div class="bg-background dark:bg-secondary"></div>
<!-- Data rows -->
<!-- Data rows (break row is pinned last) -->
<TimesheetRow
v-for="row in rows"
:key="row.key"
@@ -140,9 +171,9 @@ const emit = defineEmits<{
</TimeTrackerProjectTaskDropdown>
</div>
<!-- Totals row -->
<!-- Totals row: worked time, with break time annotated below (calendar-style) -->
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
class="flex items-center border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
Total
</div>
<div
@@ -150,18 +181,32 @@ const emit = defineEmits<{
:key="dayIndex"
data-testid="timesheet_day_total"
:class="[
'flex items-center justify-center border-t border-default-background-separator bg-background dark:bg-secondary px-2 py-1 text-xs font-medium',
'flex flex-col items-center justify-center border-t border-default-background-separator bg-background dark:bg-secondary px-2 py-1 text-xs font-medium leading-tight',
weekDays[dayIndex] === todayDate
? 'text-text-primary'
: 'text-text-secondary',
]">
<span class="w-[80px] text-center">
{{ total > 0 ? formatDuration(total) : '-' }}
</span>
<span
>{{ formatDuration(total)
}}<template v-if="total > 0 && (breakDayTotals[dayIndex] ?? 0) > 0">
work</template
></span
>
<span
v-if="(breakDayTotals[dayIndex] ?? 0) > 0"
class="font-normal text-text-tertiary"
>{{ formatDuration(breakDayTotals[dayIndex] ?? 0) }} break</span
>
</div>
<div
class="flex items-center justify-end border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary">
{{ weekTotalFormatted }}
class="flex flex-col items-end justify-center border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary leading-tight">
<span
>{{ weekTotalFormatted
}}<template v-if="breakGrandTotal > 0"> work</template></span
>
<span v-if="breakGrandTotal > 0" class="font-normal text-text-tertiary"
>{{ formatDuration(breakGrandTotal) }} break</span
>
</div>
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary"></div>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Coffee } from '@lucide/vue';
import TimesheetCell from './TimesheetCell.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
@@ -22,6 +24,7 @@ import {
import { Button } from '@/packages/ui/src/Buttons';
const organization = inject<ComputedRef<Organization>>('organization');
const breaksEnabled = useBreaksEnabled();
const props = defineProps<{
row: TimesheetRow;
@@ -62,6 +65,11 @@ const selectedTask = computed({
const rowTotalFormatted = computed(() => props.formatDuration(props.row.totalSeconds));
// A break row can survive after breaks are disabled (its entries are
// grandfathered). Those cells become read-only — creating/editing break time is
// rejected server-side — leaving the remove button as the only action.
const cellsReadonly = computed(() => props.row.type === 'break' && !breaksEnabled.value);
function hasRunningEntry(dayIndex: number): boolean {
const cell = props.row.cells.get(dayIndex);
if (!cell) return false;
@@ -74,7 +82,13 @@ function hasRunningEntry(dayIndex: number): boolean {
<!-- Project/Task column -->
<div
class="flex items-center gap-1 border-t border-default-background-separator bg-default-background pl-4 pr-3 py-2 md:sticky md:left-0 md:z-10">
<div class="flex-1 min-w-0">
<div
v-if="row.type === 'break'"
class="flex flex-1 items-center gap-1.5 min-w-0 px-2 py-1 text-sm text-text-secondary">
<Coffee class="w-4 h-4" />
<span>Break</span>
</div>
<div v-else class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="selectedProject"
v-model:task="selectedTask"
@@ -94,11 +108,13 @@ function hasRunningEntry(dayIndex: number): boolean {
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<TimeEntryRowTagDropdown
v-if="row.type !== 'break'"
:create-tag="createTag"
:tags="tags"
:model-value="row.tags"
@changed="emit('tagsChange', $event)" />
<BillableToggleButton
v-if="row.type !== 'break'"
:model-value="row.billable"
size="small"
faded
@@ -115,6 +131,7 @@ function hasRunningEntry(dayIndex: number): boolean {
:date="day"
:is-today="day === todayDate"
:has-running-entry="hasRunningEntry(dayIndex)"
:readonly="cellsReadonly"
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
@@ -126,10 +143,11 @@ function hasRunningEntry(dayIndex: number): boolean {
{{ rowTotalFormatted }}
</div>
<!-- Remove action -->
<!-- Remove action (the break row is permanent while breaks are enabled) -->
<div
class="flex items-center justify-center border-t border-default-background-separator pr-4 py-3">
<Button
v-if="!(row.type === 'break' && breaksEnabled)"
variant="ghost"
size="icon"
aria-label="Remove row"

View File

@@ -2,10 +2,17 @@
import { BellAlertIcon, XMarkIcon } from '@heroicons/vue/20/solid';
import { SecondaryButton } from '@/packages/ui/src';
import { useStorage } from '@vueuse/core';
const showReleaseInfo = useStorage('showReleaseInfo-desktop', true);
import { router } from '@inertiajs/vue3';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { canUpdateOrganization } from '@/utils/permissions';
const showReleaseInfo = useStorage('showReleaseInfo-breaks', true);
function openDesktopGithubRepo() {
window.open('https://github.com/solidtime-io/solidtime-desktop', '_blank')?.focus();
function openOrganizationSettings() {
router.visit(route('organizations.show', getCurrentOrganizationId()));
}
function openBreaksDocs() {
window.open('https://docs.solidtime.io/user-guide/breaks', '_blank')?.focus();
}
</script>
@@ -16,7 +23,7 @@ function openDesktopGithubRepo() {
<div
class="text-xs pb-1.5 font-semibold text-text-tertiary flex items-center space-x-1">
<BellAlertIcon class="w-3.5"></BellAlertIcon>
<span> New Update </span>
<span> New Feature </span>
</div>
<button>
<XMarkIcon
@@ -26,14 +33,22 @@ function openDesktopGithubRepo() {
</div>
<p class="text-xs">
<span class="font-semibold">Solidtime Desktop Beta</span> is here! Test our brand
new clients for Windows, macOS and Linux now.
<span class="font-semibold">Breaks</span> are here! Enable them in the organization
settings to track break time in the time tracker and timesheet.
</p>
<SecondaryButton
v-if="canUpdateOrganization()"
size="small"
class="w-full text-center justify-center mt-1.5"
@click="openDesktopGithubRepo"
>Download now</SecondaryButton
@click="openOrganizationSettings"
>Enable now</SecondaryButton
>
<SecondaryButton
v-else
size="small"
class="w-full text-center justify-center mt-1.5"
@click="openBreaksDocs"
>Learn more</SecondaryButton
>
</div>
</div>

View File

@@ -31,6 +31,9 @@ const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const calendarStart = ref<Dayjs | undefined>(undefined);
const calendarEnd = ref<Dayjs | undefined>(undefined);
// Optional deep link (e.g. "Fix in calendar") that opens the calendar on a specific day
const initialDate = new URLSearchParams(window.location.search).get('date');
// Test-injectable activity periods (for E2E testing).
// These hooks are no-ops in production — they only take effect when test code
// explicitly sets window globals, so they are safe to ship.
@@ -128,6 +131,7 @@ function onRefresh() {
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:initial-date="initialDate"
:organization-billable-rate="organization?.billable_rate ?? null"
:create-time-entry="createTimeEntry"
:update-time-entry="updateTimeEntry"

View File

@@ -54,6 +54,7 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import type { TagMatchType } from '@/types/reporting';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -71,7 +72,9 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null);
const entryType = ref<'work' | 'break' | null>('work');
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
const roundingMinutes = ref<number>(15);
@@ -102,7 +105,9 @@ function getFilterAttributes() {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
type: entryType.value !== null ? entryType.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
};
@@ -324,7 +329,9 @@ async function downloadExport(format: ExportFormat) {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable"
v-model:entry-type="entryType"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"
v-model:rounding-minutes="roundingMinutes"

View File

@@ -17,15 +17,18 @@ const queryClient = useQueryClient();
const form = ref<{
prevent_overlapping_time_entries: boolean;
employees_can_manage_tasks: boolean;
breaks_enabled: boolean;
}>({
prevent_overlapping_time_entries: false,
employees_can_manage_tasks: false,
breaks_enabled: false,
});
onMounted(async () => {
form.value.prevent_overlapping_time_entries =
organization.value?.prevent_overlapping_time_entries ?? false;
form.value.employees_can_manage_tasks = organization.value?.employees_can_manage_tasks ?? false;
form.value.breaks_enabled = organization.value?.breaks_enabled ?? false;
});
const mutation = useMutation({
@@ -39,6 +42,7 @@ async function submit() {
await mutation.mutateAsync({
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
employees_can_manage_tasks: form.value.employees_can_manage_tasks,
breaks_enabled: form.value.breaks_enabled,
});
}
</script>
@@ -69,6 +73,10 @@ async function submit() {
>Allow Employees to manage tasks</FieldLabel
>
</Field>
<Field orientation="horizontal">
<Checkbox id="breaksEnabled" v-model:checked="form.breaks_enabled" />
<FieldLabel for="breaksEnabled">Allow tracking breaks</FieldLabel>
</Field>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import AppLayout from '@/Layouts/AppLayout.vue';
import TimeTracker from '@/Components/TimeTracker.vue';
import { router } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import MainContainer from '@/packages/ui/src/MainContainer.vue';
import { storeToRefs } from 'pinia';
@@ -102,6 +103,11 @@ function deleteSelected() {
deleteTimeEntries(selectedTimeEntries.value);
selectedTimeEntries.value = [];
}
// SPA-navigate the calendar to a break's day so its placement can be fixed there.
function goToCalendarDay(date: string) {
router.visit(`/calendar?date=${date}`);
}
</script>
<template>
@@ -153,6 +159,7 @@ function deleteSelected() {
:currency="getOrganizationCurrencyString()"
:time-entries="timeEntries"
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
:fix-in-calendar="goToCalendarDay"
:tags="tags"></TimeEntryGroupedTable>
<div v-if="isPending" class="flex justify-center items-center py-12">
<LoadingSpinner></LoadingSpinner>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import AppLayout from '@/Layouts/AppLayout.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import TimesheetHeader from '@/Components/Timesheet/TimesheetHeader.vue';
import TimesheetGrid from '@/Components/Timesheet/TimesheetGrid.vue';
import TimesheetFooterActions from '@/Components/Timesheet/TimesheetFooterActions.vue';
import RemoveRowDialog from '@/Components/Timesheet/RemoveRowDialog.vue';
import BreakPlacementModal from '@/Components/Timesheet/BreakPlacementModal.vue';
import { useTimesheetQuery } from '@/utils/useTimesheetQuery';
import { useTimesheetGrid } from '@/utils/useTimesheetGrid';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
@@ -22,7 +24,12 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import {
formatHumanReadableDuration,
getLocalizedDateFromTimestamp,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
@@ -45,8 +52,17 @@ const {
} = useTimesheetWeek();
// ── Data fetching ─────────────────────────────────────────────────
// The query fetches one padding day on each side of the week so that entries
// crossing midnight at the week edges are known to the break-placement solver.
const { data, isPending } = useTimesheetQuery(weekStart, weekEnd);
const timeEntries = computed(() => data.value?.data ?? []);
const allTimeEntries = computed(() => data.value?.data ?? []);
// The grid and week-scoped features only see entries starting in the visible week.
const timeEntries = computed(() => {
const weekDaySet = new Set(weekDays.value);
return allTimeEntries.value.filter((entry) =>
weekDaySet.has(getLocalizedDateFromTimestamp(entry.start))
);
});
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
@@ -56,19 +72,31 @@ const { now: currentTimerNow } = storeToRefs(useCurrentTimeEntryStore());
const mutations = useTimeEntriesMutations();
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const breaksEnabled = useBreaksEnabled(organization);
// ── Grid computation ──────────────────────────────────────────────
const { rows, dayTotals, grandTotal, addSlot, removeSlot, updateSlot, clearSlots } =
useTimesheetGrid(timeEntries, weekDays, projects, tasks, currentTimerNow);
const {
rows,
dayTotals,
grandTotal,
breakDayTotals,
breakGrandTotal,
addSlot,
removeSlot,
updateSlot,
clearSlots,
} = useTimesheetGrid(timeEntries, weekDays, projects, tasks, currentTimerNow, breaksEnabled);
// Wipe slots on week navigation so the new week starts fresh — the
// grid's watcher will reseed from the newly fetched entries.
watch(weekStart, () => clearSlots());
// flush: 'sync' so the wipe happens the moment weekStart is assigned, BEFORE
// the same flush recomputes `timeEntries` (it depends on weekDays) and lets
// the grid seed the new week — otherwise a cached (prefetched) week seeds
// first, gets wiped here, and nothing re-triggers the seeding afterwards.
watch(weekStart, () => clearSlots(), { flush: 'sync' });
// ── Formatters ────────────────────────────────────────────────────
// Pull number/interval format off the org via its query rather than
// inject('organization'), which is undefined during the page's setup
// (AppLayout provides it later in the lifecycle).
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const intervalFormat = computed(() => organization.value?.interval_format ?? 'hours-minutes');
const numberFormat = computed(() => organization.value?.number_format ?? 'point');
@@ -90,12 +118,28 @@ const weekRangeDisplay = computed(() => {
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
weekDays,
timeEntries,
rows,
removeSlot
);
const {
handleCellUpdate,
cellStatus,
cellPendingSeconds,
breakPlacementRequest,
applyBreakPlacement,
dismissBreakPlacement,
} = useTimesheetCellMutations(weekDays, allTimeEntries, rows, removeSlot);
// Local dates (YYYY-MM-DD) that have a misplaced break. There is only one break
// row, so a flat set is enough — its cells show a warning for dates in the set.
const misplacedBreakDates = computed<Set<string>>(() => {
const dates = new Set<string>();
for (const entry of timeEntries.value) {
if (entry.type !== 'break') continue;
// Hint against the padded list so work just across midnight counts.
if (getBreakPlacementHint(entry, allTimeEntries.value)?.misplaced) {
dates.add(getLocalizedDayJs(entry.start).format('YYYY-MM-DD'));
}
}
return dates;
});
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations,
@@ -125,7 +169,8 @@ const { isCopyingLastWeek, copyLastWeekRows, copyLastWeekWithTime } = useCopyLas
weekDays,
rows,
timeEntries,
addSlot
addSlot,
breaksEnabled
);
// ── Inline creation helpers (passed to TimesheetRow) ──────────────
@@ -161,6 +206,8 @@ async function createTag(name: string): Promise<Tag | undefined> {
:today-date="todayDate"
:day-totals="dayTotals"
:week-total-formatted="weekTotalFormatted"
:break-day-totals="breakDayTotals"
:break-grand-total="breakGrandTotal"
:projects="projects"
:tasks="tasks"
:clients="clients"
@@ -174,6 +221,7 @@ async function createTag(name: string): Promise<Tag | undefined> {
:format-duration="formatDuration"
:cell-statuses="cellStatus"
:cell-pending-seconds="cellPendingSeconds"
:misplaced-break-dates="misplacedBreakDates"
@remove-row="handleRemoveRow"
@cell-update="handleCellUpdate"
@project-task-change="
@@ -199,5 +247,10 @@ async function createTag(name: string): Promise<Tag | undefined> {
:entry-count="deleteRowEntryCount"
:project-name="deleteRowProjectName"
@confirm="confirmDeleteRow" />
<BreakPlacementModal
:request="breakPlacementRequest"
:apply="applyBreakPlacement"
@cancel="dismissBreakPlacement" />
</AppLayout>
</template>

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/api",
"version": "0.0.6",
"version": "0.0.7",
"description": "Package containing the solidtime api client and type declarations",
"main": "./dist/solidtime-api.umd.cjs",
"module": "./dist/solidtime-api.js",

View File

@@ -16,6 +16,7 @@ export type Invitation = InvitationsIndexResponse['data'][0];
export type TimeEntryResponse = ZodiosResponseByAlias<SolidTimeApi, 'getTimeEntries'>;
export type TimeEntry = TimeEntryResponse['data'][0];
export type TimeEntryType = TimeEntry['type'];
export type CreateTimeEntryBody = ZodiosBodyByAlias<SolidTimeApi, 'createTimeEntry'>;

View File

@@ -319,6 +319,7 @@ const OrganizationResource = z
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
breaks_enabled: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
@@ -336,6 +337,7 @@ const OrganizationUpdateRequest = z
employees_can_see_billable_rates: z.boolean(),
employees_can_manage_tasks: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
breaks_enabled: z.boolean(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,
@@ -420,6 +422,7 @@ const TimeEntryAggregationType = z.enum([
'billable',
'description',
'tag',
'type',
]);
const TimeEntryAggregationTypeInterval = z.enum(['day', 'week', 'month', 'year']);
const Weekday = z.enum([
@@ -448,6 +451,7 @@ const ReportStoreRequest = z
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
@@ -478,9 +482,11 @@ const DetailedReportResource = z
active: z.union([z.boolean(), z.null()]),
member_ids: z.union([z.array(z.string()), z.null()]),
billable: z.union([z.boolean(), z.null()]),
time_entry_type: z.union([z.enum(['work', 'break']), z.null()]),
client_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
@@ -629,6 +635,7 @@ const TaskUpdateRequest = z
.passthrough();
const start = z.union([z.string(), z.null()]).optional();
const rounding_minutes = z.union([z.number(), z.null()]).optional();
const TimeEntryType = z.enum(['work', 'break']);
const TimeEntryResource = z
.object({
id: z.string(),
@@ -642,6 +649,7 @@ const TimeEntryResource = z
user_id: z.string(),
tags: z.array(z.string()),
billable: z.boolean(),
type: TimeEntryType,
})
.passthrough();
const TimeEntryStoreRequest = z
@@ -652,6 +660,7 @@ const TimeEntryStoreRequest = z
start: z.string(),
end: z.union([z.string(), z.null()]).optional(),
billable: z.boolean(),
type: TimeEntryType.optional(),
description: z.union([z.string(), z.null()]).optional(),
tags: z.union([z.array(z.string()), z.null()]).optional(),
})
@@ -665,6 +674,7 @@ const TimeEntryUpdateMultipleRequest = z
project_id: z.union([z.string(), z.null()]),
task_id: z.union([z.string(), z.null()]),
billable: z.boolean(),
type: TimeEntryType,
description: z.union([z.string(), z.null()]),
tags: z.union([z.array(z.string()), z.null()]),
})
@@ -680,6 +690,7 @@ const TimeEntryUpdateRequest = z
start: z.string(),
end: z.union([z.string(), z.null()]),
billable: z.boolean(),
type: TimeEntryType,
description: z.union([z.string(), z.null()]),
tags: z.union([z.array(z.string()), z.null()]),
})
@@ -772,6 +783,7 @@ export const schemas = {
TaskUpdateRequest,
start,
rounding_minutes,
TimeEntryType,
TimeEntryResource,
TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest,
@@ -3734,6 +3746,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'type',
type: 'Query',
schema: TimeEntryType.optional(),
},
{
name: 'limit',
type: 'Query',
@@ -3784,6 +3801,11 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -3888,7 +3910,9 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
schema: z.string(),
},
],
response: z.object({ success: z.string(), error: z.string() }).passthrough(),
response: z
.object({ success: z.array(z.string()), error: z.array(z.string()) })
.passthrough(),
errors: [
{
status: 401,
@@ -4078,6 +4102,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
])
.optional(),
},
@@ -4097,6 +4122,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
])
.optional(),
},
@@ -4130,6 +4156,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'type',
type: 'Query',
schema: TimeEntryType.optional(),
},
{
name: 'fill_gaps_in_time_groups',
type: 'Query',
@@ -4165,6 +4196,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4265,6 +4301,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
]),
},
{
@@ -4282,6 +4319,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
]),
},
{
@@ -4319,6 +4357,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'type',
type: 'Query',
schema: TimeEntryType.optional(),
},
{
name: 'fill_gaps_in_time_groups',
type: 'Query',
@@ -4359,6 +4402,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4442,6 +4490,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.enum(['true', 'false']).optional(),
},
{
name: 'type',
type: 'Query',
schema: TimeEntryType.optional(),
},
{
name: 'limit',
type: 'Query',
@@ -4487,6 +4540,11 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',

View File

@@ -1,6 +1,6 @@
{
"name": "@solidtime/ui",
"version": "0.0.21",
"version": "0.0.22",
"description": "Package containing the solidtime ui components",
"main": "./dist/solidtime-ui-lib.umd.cjs",
"module": "./dist/solidtime-ui-lib.js",

View File

@@ -9,7 +9,7 @@ export const buttonVariants = cva(
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 dark:bg-destructive/70',
outline:
'border shadow-xs hover:text-text-primary bg-card-background dark:bg-transparent border-input dark:border-input hover:bg-white/5',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',

View File

@@ -94,6 +94,7 @@ const emit = defineEmits<{
getEventOpacityClass(dayEvent, dayStr),
{
'running-entry rounded-b-none': dayEvent.event.isRunning,
'fc-event-break': dayEvent.event.isBreak,
'fc-event-dragging': isDragging && dragEventId === dayEvent.event.id,
'fc-event-resizing': resizeEventId === dayEvent.event.id,
'rounded-t-none': dayEvent.isClippedStart,
@@ -121,6 +122,8 @@ const emit = defineEmits<{
:project-name="dayEvent.event.project?.name"
:task-name="dayEvent.event.task?.name"
:client-name="dayEvent.event.client?.name"
:is-break="dayEvent.event.isBreak"
:is-misplaced-break="dayEvent.event.isMisplacedBreak"
:duration-seconds="getEventDurationSeconds(dayEvent, dayStr)" />
</div>
<div
@@ -413,4 +416,15 @@ const emit = defineEmits<{
.fc-events-inset-expanded {
left: 204px;
}
/* Breaks get a hatched texture so they can not be confused with a project color */
.fc-event-break {
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 5px,
rgba(217, 119, 6, 0.15) 5px,
rgba(217, 119, 6, 0.15) 7px
);
}
</style>

View File

@@ -7,14 +7,37 @@ import type { Dayjs } from 'dayjs';
const props = defineProps<{
date: Dayjs;
totalSeconds?: number;
breakSeconds?: number;
isToday?: boolean;
}>();
const totalSecondsValue = computed(() => props.totalSeconds ?? 0);
const breakSecondsValue = computed(() => props.breakSeconds ?? 0);
const organization = inject('organization') as ComputedRef<Organization | undefined> | undefined;
const intervalFormat = computed(() => organization?.value?.interval_format);
const numberFormat = computed(() => organization?.value?.number_format);
const hasBreak = computed(() => breakSecondsValue.value > 0);
// Without breaks the work time stands alone, so it needs no label. Once break
// time joins it, both halves are labelled to keep them apart.
const durationSummary = computed(() => {
const work = formatHumanReadableDuration(
totalSecondsValue.value,
intervalFormat.value,
numberFormat.value
);
if (!hasBreak.value) {
return work;
}
const breakTime = formatHumanReadableDuration(
breakSecondsValue.value,
intervalFormat.value,
numberFormat.value
);
return `${work} work · ${breakTime} break`;
});
</script>
<template>
@@ -22,8 +45,10 @@ const numberFormat = computed(() => organization?.value?.number_format);
<div class="text-sm text-foreground" :class="isToday ? 'font-semibold' : 'font-medium'">
{{ date.format('ddd') }} {{ date.date() }}
</div>
<span class="block text-xs text-muted-foreground font-medium mt-0.5">
{{ formatHumanReadableDuration(totalSecondsValue, intervalFormat, numberFormat) }}
</span>
<span
class="block text-xs text-muted-foreground font-medium mt-0.5"
data-testid="day_duration_summary"
>{{ durationSummary }}</span
>
</div>
</template>

View File

@@ -2,6 +2,8 @@
import { computed, inject, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, getDayJsInstance } from '../utils/time';
import type { Organization } from '@/packages/api/src';
import { Coffee } from '@lucide/vue';
import { ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
const props = defineProps<{
title: string;
@@ -11,6 +13,8 @@ const props = defineProps<{
durationSeconds?: number;
start?: string | Date | null;
end?: string | Date | null;
isBreak?: boolean;
isMisplacedBreak?: boolean;
}>();
const effectiveDurationSeconds = computed(() => {
@@ -41,7 +45,15 @@ const formattedDuration = computed(() =>
<template>
<div class="text-2xs leading-tight px-0.5 py-1">
<div class="font-semibold">{{ title }}</div>
<div class="font-semibold flex items-center gap-1">
<Coffee v-if="isBreak" class="w-3 h-3 shrink-0" />
<span class="truncate">{{ title }}</span>
<ExclamationTriangleIcon
v-if="isMisplacedBreak"
data-testid="calendar_break_placement_hint"
title="This break does not align with your work entries"
class="w-3 h-3 shrink-0 text-amber-600 dark:text-amber-400" />
</div>
<div v-if="projectName" class="font-medium opacity-90">
{{ projectName }}
</div>

View File

@@ -12,8 +12,10 @@ import {
} from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useCssVariable } from '../utils/useCssVariable';
import { getLocalizedDayJs } from '../utils/time';
import { useBreaksEnabled } from '../utils/useBreaksEnabled';
import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..';
import BreakCreateModal from '../TimeEntry/BreakCreateModal.vue';
import FullCalendarDayHeader from './FullCalendarDayHeader.vue';
import CalendarToolbar from './CalendarToolbar.vue';
import CalendarDayColumn from './CalendarDayColumn.vue';
@@ -34,6 +36,7 @@ import {
StopIcon,
XMarkIcon,
} from '@heroicons/vue/20/solid';
import { Coffee } from '@lucide/vue';
import type { ActivityPeriod } from './activityTypes';
import { SLOT_HEIGHT, TIME_AXIS_WIDTH, type DayEvent } from './calendarTypes';
import { useCalendarGrid } from './useCalendarGrid';
@@ -74,6 +77,8 @@ const props = defineProps<{
currency: string;
canCreateProject: boolean;
organizationBillableRate: number | null;
// Local date (YYYY-MM-DD) to open the calendar on, e.g. from a "Fix in calendar" deep link
initialDate?: string | null;
createTimeEntry: (
entry: Omit<TimeEntry, 'id' | 'organization_id' | 'user_id'>
@@ -87,6 +92,9 @@ const props = defineProps<{
const newEventStart = ref<Dayjs | null>(null);
const newEventEnd = ref<Dayjs | null>(null);
const showCreateBreakModal = ref(false);
const newBreakStart = ref<Dayjs | null>(null);
const newBreakEnd = ref<Dayjs | null>(null);
const showCreateTimeEntryModal = ref<boolean>(false);
const showEditTimeEntryModal = ref<boolean>(false);
const selectedTimeEntry = ref<TimeEntry | null>(null);
@@ -114,6 +122,7 @@ const currentTime = ref(getLocalizedDayJs());
let currentTimeInterval: ReturnType<typeof setInterval> | null = null;
const organization = inject<ComputedRef<Organization>>('organization');
const breaksEnabled = useBreaksEnabled();
const {
slots,
@@ -138,23 +147,34 @@ const {
} = useCalendarNavigation({
onDatesChange: (payload) => emit('dates-change', payload),
scrollToCurrentTime: () => scrollToCurrentTime(),
// Parse as local midnight in the user's timezone — getLocalizedDayJs would
// treat the bare date as UTC midnight, landing on the previous local day
// for negative UTC offsets
initialDate: props.initialDate ? getLocalizedDayJsFromMinutes(props.initialDate, 0) : null,
});
const cssBackground = useCssVariable('--color-bg-background');
const { optimisticOverrides, calendarEvents, eventsByDay, dailyTotals, isToday, nowIndicatorTop } =
useCalendarEvents({
timeEntries: () => props.timeEntries,
projects: () => props.projects,
clients: () => props.clients,
tasks: () => props.tasks,
calendarSettings,
viewDays,
currentTime,
cssBackground,
minutesToPixels,
timeToMinutesFromMidnight,
});
const {
optimisticOverrides,
calendarEvents,
eventsByDay,
dailyTotals,
dailyBreakTotals,
isToday,
nowIndicatorTop,
} = useCalendarEvents({
timeEntries: () => props.timeEntries,
projects: () => props.projects,
clients: () => props.clients,
tasks: () => props.tasks,
calendarSettings,
viewDays,
currentTime,
cssBackground,
minutesToPixels,
timeToMinutesFromMidnight,
});
const {
activityBoxesForDay,
@@ -244,6 +264,7 @@ const {
handleContextStop,
handleContextDiscard,
handleContextCreate,
handleContextCreateBreak,
} = useContextMenu({
calendarSettings,
calendarEvents,
@@ -262,6 +283,11 @@ const {
newEventEnd.value = end;
showCreateTimeEntryModal.value = true;
},
onCreateBreak: (start, end) => {
newBreakStart.value = start;
newBreakEnd.value = end;
showCreateBreakModal.value = true;
},
emitRefresh: () => emit('refresh'),
});
@@ -274,6 +300,14 @@ watch(showCreateTimeEntryModal, (value) => {
}
});
watch(showCreateBreakModal, (value) => {
if (!value) {
newBreakStart.value = null;
newBreakEnd.value = null;
emit('refresh');
}
});
watch(showEditTimeEntryModal, (value) => {
if (!value) {
selectedTimeEntry.value = null;
@@ -455,6 +489,12 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:start="newEventStart ? newEventStart.toISOString() : undefined"
:end="newEventEnd ? newEventEnd.toISOString() : undefined" />
<BreakCreateModal
v-model:show="showCreateBreakModal"
:create-time-entry="createTimeEntry"
:start="newBreakStart ? newBreakStart.toISOString() : undefined"
:end="newBreakEnd ? newBreakEnd.toISOString() : undefined" />
<TimeEntryEditModal
v-model:show="showEditTimeEntryModal"
:time-entry="selectedTimeEntry as any"
@@ -516,8 +556,9 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<FullCalendarDayHeader
:date="day"
:is-today="isToday(day)"
:total-seconds="
dailyTotals[day.format('YYYY-MM-DD')] || 0
:total-seconds="dailyTotals[day.format('YYYY-MM-DD')] || 0"
:break-seconds="
dailyBreakTotals[day.format('YYYY-MM-DD')] || 0
" />
</div>
</div>
@@ -682,11 +723,19 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<PencilIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleContextDuplicate()">
<!-- Duplicate/Split create a new entry of the same type, which the
server rejects for breaks when breaks are disabled -->
<ContextMenuItem
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
class="space-x-3"
@select="handleContextDuplicate()">
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
<span>Duplicate</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="handleContextSplit()">
<ContextMenuItem
v-if="contextMenuTimeEntry.type !== 'break' || breaksEnabled"
class="space-x-3"
@select="handleContextSplit()">
<ScissorsIcon class="w-4 h-4 text-icon-default" />
<span>Split</span>
</ContextMenuItem>
@@ -716,6 +765,13 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<PlusIcon class="w-4 h-4 text-icon-default" />
<span>Create Time Entry</span>
</ContextMenuItem>
<ContextMenuItem
v-if="breaksEnabled"
class="space-x-3"
@select="handleContextCreateBreak()">
<Coffee class="w-4 h-4 text-icon-default" />
<span>Add Break</span>
</ContextMenuItem>
</template>
</ContextMenuContent>
</ContextMenu>

View File

@@ -13,6 +13,8 @@ export interface CalendarEvent {
client?: Client;
task?: Task;
isRunning: boolean;
isBreak: boolean;
isMisplacedBreak: boolean;
durationMinutes: number;
title: string;
backgroundColor: string;

View File

@@ -2,6 +2,7 @@ import { computed, ref, type Ref, type ComputedRef } from 'vue';
import chroma from 'chroma-js';
import type { Dayjs } from 'dayjs';
import type { TimeEntry, Project, Client, Task } from '@/packages/api/src';
import { getBreakPlacementHint } from '../utils/breakPlacement';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes';
@@ -181,7 +182,8 @@ export function useCalendarEvents(params: {
const calendarEvents = computed<CalendarEvent[]>(() => {
const themeBackground = params.cssBackground.value?.trim();
return params.timeEntries().map((rawEntry) => {
const allEntries = params.timeEntries();
return allEntries.map((rawEntry) => {
const timeEntry = optimisticOverrides.value.get(rawEntry.id) || rawEntry;
const isRunning = timeEntry.end === null;
const project = params.projects().find((p) => p.id === timeEntry.project_id);
@@ -196,9 +198,20 @@ export function useCalendarEvents(params: {
'minutes'
);
const title = timeEntry.description || 'No description';
const baseColor = project?.color || '#6B7280';
const backgroundColor = chroma.mix(baseColor, themeBackground, 0.65, 'lab').hex();
const isBreak = timeEntry.type === 'break';
const isMisplacedBreak = isBreak
? (getBreakPlacementHint(timeEntry, allEntries)?.misplaced ?? false)
: false;
let title: string;
if (isBreak) {
title = timeEntry.description ? `Break · ${timeEntry.description}` : 'Break';
} else {
title = timeEntry.description || 'No description';
}
const baseColor = isBreak ? '#F59E0B' : project?.color || '#6B7280';
const backgroundColor = chroma
.mix(baseColor, themeBackground, isBreak ? 0.75 : 0.65, 'lab')
.hex();
const borderColor = chroma.mix(baseColor, themeBackground, 0.5, 'lab').hex();
const startTime = getLocalizedDayJs(timeEntry.start);
@@ -215,6 +228,8 @@ export function useCalendarEvents(params: {
client,
task,
isRunning,
isBreak,
isMisplacedBreak,
durationMinutes,
title,
backgroundColor,
@@ -253,28 +268,37 @@ export function useCalendarEvents(params: {
return result;
});
const dailyTotals = computed(() => {
function computeDailyTotals(filter: (entry: TimeEntry) => boolean): Record<string, number> {
const totals: Record<string, number> = {};
params.timeEntries().forEach((entry) => {
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
params
.timeEntries()
.filter(filter)
.forEach((entry) => {
const date = getLocalizedDayJs(entry.start).format('YYYY-MM-DD');
let durationSeconds: number;
if (entry.end !== null) {
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'seconds'
);
} else {
durationSeconds = Math.max(
0,
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
);
}
if (entry.end !== null) {
durationSeconds = getDayJsInstance()(entry.end).diff(
getDayJsInstance()(entry.start),
'seconds'
);
} else {
durationSeconds = Math.max(
0,
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
);
}
totals[date] = (totals[date] || 0) + durationSeconds;
});
totals[date] = (totals[date] || 0) + durationSeconds;
});
return totals;
});
}
// Breaks are not working time: the day total only sums work entries,
// the break portion is exposed separately
const dailyTotals = computed(() => computeDailyTotals((entry) => entry.type !== 'break'));
const dailyBreakTotals = computed(() => computeDailyTotals((entry) => entry.type === 'break'));
function isToday(day: Dayjs): boolean {
return day.isSame(getLocalizedDayJs(), 'day');
@@ -294,6 +318,7 @@ export function useCalendarEvents(params: {
calendarEvents,
eventsByDay,
dailyTotals,
dailyBreakTotals,
isToday,
nowIndicatorTop,
};

View File

@@ -6,9 +6,10 @@ import { getWeekStartDayNumber } from '../utils/settings';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
scrollToCurrentTime: () => void;
initialDate?: Dayjs | null;
}) {
const activeView = ref('timeGridWeek');
const currentDate = ref(getLocalizedDayJs());
const currentDate = ref(callbacks.initialDate ?? getLocalizedDayJs());
function getFirstDay(): number {
return getWeekStartDayNumber();

View File

@@ -0,0 +1,95 @@
import { computed, ref } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TimeEntry } from '@/packages/api/src';
import type { CalendarEvent } from './calendarTypes';
import { useContextMenu } from './useContextMenu';
function breakEntry(): TimeEntry {
return {
id: 'break-1',
start: '2026-07-14T10:00:00Z',
end: '2026-07-14T11:00:00Z',
duration: 3600,
description: 'Lunch',
project_id: null,
task_id: null,
organization_id: 'organization-1',
user_id: 'user-1',
tags: [],
billable: false,
type: 'break',
} as TimeEntry;
}
describe('useContextMenu break actions', () => {
const createTimeEntry = vi.fn().mockResolvedValue(undefined);
const updateTimeEntry = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
});
function contextMenu() {
const entry = breakEntry();
const calendarEvents = computed(() => [
{
id: entry.id,
timeEntry: entry,
} as CalendarEvent,
]);
const menu = useContextMenu({
calendarSettings: ref({
snapMinutes: 15,
startHour: 0,
endHour: 24,
slotMinutes: 15,
}),
calendarEvents,
pixelsToMinutesFromMidnight: () => 0,
getDayFromClientX: () => null,
clientYToGridPixels: () => 0,
createTimeEntry,
updateTimeEntry,
deleteTimeEntry: vi.fn().mockResolvedValue(undefined),
onEditEvent: vi.fn(),
onCreateEvent: vi.fn(),
onCreateBreak: vi.fn(),
emitRefresh: vi.fn(),
});
menu.handleCalendarContextMenu({
target: {
closest: () => ({
getAttribute: () => entry.id,
}),
},
} as unknown as MouseEvent);
return menu;
}
it('preserves the type when duplicating a break', async () => {
await contextMenu().handleContextDuplicate();
expect(createTimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
type: 'break',
})
);
});
it('preserves the type when creating the second half of a split break', async () => {
await contextMenu().handleContextSplit();
expect(updateTimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
type: 'break',
})
);
expect(createTimeEntry).toHaveBeenCalledWith(
expect.objectContaining({
type: 'break',
})
);
});
});

View File

@@ -1,7 +1,7 @@
import { ref, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent } from './calendarTypes';
@@ -19,6 +19,7 @@ export function useContextMenu(params: {
deleteTimeEntry: (id: string) => Promise<void>;
onEditEvent: (entry: TimeEntry) => void;
onCreateEvent: (start: Dayjs, end: Dayjs) => void;
onCreateBreak: (start: Dayjs, end: Dayjs) => void;
emitRefresh: () => void;
}) {
const contextMenuTimeEntry = ref<TimeEntry | null>(null);
@@ -73,6 +74,7 @@ export function useContextMenu(params: {
start: entry.start,
end: entry.end,
billable: entry.billable,
type: entry.type,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
@@ -108,6 +110,7 @@ export function useContextMenu(params: {
start: midpoint.utc().format(),
end: entry.end,
billable: entry.billable,
type: entry.type,
description: entry.description,
project_id: entry.project_id,
task_id: entry.task_id,
@@ -154,6 +157,47 @@ export function useContextMenu(params: {
}
}
function handleContextCreateBreak() {
const dayjs = getDayJsInstance();
if (!contextMenuCreateTime.value) {
params.onCreateBreak(dayjs().utc().subtract(30, 'minute'), dayjs().utc());
return;
}
const clickTime = contextMenuCreateTime.value.start;
// Day matching must use the user's configured timezone (the calendar renders
// its day columns in that timezone), not the browser's local timezone
const clickDate = getLocalizedDayJs(clickTime.format()).format('YYYY-MM-DD');
// When the click lands in a gap between two entries of the same day,
// the break is prefilled to exactly fill that gap
let previousEnd: Dayjs | null = null;
let nextStart: Dayjs | null = null;
for (const calendarEvent of params.calendarEvents.value) {
const entry = calendarEvent.timeEntry;
const entryStart = dayjs.utc(entry.start);
if (getLocalizedDayJs(entry.start).format('YYYY-MM-DD') !== clickDate) {
continue;
}
const entryEnd = entry.end === null ? null : dayjs.utc(entry.end);
if (entryEnd !== null && !entryEnd.isAfter(clickTime)) {
if (previousEnd === null || entryEnd.isAfter(previousEnd)) {
previousEnd = entryEnd;
}
}
if (!entryStart.isBefore(clickTime)) {
if (nextStart === null || entryStart.isBefore(nextStart)) {
nextStart = entryStart;
}
}
}
if (previousEnd !== null && nextStart !== null && previousEnd.isBefore(nextStart)) {
params.onCreateBreak(previousEnd, nextStart);
return;
}
params.onCreateBreak(contextMenuCreateTime.value.start, contextMenuCreateTime.value.end);
}
return {
contextMenuTimeEntry,
contextMenuCreateTime,
@@ -165,5 +209,6 @@ export function useContextMenu(params: {
handleContextStop,
handleContextDiscard,
handleContextCreate,
handleContextCreateBreak,
};
}

Some files were not shown because too many files have changed in this diff Show More