Compare commits

..

17 Commits

Author SHA1 Message Date
Constantin Graf
29a2e994cd Fixed phpstan error in TrustHosts middleware 2026-08-06 17:06:34 +02:00
Constantin Graf
f6d886b218 Refactored TrustHostsTest; Added return types to TrustHosts 2026-08-06 17:01:46 +02:00
Gregor Vostrak
80d98b30a1 add custom error handling for host mismatch; ensure TrustHosts runs
before TrustProxies
2026-08-06 17:01:46 +02:00
Gregor Vostrak
32f2f1431b add TrustHosts middleware with exemption for healthchecks 2026-08-06 17:01:46 +02:00
Constantin Graf
8f6d584ee9 Fixed invoice tax rate 2026-07-30 21:16:06 +02:00
github-actions[bot]
1905cbf40c Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1148#issuecomment-5123895106
2026-07-29 22:12:52 +00:00
github-actions[bot]
c8f668238e Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1179#issuecomment-5123615992
2026-07-29 21:36:39 +00:00
Gregor Vostrak
bf11bacdee add vouch system requirement for PRs > 50 line changes 2026-07-29 23:22:32 +02:00
Gregor Vostrak
f3c6a0b8ae fix invitations not being respected during signup when email case differs 2026-07-28 17:07:53 +02:00
Gregor Vostrak
619c602571 cleanup and deduplicate breaks frontend tests 2026-07-28 16:52:36 +02:00
Gregor Vostrak
600daf44d9 add description/project labels to break placement modal for existing
time entries
2026-07-28 16:52:36 +02:00
Gregor Vostrak
a1d6c92806 insert breaks into work entries instead of carving them out; rollback on
failure system
2026-07-28 16:52:36 +02:00
Gregor Vostrak
ce1bd6a435 unify display values for work and break time totals 2026-07-28 16:52:36 +02:00
Gregor Vostrak
79d85227c2 bump ui to 0.0.22 and api to 0.0.7 2026-07-28 16:52:36 +02:00
Gregor Vostrak
885abf3333 promote breaks feature in sidebar banner 2026-07-28 16:52:36 +02:00
Gregor Vostrak
c0c8fee6be default time entry type filter to work for legacy reports 2026-07-28 16:52:36 +02:00
Gregor Vostrak
cbcd1e51f6 add break time entries and simplified time tracker ui 2026-07-28 16:52:36 +02:00
152 changed files with 8383 additions and 924 deletions

25
.github/VOUCHED.td vendored Normal file
View File

@@ -0,0 +1,25 @@
# Vouched contributors for solidtime.
#
# One handle per line, without the leading @, sorted alphabetically.
# Prefix a handle with - to denounce them, optionally followed by a reason.
# Format reference: https://github.com/mitchellh/vouch
#
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
# discussion and the vouch workflows will update this file.
#
# Collaborators with write access and bots are always allowed and do not need
# an entry here.
#
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
bufferhead-code
candideu
kasparrosin
korridor
onatcer
shrootbuck
smilebeda
thespyder
utlark

75
.github/workflows/vouch-check-pr.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Vouch (check PR)
on:
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
if: >-
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/recheck'))
steps:
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
- name: "Measure diff size"
id: size
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
# Changes to these files do not count towards the 50-line limit.
# One extended regex per line, matched against the whole repo-relative
# path, so use a leading .* to match a file in any directory.
IGNORED: |
package-lock\.json
composer\.lock
tests/.*
e2e/.*
.*\.(test|spec)\.(ts|js|vue)
run: |
set -euo pipefail
# An empty list yields "^()$", which matches no filename. grep exits
# 1 on an empty list, so swallow that rather than fail the step.
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
ignored="^($(join <<<"$IGNORED"))$"
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
awk -F'\t' -v ignored="$ignored" '
$1 ~ ignored { next }
{ n += $2 }
END { print n+0 }')
echo "total=$total" >> "$GITHUB_OUTPUT"
echo "Countable diff size: $total line(s)"
- name: "Small patch (denounced users still blocked)"
if: fromJSON(steps.size.outputs.total) <= 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
require-vouch: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Full vouch required"
if: fromJSON(steps.size.outputs.total) > 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,33 @@
name: Vouch (manage by discussion)
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
on:
discussion_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
discussions: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,35 @@
name: Vouch (manage by issue)
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,6 +12,22 @@ In order to keep the issues of the repository clean we decided to only use them
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Vouched contributors
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
Your pull request is not affected if any of the following applies:
- You have write access to this repository.
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
### Contributor License Agreement
You'll also notice that weve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Dont worry - the process is quick and only takes a few clicks.

View File

@@ -39,6 +39,8 @@ Please open an issue or start a discussion and wait for approval before submitti
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

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

@@ -6,7 +6,10 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class Handler extends ExceptionHandler
@@ -30,6 +33,29 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void {
//
});
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the
// SuspiciousOperationException into a BadRequestHttpException by the time
// renderables run, so we match that and inspect the original.
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
$previous = $e->getPrevious();
if (! $previous instanceof SuspiciousOperationException
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
return null; // any other bad request keeps the default response
}
$message = 'This hostname is not configured for this instance. '
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
if ($request->expectsJson()) {
return response()->json(['message' => $message], 400);
}
return response()->view('errors.untrusted-host', ['message' => $message], 400);
});
}
public function render($request, Throwable $e): Response|RedirectResponse

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,6 +57,7 @@ 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(), $properties->tagMatchType);

View File

@@ -112,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;
@@ -209,6 +210,7 @@ class TimeEntryController extends Controller
$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();
}
@@ -568,6 +570,7 @@ class TimeEntryController extends Controller
$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();
}
@@ -759,6 +762,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

@@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
@@ -47,6 +48,7 @@ class Kernel extends HttpKernel
*/
protected $middleware = [
ForceHttps::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Rejects requests whose Host is not trusted, preventing Host-header poisoning of
* generated URLs (password reset, SSO callback, invitations). Trusted = the
* APP_URL host and its subdomains, plus TRUSTED_HOSTS (for multi-host access such
* as a Tailscale name). Health-check endpoints are exempt (probed by IP).
*/
class TrustHosts extends BaseTrustHosts
{
/**
* @return array<int, string|null>
*/
public function hosts(): array
{
/** @var array<int, string> $configured */
$configured = config('app.trusted_hosts', []);
$extra = array_map(function (string $host): string {
$host = trim($host);
// "*.example.com" matches any subdomain, not the apex.
if (str_starts_with($host, '*.')) {
return '^.+\.'.preg_quote(substr($host, 2), '#').'$';
}
return '^'.preg_quote($host, '#').'$';
}, $configured);
return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra);
}
/**
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, $next): Response
{
// Exempt health checks (probed by IP). Also reset the trusted hosts,
// since Octane leaks the static state across requests.
if ($request->is('health-check/*')) {
Request::setTrustedHosts([]);
return $next($request);
}
return parent::handle($request, $next);
}
}

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

@@ -8,6 +8,7 @@ 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;
@@ -177,6 +178,12 @@ class ReportStoreRequest extends BaseFormRequest
'numeric',
'integer',
],
// Filter by time entry type
'properties.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
];
}
@@ -240,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'));

View File

@@ -9,6 +9,7 @@ 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;
@@ -183,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',

View File

@@ -7,6 +7,7 @@ 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;
@@ -169,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',

View File

@@ -7,6 +7,7 @@ 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;
@@ -155,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',

View File

@@ -6,6 +6,7 @@ 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;
@@ -148,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',

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

@@ -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,6 +50,8 @@ 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 */

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

@@ -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

@@ -8,6 +8,7 @@ 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;
@@ -68,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.
*
@@ -129,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;
}
@@ -157,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);

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

@@ -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

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

@@ -23,6 +23,10 @@ class InvitationService
*/
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
{
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
// otherwise a mixed-case invite silently fails to link on registration.
$email = strtolower($email);
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException;
}
@@ -55,7 +59,7 @@ class InvitationService
$organizations = new Collection;
$invitations = OrganizationInvitation::query()
->where('email', $user->email)
->whereRaw('lower(email) = ?', [strtolower($user->email)])
->whereNotNull('accepted_at')
->get();

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

@@ -5,6 +5,7 @@ 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;
@@ -144,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
*/

View File

@@ -9,9 +9,9 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.35",
"dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.5.0",
"flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3",

886
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -75,6 +75,27 @@ return [
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Trusted Hosts
|--------------------------------------------------------------------------
|
| Additional hostnames (besides the APP_URL host and its subdomains) that
| the application is allowed to respond on. This is needed for multi-host
| setups, e.g. reaching the instance over both a public domain and a
| Tailscale name. A request arriving on any host that is neither APP_URL
| (nor a subdomain of it) nor listed here is rejected, which prevents
| Host-header poisoning of password reset and other out-of-band links.
|
| See App\Http\Middleware\TrustHosts.
|
*/
'trusted_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('TRUSTED_HOSTS', ''))
))),
'asset_url' => env('ASSET_URL'),
'force_https' => (bool) env('APP_FORCE_HTTPS', false),

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

@@ -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,15 @@ 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,
type TestContext,
} from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
@@ -58,6 +66,34 @@ function addRowButton(page: Page) {
return page.getByRole('button', { name: /Add row/i }).first();
}
async function fillBreakCell(page: Page, hours: string, dayIndex = 0) {
const input = page
.locator('[data-testid="timesheet_row"]')
.filter({ has: page.getByText('Break', { exact: true }) })
.locator('[data-testid="timesheet_cell"]')
.nth(dayIndex)
.locator('input');
await input.click();
await input.fill(hours);
return input;
}
function waitForBreakCreated(page: Page) {
return page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'break'
);
}
async function getDayEntriesViaApi(ctx: TestContext, day: string) {
return (await getTimeEntriesViaApi(ctx))
.filter((e) => e.start.startsWith(day))
.sort((a, b) => a.start.localeCompare(b.start));
}
async function chooseRowIdentity(page: Page, optionName: string) {
await addRowButton(page).click();
@@ -639,3 +675,252 @@ 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 breakCell = await fillBreakCell(page, '0.5');
await breakCell.press('Enter');
// The placement modal opens with the split preview, naming the entry that
// will be split so the user can recognize it.
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await expect(page.getByTestId('break_placement_summary')).toContainText(
'No Project · Split me'
);
await Promise.all([
waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(),
]);
// The break is inserted without reducing the eight hours of work.
const dayEntries = await getDayEntriesViaApi(ctx, day);
expect(dayEntries.map((e) => [e.type, e.start, e.end])).toEqual([
['work', `${day}T09:00:00Z`, `${day}T13:00:00Z`],
['break', `${day}T13:00:00Z`, `${day}T13:30:00Z`],
['work', `${day}T13:30:00Z`, `${day}T17:30:00Z`],
]);
});
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 breakCell = await fillBreakCell(page, '0.5');
await Promise.all([waitForBreakCreated(page), breakCell.press('Enter')]);
await expect(page.getByTestId('break_placement_summary')).not.toBeVisible();
const dayEntries = await getDayEntriesViaApi(ctx, day);
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 breakCell = await fillBreakCell(page, '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([
waitForBreakCreated(page),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const dayEntries = await getDayEntriesViaApi(ctx, day);
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
const hint = page.getByRole('button', {
name: 'does not align with your work entries',
});
await expect(hint).toBeVisible();
// The resulting warning 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 breakCell = await fillBreakCell(page, '0.75');
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 breaks = (await getDayEntriesViaApi(ctx, day)).filter((e) => 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`);
});
test('test that editing an adjacent break vacates its old slot before extending work', async ({
page,
ctx,
}) => {
// The existing break must move before work can extend through its old slot.
await updateOrganizationSettingViaApi(ctx, {
breaks_enabled: true,
prevent_overlapping_time_entries: true,
});
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T09:00:00Z`,
end: `${day}T17:00:00Z`,
description: 'Work before break',
});
const breakEntry = await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${day}T17:00:00Z`,
end: `${day}T17:30:00Z`,
type: 'break',
});
await goToTimesheet(page);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
const breakCell = await fillBreakCell(page, '1');
await breakCell.press('Enter');
await expect(page.getByTestId('break_placement_summary')).toBeVisible();
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes(`/time-entries/${breakEntry.id}`) &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
async (resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201 &&
(await resp.json()).data.type === 'work'
),
page.getByRole('button', { name: 'Add break' }).click(),
]);
const entries = await getDayEntriesViaApi(ctx, day);
expect(entries.map((entry) => [entry.id, entry.type, entry.start, entry.end])).toEqual([
[expect.any(String), 'work', `${day}T09:00:00Z`, `${day}T13:00:00Z`],
[breakEntry.id, 'break', `${day}T13:00:00Z`, `${day}T14:00:00Z`],
[expect.any(String), 'work', `${day}T14:00:00Z`, `${day}T18: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)
);
});

View File

@@ -9,6 +9,6 @@
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.1"
"ref": "v0.0.2"
}
}

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,7 +1,8 @@
<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 } from '@lucide/vue';
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';
@@ -27,6 +28,7 @@ const selectedClients = defineModel<string[]>('selectedClients', { required: tru
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 });
@@ -37,6 +39,8 @@ const emit = defineEmits<{
submit: [];
}>();
const breaksEnabled = useBreaksEnabled();
const { tags } = useTagsQuery();
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
@@ -162,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

@@ -71,6 +71,7 @@ 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);
@@ -126,6 +127,7 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
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,
@@ -160,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') {
@@ -172,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),
@@ -371,6 +374,7 @@ const tableData = computed(() => {
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,249 @@
<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,
type Interval,
} 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>;
entryLabel: (id: string) => string;
}>();
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, {
dayStart: props.request.dayStart,
dayEnd: props.request.dayEnd,
otherEntries: props.request.otherEntries,
});
});
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 around it. The work moves to make room and keeps its full length."
: "There's no free gap that fits this break, so the surrounding entries will be shifted to make room.";
});
interface PlanLine {
times: string;
label: string;
}
const changeSummary = computed<PlanLine[]>(() => {
const req = props.request;
if (!req) return [];
const range = (interval: Interval) => `${fmt(interval.start)}${fmt(interval.end)}`;
const moved = (from: Interval, to: Interval) => `${range(from)}${range(to)}`;
if (mode.value === 'split') {
const plan = splitPlan.value;
if (!plan) return [];
const workLabel = props.entryLabel(req.workEntries[0]!.id);
return [
{ times: range(plan.firstHalf), label: workLabel },
{ times: range(plan.breakSlot), label: 'Break' },
{ times: range(plan.secondHalf), label: workLabel },
...plan.shifted.map((shift) => ({
times: moved(req.otherEntries.find((e) => e.id === shift.id)!, shift),
label: props.entryLabel(shift.id),
})),
];
}
const plan = movePlan.value;
if (!plan) return [];
if (plan.shifted.length === 0) return [{ times: 'No entries need to move.', label: '' }];
return plan.shifted.map((shift) => {
const original =
req.workEntries.find((e) => e.id === shift.id) ??
req.otherEntries.find((e) => e.id === shift.id)!;
return { times: moved(original, shift), label: props.entryLabel(shift.id) };
});
});
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="flex items-baseline gap-2">
<span class="tabular-nums whitespace-nowrap">{{ line.times }}</span>
<span v-if="line.label" class="text-text-tertiary truncate">
{{ line.label }}
</span>
</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 has to sit inside the work, leaving at least a minute of work on each side, and the work around it has to stay inside the day."
: "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

@@ -74,6 +74,7 @@ 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);
@@ -106,6 +107,7 @@ function getFilterAttributes() {
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,
};
@@ -329,6 +331,7 @@ async function downloadExport(format: ExportFormat) {
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,13 +118,46 @@ const weekRangeDisplay = computed(() => {
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
const {
handleCellUpdate,
cellStatus,
cellPendingSeconds,
breakPlacementRequest,
applyBreakPlacement,
dismissBreakPlacement,
} = useTimesheetCellMutations(
weekDays,
timeEntries,
allTimeEntries,
rows,
removeSlot
removeSlot,
() => organization.value?.prevent_overlapping_time_entries ?? false
);
function breakPlanEntryLabel(id: string): string {
const entry = allTimeEntries.value.find((e) => e.id === id);
if (!entry) return '';
if (entry.type === 'break') return 'Break';
const project = projects.value.find((p) => p.id === entry.project_id);
const task = tasks.value.find((t) => t.id === entry.task_id);
return [project?.name ?? 'No Project', task?.name, entry.description]
.filter((part): part is string => !!part)
.join(' · ');
}
// 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,
projects,
@@ -125,7 +186,8 @@ const { isCopyingLastWeek, copyLastWeekRows, copyLastWeekWithTime } = useCopyLas
weekDays,
rows,
timeEntries,
addSlot
addSlot,
breaksEnabled
);
// ── Inline creation helpers (passed to TimesheetRow) ──────────────
@@ -161,6 +223,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 +238,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 +264,11 @@ async function createTag(name: string): Promise<Tag | undefined> {
:entry-count="deleteRowEntryCount"
:project-name="deleteRowProjectName"
@confirm="confirmDeleteRow" />
<BreakPlacementModal
:request="breakPlacementRequest"
:apply="applyBreakPlacement"
:entry-label="breakPlanEntryLabel"
@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([
@@ -479,6 +482,7 @@ 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()]),
@@ -631,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(),
@@ -644,6 +649,7 @@ const TimeEntryResource = z
user_id: z.string(),
tags: z.array(z.string()),
billable: z.boolean(),
type: TimeEntryType,
})
.passthrough();
const TimeEntryStoreRequest = z
@@ -654,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(),
})
@@ -667,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()]),
})
@@ -682,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()]),
})
@@ -774,6 +783,7 @@ export const schemas = {
TaskUpdateRequest,
start,
rounding_minutes,
TimeEntryType,
TimeEntryResource,
TimeEntryStoreRequest,
TimeEntryUpdateMultipleRequest,
@@ -3736,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',
@@ -3895,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,
@@ -4085,6 +4102,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
])
.optional(),
},
@@ -4104,6 +4122,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
])
.optional(),
},
@@ -4137,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',
@@ -4277,6 +4301,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
]),
},
{
@@ -4294,6 +4319,7 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
'billable',
'description',
'tag',
'type',
]),
},
{
@@ -4331,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',
@@ -4459,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',

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,
};
}

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { Field, FieldLabel } from '../field';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type { CreateTimeEntryBody } from '@/packages/api/src';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import { Coffee } from '@lucide/vue';
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => Promise<void>;
start?: string;
end?: string;
}>();
function defaultStart() {
return getDayJsInstance().utc().subtract(30, 'm').second(0).format();
}
function defaultEnd() {
return getDayJsInstance().utc().second(0).format();
}
const note = ref('');
const localStart = ref(getLocalizedDayJs(defaultStart()).format());
const localEnd = ref(getLocalizedDayJs(defaultEnd()).format());
// Prefill start/end when the modal is opened with a given range (e.g. from the calendar)
watch(
() => props.start,
(value) => {
if (value) {
localStart.value = getLocalizedDayJs(value).format();
}
}
);
watch(
() => props.end,
(value) => {
if (value) {
localEnd.value = getLocalizedDayJs(value).format();
}
}
);
const durationSeconds = computed(() =>
getLocalizedDayJs(localEnd.value).diff(getLocalizedDayJs(localStart.value), 'second')
);
async function submit() {
if (durationSeconds.value <= 0) return;
saving.value = true;
try {
await props.createTimeEntry({
description: note.value,
project_id: null,
task_id: null,
tags: [],
billable: false,
type: 'break',
start: getLocalizedDayJs(localStart.value).utc().format(),
end: getLocalizedDayJs(localEnd.value).utc().format(),
});
note.value = '';
localStart.value = getLocalizedDayJs(defaultStart()).format();
localEnd.value = getLocalizedDayJs(defaultEnd()).format();
show.value = false;
} finally {
saving.value = false;
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5" />
<span> Add break </span>
</div>
</template>
<template #content>
<div class="space-y-4">
<TimeRangeFields
v-model:start="localStart"
v-model:end="localEnd"
date-picker-size="sm"></TimeRangeFields>
<Field>
<FieldLabel for="break_note">Note (optional)</FieldLabel>
<TextInput
id="break_note"
v-model="note"
placeholder="e.g. Lunch"
type="text"
class="block w-full"
@keydown.enter="submit" />
</Field>
</div>
</template>
<template #footer>
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
tabindex="2"
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving || durationSeconds <= 0"
@click="submit">
Add Break
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import { Coffee } from '@lucide/vue';
</script>
<template>
<div
data-testid="break_badge"
class="flex items-center space-x-1.5 text-sm font-medium text-text-secondary">
<Coffee class="w-4 h-4 text-text-tertiary" />
<span>Break</span>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ExclamationTriangleIcon, ArrowRightIcon } from '@heroicons/vue/20/solid';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/packages/ui/src';
// Warning affordance for a misplaced break: an amber triangle that opens a small
// popover offering to jump to the break's day in the calendar. Rendered by the
// caller only when the break is actually misplaced (see `showPlacementHint`).
defineProps<{
// Local day (YYYY-MM-DD) the calendar should navigate to.
fixDate: string;
// Delegated navigation — packages/ui stays router-agnostic.
fixInCalendar?: (date: string) => void;
}>();
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
type="button"
data-testid="break_placement_hint"
title="This break 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-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent class="min-w-[260px]" align="start">
<div class="px-3 py-2 space-y-1.5">
<p class="text-xs text-text-secondary">
This break is not directly between work entries.
</p>
<button
v-if="fixInCalendar"
type="button"
data-testid="break_fix_in_calendar"
class="inline-flex items-center gap-1 text-sm font-medium text-accent-400 hover:underline"
@click="fixInCalendar(fixDate)">
Fix in calendar
<ArrowRightIcon class="w-3.5 h-3.5" />
</button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -16,11 +16,19 @@ import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTag
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { ref, inject, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, formatStartEnd } from '@/packages/ui/src/utils/time';
import { ref, inject, computed, type ComputedRef } from 'vue';
import {
formatHumanReadableDuration,
formatStartEnd,
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import type { TimeEntriesGroupedByType } from '@/types/time-entries';
import {
findMisplacedBreak,
type BreakPlacementHint,
} from '@/packages/ui/src/utils/breakPlacement';
import {
Checkbox,
ContextMenu,
@@ -30,6 +38,9 @@ import {
ContextMenuTrigger,
} from '@/packages/ui/src';
import { PlayIcon, TrashIcon } from '@heroicons/vue/20/solid';
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
timeEntry: TimeEntriesGroupedByType;
@@ -50,6 +61,8 @@ const props = defineProps<{
selectedTimeEntries: TimeEntry[];
enableEstimatedTime: boolean;
canCreateProject: boolean;
breakPlacementHints?: Record<string, BreakPlacementHint | null>;
fixInCalendar?: (date: string) => void;
}>();
const emit = defineEmits<{
selected: [TimeEntry[]];
@@ -57,6 +70,26 @@ const emit = defineEmits<{
}>();
const organization = inject<ComputedRef<Organization>>('organization');
const breaksEnabled = useBreaksEnabled();
// Continue creates a new entry of the same type, which the server rejects
// for breaks when breaks are disabled for the organization
const canRecreate = computed(() => props.timeEntry.type !== 'break' || breaksEnabled.value);
// Grouped breaks collapse into a single summary row, so surface the placement
// warning if any entry in the group is misplaced. All grouped entries share the
// same day, so the first misplaced one supplies the calendar navigation date.
const misplacedBreakEntry = computed<TimeEntry | null>(() =>
findMisplacedBreak(props.timeEntry.timeEntries, props.breakPlacementHints ?? {})
);
const showPlacementHint = computed(
() => props.timeEntry.type === 'break' && misplacedBreakEntry.value !== null
);
const breakFixDate = computed(() =>
misplacedBreakEntry.value
? getLocalizedDayJs(misplacedBreakEntry.value.start).format('YYYY-MM-DD')
: ''
);
function updateTimeEntryDescription(description: string) {
props.updateTimeEntries(
@@ -121,12 +154,26 @@ function onSelectChange(checked: boolean) {
{{ timeEntry?.timeEntries?.length }}
</GroupedItemsCountButton>
<TimeEntryDescriptionInput
v-if="timeEntry.type !== 'break'"
class="min-w-0 mr-4 shrink"
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
<BreakLabel
v-if="timeEntry.type === 'break'"
class="px-2 shrink-0" />
<span
v-if="timeEntry.type === 'break' && timeEntry.description"
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
{{ timeEntry.description }}
</span>
<BreakPlacementHintButton
v-if="showPlacementHint"
:fix-date="breakFixDate"
:fix-in-calendar="fixInCalendar" />
<TimeTrackerProjectTaskDropdown
v-if="timeEntry.type !== 'break'"
class="min-w-0 shrink"
:clients
:create-project
@@ -147,11 +194,13 @@ function onSelectChange(checked: boolean) {
<div
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
<TimeEntryRowTagDropdown
v-if="timeEntry.type !== 'break'"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="timeEntry.type !== 'break'"
:model-value="timeEntry.billable"
size="small"
faded
@@ -189,6 +238,7 @@ function onSelectChange(checked: boolean) {
</button>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="opacity-60 flex group-hover:opacity-100 focus-visible:opacity-100"
@@ -231,7 +281,17 @@ function onSelectChange(checked: boolean) {
</div>
<!-- Second row: project/task - tags - billable - start - more -->
<div class="flex items-center justify-between mt-1">
<div
v-if="timeEntry.type === 'break'"
class="flex items-center min-w-0">
<BreakLabel class="px-2 min-w-0" />
<BreakPlacementHintButton
v-if="showPlacementHint"
:fix-date="breakFixDate"
:fix-in-calendar="fixInCalendar" />
</div>
<TimeTrackerProjectTaskDropdown
v-else
class="min-w-0"
:clients
:create-project
@@ -249,16 +309,19 @@ function onSelectChange(checked: boolean) {
"></TimeTrackerProjectTaskDropdown>
<div class="flex items-center shrink-0">
<TimeEntryRowTagDropdown
v-if="timeEntry.type !== 'break'"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
compact
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="timeEntry.type !== 'break'"
:model-value="timeEntry.billable"
size="small"
@changed="updateTimeEntryBillable"></BillableToggleButton>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="ml-2"
@@ -278,7 +341,7 @@ function onSelectChange(checked: boolean) {
</MainContainer>
<div
v-if="expanded"
class="w-full border-t border-default-background-separator bg-black/15">
class="w-full border-t border-default-background-separator bg-black/5 dark:bg-black/15">
<TimeEntryRow
v-for="subEntry in timeEntry.timeEntries"
:key="subEntry.id"
@@ -303,6 +366,8 @@ function onSelectChange(checked: boolean) {
:duplicate-time-entry="() => duplicateTimeEntry(subEntry)"
:currency="currency"
:create-tag
:placement-hint="breakPlacementHints?.[subEntry.id] ?? null"
:fix-in-calendar="fixInCalendar"
:time-entry="subEntry"
@selected="emit('selected', [subEntry])"
@unselected="emit('unselected', [subEntry])"></TimeEntryRow>
@@ -311,12 +376,13 @@ function onSelectChange(checked: boolean) {
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem
v-if="canRecreate"
class="space-x-3"
@select="onStartStopClick(timeEntry.timeEntries[0]!)">
<PlayIcon class="w-4 h-4 text-icon-default" />
<span>Continue</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator v-if="canRecreate" />
<ContextMenuItem
class="space-x-3 text-destructive"
@select="deleteTimeEntries(timeEntry?.timeEntries ?? [])">

View File

@@ -5,7 +5,6 @@ import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { computed, nextTick, ref, watch } from 'vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { Field, FieldLabel } from '../field';
import { TagIcon } from '@heroicons/vue/20/solid';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import type {
@@ -19,12 +18,8 @@ import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import TimeRangeFields from '@/packages/ui/src/TimeEntry/TimeRangeFields.vue';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -62,6 +57,7 @@ const timeEntryDefaultValues = {
task_id: null,
tags: [],
billable: false,
type: 'work' as CreateTimeEntryBody['type'],
start: getDayJsInstance().utc().subtract(1, 'h').second(0).format(),
end: getDayJsInstance().utc().second(0).format(),
};
@@ -107,9 +103,6 @@ const localEnd = ref(getLocalizedDayJs(timeEntryDefaultValues.end).format());
watch(localStart, (value) => {
timeEntry.value.start = getLocalizedDayJs(value).utc().format();
if (getLocalizedDayJs(localEnd.value).isBefore(getLocalizedDayJs(value))) {
localEnd.value = value;
}
});
watch(localEnd, (value) => {
@@ -202,39 +195,11 @@ const billableProxy = computed({
</Select>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 pt-4">
<Field class="col-span-2 sm:col-span-3">
<FieldLabel>Duration</FieldLabel>
<div class="space-y-2 flex flex-col">
<DurationHumanInput
v-model:start="localStart"
v-model:end="localEnd"
name="Duration"></DurationHumanInput>
<div class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language like
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</Field>
<Field>
<FieldLabel>Start</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="localStart" class="w-full"></TimePickerSimple>
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
</div>
</Field>
<Field>
<FieldLabel>End</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="localEnd" class="w-full"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</Field>
</div>
<TimeRangeFields
v-model:start="localStart"
v-model:end="localEnd"
show-hint
class="pt-4"></TimeRangeFields>
</template>
<template #footer>
<SecondaryButton tabindex="2" @click="show = false"> Cancel</SecondaryButton>

View File

@@ -23,8 +23,14 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import { Coffee } from '@lucide/vue';
import type { Tag, Task } from '@/packages/api/src';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
// Breaks may have been disabled after this entry was created, so an existing break can still be
// edited (and converted back), but a work entry may only offer the break option when enabled.
const breaksEnabled = useBreaksEnabled();
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -137,6 +143,24 @@ const billableProxy = computed({
}
},
});
const isBreak = computed(() => editableTimeEntry.value?.type === 'break');
const typeProxy = computed({
get: () => editableTimeEntry.value?.type ?? 'work',
set: (value: string) => {
if (editableTimeEntry.value) {
editableTimeEntry.value.type = value as TimeEntry['type'];
if (value === 'break') {
// Breaks can not be billable, have tags or belong to a project/task
editableTimeEntry.value.project_id = null;
editableTimeEntry.value.task_id = null;
editableTimeEntry.value.billable = false;
editableTimeEntry.value.tags = [];
}
}
},
});
</script>
<template>
@@ -162,7 +186,7 @@ const billableProxy = computed({
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-2">
<div class="flex-1 min-w-0">
<div v-if="!isBreak" class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="editableTimeEntry.project_id"
v-model:task="editableTimeEntry.task_id"
@@ -178,8 +202,24 @@ const billableProxy = computed({
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime" />
</div>
<div v-else class="flex-1 min-w-0"></div>
<div class="flex items-center gap-2 shrink-0">
<Select v-if="breaksEnabled || isBreak" v-model="typeProxy">
<SelectTrigger :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<Coffee
class="h-4 w-4"
:class="isBreak ? 'text-amber-500' : 'text-icon-default'" />
<span>{{ isBreak ? 'Break' : 'Work time' }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="work">Work time</SelectItem>
<SelectItem value="break">Break</SelectItem>
</SelectContent>
</Select>
<TagDropdown
v-if="!isBreak"
v-model="editableTimeEntry.tags"
:create-tag
:tags="tags"
@@ -195,7 +235,7 @@ const billableProxy = computed({
</Button>
</template>
</TagDropdown>
<Select v-model="billableProxy">
<Select v-if="!isBreak" v-model="billableProxy">
<SelectTrigger :show-chevron="false">
<SelectValue class="flex items-center gap-2">
<BillableIcon class="h-4 text-icon-default" />

View File

@@ -11,6 +11,10 @@ import type {
Client,
} from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
import {
getBreakPlacementHint,
type BreakPlacementHint,
} from '@/packages/ui/src/utils/breakPlacement';
import TimeEntryAggregateRow from '@/packages/ui/src/TimeEntry/TimeEntryAggregateRow.vue';
import TimeEntryRowHeading from '@/packages/ui/src/TimeEntry/TimeEntryRowHeading.vue';
import TimeEntryRow from '@/packages/ui/src/TimeEntry/TimeEntryRow.vue';
@@ -39,12 +43,24 @@ const props = withDefaults(
enableEstimatedTime: boolean;
canCreateProject: boolean;
groupSimilarTimeEntries?: boolean;
// Host-provided navigation to the calendar for a break's day (YYYY-MM-DD)
fixInCalendar?: (date: string) => void;
}>(),
{
groupSimilarTimeEntries: true,
}
);
const breakPlacementHints = computed<Record<string, BreakPlacementHint | null>>(() => {
const hints: Record<string, BreakPlacementHint | null> = {};
for (const entry of props.timeEntries) {
if (entry.type === 'break') {
hints[entry.id] = getBreakPlacementHint(entry, props.timeEntries);
}
}
return hints;
});
const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
for (const entry of props.timeEntries) {
@@ -75,6 +91,7 @@ const groupedTimeEntries = computed(() => {
e.project_id === entry.project_id &&
e.task_id === entry.task_id &&
e.billable === entry.billable &&
e.type === entry.type &&
e.description === entry.description
);
if (oldEntriesIndex !== -1 && newDailyEntries[oldEntriesIndex]) {
@@ -113,13 +130,24 @@ function startTimeEntryFromExisting(entry: TimeEntry) {
start: getDayJsInstance().utc().format(),
end: null,
billable: entry.billable,
type: entry.type,
description: entry.description,
tags: [...entry.tags],
});
}
function sumDuration(timeEntries: TimeEntry[]) {
return timeEntries.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
// Breaks are not working time: the day total only sums work entries,
// the break portion is shown separately in the heading
return timeEntries
.filter((entry) => entry.type !== 'break')
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
function sumBreakDuration(timeEntries: TimeEntry[]) {
return timeEntries
.filter((entry) => entry.type === 'break')
.reduce((acc, entry) => acc + (entry?.duration ?? 0), 0);
}
function selectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
for (const timeEntry of value) {
@@ -151,6 +179,7 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
<TimeEntryRowHeading
:date="String(key)"
:duration="sumDuration(value)"
:break-duration="sumBreakDuration(value)"
:checked="
value.every((timeEntry: TimeEntry) => selectedTimeEntries.includes(timeEntry))
"
@@ -176,6 +205,8 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:create-tag
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:break-placement-hints="breakPlacementHints"
:fix-in-calendar="fixInCalendar"
:time-entry="entry"
@selected="
(timeEntries: TimeEntry[]) => {
@@ -213,6 +244,9 @@ function unselectAllTimeEntries(value: TimeEntriesGroupedByType[]) {
:on-start-stop-click="() => startTimeEntryFromExisting(entry)"
:delete-time-entry="() => deleteTimeEntries([entry])"
:duplicate-time-entry="() => createTimeEntry(entry)"
:create-time-entry="createTimeEntry"
:placement-hint="breakPlacementHints[entry.timeEntries[0]!.id] ?? null"
:fix-in-calendar="fixInCalendar"
:currency="currency"
:time-entry="entry.timeEntries[0]!"
@selected="selectedTimeEntries.push(entry)"

View File

@@ -15,7 +15,7 @@ import {
type UpdateMultipleTimeEntriesChangeset,
} from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import { TagIcon } from '@heroicons/vue/20/solid';
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -129,6 +129,21 @@ watch(removeAllTags, () => {
selectedTags.value = [];
}
});
const selectedBreaksCount = computed(
() => props.timeEntries.filter((entry) => entry.type === 'break').length
);
// Mirrors the server-side skip in TimeEntryController::updateMultiple: a break
// entry is skipped entirely when the changeset assigns a project, makes it
// billable, or adds tags (clearing tags via removeAllTags is fine).
const showBreakWarning = computed(
() =>
selectedBreaksCount.value > 0 &&
((projectId.value !== null && projectId.value !== '') ||
billable.value === true ||
selectedTags.value.length > 0)
);
</script>
<template>
@@ -141,6 +156,20 @@ watch(removeAllTags, () => {
<template #content>
<div class="space-y-4">
<div
v-if="showBreakWarning"
data-testid="mass_update_break_warning"
class="flex items-start space-x-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:text-amber-400">
<ExclamationTriangleIcon class="w-4 h-4 mt-0.5 shrink-0" />
<span>
{{ selectedBreaksCount }}
{{ selectedBreaksCount === 1 ? 'break is' : 'breaks are' }} selected
breaks can not have a project or tags, or be billable, so
{{ selectedBreaksCount === 1 ? 'this entry' : 'these entries' }} will be
skipped entirely and none of the changes (including the description) will be
applied to {{ selectedBreaksCount === 1 ? 'it' : 'them' }}.
</span>
</div>
<Field>
<FieldLabel for="description">Description</FieldLabel>
<TextInput

View File

@@ -18,6 +18,8 @@ import TimeEntryRowDurationInput from '@/packages/ui/src/TimeEntry/TimeEntryRowD
import TimeEntryMoreOptionsDropdown from '@/packages/ui/src/TimeEntry/TimeEntryMoreOptionsDropdown.vue';
import { TimeEntryEditModal } from '@/packages/ui/src';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { useBreaksEnabled } from '@/packages/ui/src/utils/useBreaksEnabled';
import { computed, ref } from 'vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import {
@@ -29,6 +31,10 @@ import {
ContextMenuTrigger,
} from '@/packages/ui/src';
import { PlayIcon, PencilIcon, DocumentDuplicateIcon, TrashIcon } from '@heroicons/vue/20/solid';
import BreakLabel from '@/packages/ui/src/TimeEntry/BreakLabel.vue';
import BreakPlacementHintButton from '@/packages/ui/src/TimeEntry/BreakPlacementHintButton.vue';
import type { BreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import type { CreateTimeEntryBody } from '@/packages/api/src';
const props = defineProps<{
timeEntry: TimeEntry;
@@ -45,6 +51,9 @@ const props = defineProps<{
deleteTimeEntry: () => void;
duplicateTimeEntry?: () => void;
updateTimeEntry: (timeEntry: TimeEntry) => void;
createTimeEntry?: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
placementHint?: BreakPlacementHint | null;
fixInCalendar?: (date: string) => void;
currency: string;
organizationBillableRate: number | null;
showMember?: boolean;
@@ -59,6 +68,20 @@ const emit = defineEmits<{ selected: []; unselected: [] }>();
const showEditModal = ref(false);
const breaksEnabled = useBreaksEnabled();
const isBreak = computed(() => props.timeEntry.type === 'break');
// Continue/Duplicate create a new entry of the same type, which the server
// rejects for breaks when breaks are disabled for the organization
const canRecreate = computed(() => !isBreak.value || breaksEnabled.value);
const showPlacementHint = computed(
() => isBreak.value && props.placementHint != null && props.placementHint.misplaced
);
const breakFixDate = computed(() => getLocalizedDayJs(props.timeEntry.start).format('YYYY-MM-DD'));
function updateTimeEntryDescription(description: string) {
props.updateTimeEntry({ ...props.timeEntry, description });
}
@@ -131,10 +154,22 @@ async function handleDeleteTimeEntry() {
<Checkbox :checked="selected" @update:checked="onSelectChange" />
<div v-if="indent === true" class="w-10 h-7"></div>
<TimeEntryDescriptionInput
v-if="!isBreak"
class="min-w-0 mr-4 shrink"
:model-value="timeEntry.description"
@changed="updateTimeEntryDescription"></TimeEntryDescriptionInput>
<BreakLabel v-if="isBreak" class="pl-1.5 @lg:pl-3 pr-2 shrink-0" />
<span
v-if="isBreak && timeEntry.description"
class="min-w-0 mr-4 shrink truncate text-sm text-text-secondary">
{{ timeEntry.description }}
</span>
<BreakPlacementHintButton
v-if="showPlacementHint"
:fix-date="breakFixDate"
:fix-in-calendar="fixInCalendar" />
<TimeTrackerProjectTaskDropdown
v-if="!isBreak"
class="min-w-0 shrink"
:create-project
:create-client
@@ -154,11 +189,13 @@ async function handleDeleteTimeEntry() {
{{ memberName }}
</div>
<TimeEntryRowTagDropdown
v-if="!isBreak"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="!isBreak"
:model-value="timeEntry.billable"
size="small"
faded
@@ -176,11 +213,13 @@ async function handleDeleteTimeEntry() {
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="opacity-60 flex focus-visible:opacity-100 group-hover:opacity-100"
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-duplicate="canRecreate"
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
@@ -190,11 +229,17 @@ async function handleDeleteTimeEntry() {
<!-- First row: description + duration -->
<div class="flex items-center justify-between min-w-0">
<TimeEntryDescriptionInput
v-if="!isBreak"
class="min-w-0 flex-1"
:model-value="timeEntry.description"
@changed="
updateTimeEntryDescription
"></TimeEntryDescriptionInput>
<span
v-else
class="min-w-0 flex-1 truncate text-sm text-text-secondary pl-1.5">
{{ timeEntry.description }}
</span>
<TimeEntryRowDurationInput
:start="timeEntry.start"
:end="timeEntry.end"
@@ -203,7 +248,9 @@ async function handleDeleteTimeEntry() {
</div>
<!-- Second row: project/task - tags - billable - start - more -->
<div class="flex items-center justify-between mt-1">
<BreakLabel v-if="isBreak" class="pl-1.5 pr-2 min-w-0" />
<TimeTrackerProjectTaskDropdown
v-else
class="min-w-0"
:create-project
:create-client
@@ -221,21 +268,25 @@ async function handleDeleteTimeEntry() {
"></TimeTrackerProjectTaskDropdown>
<div class="flex items-center shrink-0">
<TimeEntryRowTagDropdown
v-if="!isBreak"
:create-tag
:tags="tags"
:model-value="timeEntry.tags"
compact
@changed="updateTimeEntryTags"></TimeEntryRowTagDropdown>
<BillableToggleButton
v-if="!isBreak"
:model-value="timeEntry.billable"
size="small"
@changed="updateTimeEntryBillable"></BillableToggleButton>
<TimeTrackerStartStop
v-if="canRecreate"
:active="!!(timeEntry.start && !timeEntry.end)"
variant="secondary"
class="ml-2"
@changed="onStartStopClick"></TimeTrackerStartStop>
<TimeEntryMoreOptionsDropdown
:show-duplicate="canRecreate"
@edit="handleEdit"
@duplicate="duplicateTimeEntry"
@delete="deleteTimeEntry"></TimeEntryMoreOptionsDropdown>
@@ -247,7 +298,7 @@ async function handleDeleteTimeEntry() {
</div>
</ContextMenuTrigger>
<ContextMenuContent class="min-w-[160px]">
<ContextMenuItem class="space-x-3" @select="onStartStopClick()">
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="onStartStopClick()">
<PlayIcon class="w-4 h-4 text-icon-default" />
<span>Continue</span>
</ContextMenuItem>
@@ -255,7 +306,7 @@ async function handleDeleteTimeEntry() {
<PencilIcon class="w-4 h-4 text-icon-default" />
<span>Edit</span>
</ContextMenuItem>
<ContextMenuItem class="space-x-3" @select="duplicateTimeEntry?.()">
<ContextMenuItem v-if="canRecreate" class="space-x-3" @select="duplicateTimeEntry?.()">
<DocumentDuplicateIcon class="w-4 h-4 text-icon-default" />
<span>Duplicate</span>
</ContextMenuItem>

View File

@@ -6,17 +6,43 @@ import {
formatWeekday,
} from '@/packages/ui/src/utils/time';
import Checkbox from '../Input/Checkbox.vue';
import { inject, type ComputedRef } from 'vue';
import { computed, inject, type ComputedRef } from 'vue';
import type { Organization } from '@/packages/api/src';
import { CalendarIcon } from '@heroicons/vue/20/solid';
const organization = inject<ComputedRef<Organization>>('organization');
defineProps<{
date: string;
duration: number;
checked: boolean;
}>();
const props = withDefaults(
defineProps<{
date: string;
duration: number;
checked: boolean;
breakDuration?: number;
}>(),
{
breakDuration: 0,
}
);
const hasBreak = computed(() => props.breakDuration > 0);
function formatDuration(seconds: number) {
return formatHumanReadableDuration(
seconds,
organization?.value?.interval_format,
organization?.value?.number_format
);
}
// 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. The separator and
// its spacing live inside the interpolated strings so the markup cannot collapse
// them away.
const workLabel = computed(() =>
hasBreak.value ? `${formatDuration(props.duration)} work` : formatDuration(props.duration)
);
const breakLabel = computed(() => ` · ${formatDuration(props.breakDuration)} break`);
const emit = defineEmits<{
selectAll: [];
unselectAll: [];
@@ -54,16 +80,14 @@ function selectUnselectAll(value: boolean) {
{{ formatDate(date, organization?.date_format) }}
</span>
</div>
<div class="text-text-primary pr-2 @lg:pr-[92px]">
<span class="font-medium">
{{
formatHumanReadableDuration(
duration,
organization?.interval_format,
organization?.number_format
)
}}
</span>
<div class="flex items-center text-text-primary pr-2 @lg:pr-[92px]">
<span class="font-medium">{{ workLabel }}</span>
<span
v-if="hasBreak"
data-testid="day_break_duration"
class="text-text-secondary font-normal whitespace-pre"
>{{ breakLabel }}</span
>
</div>
</div>
</MainContainer>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { watch } from 'vue';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import { Field, FieldLabel } from '../field';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import DurationHumanInput from '@/packages/ui/src/Input/DurationHumanInput.vue';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import { getLocalizedDayJs } from '@/packages/ui/src/utils/time';
// Local (user timezone) ISO strings, as produced by getLocalizedDayJs(...).format()
const start = defineModel<string>('start', { required: true });
const end = defineModel<string>('end', { required: true });
defineProps<{
showHint?: boolean;
datePickerSize?: 'sm';
}>();
// Moving the start to or past the end drags the end along, preserving the
// range's previous duration, so the range never collapses or inverts.
watch(start, (value, oldValue) => {
if (getLocalizedDayJs(end.value).isAfter(getLocalizedDayJs(value))) return;
const previousDuration = Math.max(
0,
getLocalizedDayJs(end.value).diff(getLocalizedDayJs(oldValue), 'second')
);
end.value = getLocalizedDayJs(value).add(previousDuration, 'second').format();
});
</script>
<template>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4">
<Field class="col-span-2 sm:col-span-3">
<FieldLabel>Duration</FieldLabel>
<div class="space-y-2 flex flex-col">
<DurationHumanInput
v-model:start="start"
v-model:end="end"
name="Duration"></DurationHumanInput>
<div v-if="showHint" class="text-sm flex space-x-1">
<InformationCircleIcon
class="w-4 shrink-0 text-text-quaternary"></InformationCircleIcon>
<span class="text-text-secondary text-xs">
You can type natural language like
<span class="font-semibold"> 2h 30m</span>
</span>
</div>
</div>
</Field>
<Field>
<FieldLabel>Start</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="start" class="w-full"></TimePickerSimple>
<DatePicker
v-model="start"
:size="datePickerSize"
class="w-full"
tabindex="1"></DatePicker>
</div>
</Field>
<Field>
<FieldLabel>End</FieldLabel>
<div class="flex flex-col gap-2">
<TimePickerSimple v-model="end" class="w-full"></TimePickerSimple>
<DatePicker
v-model="end"
:size="datePickerSize"
class="w-full"
tabindex="1"></DatePicker>
</div>
</Field>
</div>
</template>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import TimeTrackerTagDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerTagDropdown.vue';
import TimeTrackerStartStop from '@/packages/ui/src/TimeTrackerStartStop.vue';
import TimeTrackerRangeSelector from '@/packages/ui/src/TimeTracker/TimeTrackerRangeSelector.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TimeTrackerEntryInput from '@/packages/ui/src/TimeTracker/TimeTrackerEntryInput.vue';
import TimeTrackerProjectControls from '@/packages/ui/src/TimeTracker/TimeTrackerProjectControls.vue';
import type {
CreateClientBody,
CreateProjectBody,
@@ -13,35 +12,45 @@ import type {
TimeEntry,
Client,
} from '@/packages/api/src';
import { computed, nextTick, ref, watch } from 'vue';
import { nextTick, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
import { useSelectEvents } from '@/packages/ui/src/utils/select';
import { Coffee, Play } from '@lucide/vue';
import type { TimeTrackerMode } from '@/packages/ui/src/TimeTracker/types';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', {
required: true,
});
const liveTimer = defineModel<Dayjs | null>('liveTimer', { required: true });
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const props = defineProps<{
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
timeEntries: TimeEntry[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
}>();
const props = withDefaults(
defineProps<{
projects: Project[];
tasks: Task[];
tags: Tag[];
clients: Client[];
timeEntries: TimeEntry[];
createTag: (name: string) => Promise<Tag | undefined>;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
isActive: boolean;
currency: string;
organizationBillableRate: number | null;
enableEstimatedTime: boolean;
canCreateProject: boolean;
isOnBreak?: boolean;
breaksEnabled?: boolean;
canResumeAfterBreak?: boolean;
resumeDescription?: string | null;
timeTrackerMode?: TimeTrackerMode;
}>(),
{
isOnBreak: false,
breaksEnabled: false,
canResumeAfterBreak: false,
resumeDescription: null,
timeTrackerMode: 'project',
}
);
const emit = defineEmits<{
startTimer: [];
@@ -50,251 +59,136 @@ const emit = defineEmits<{
startLiveTimer: [];
stopLiveTimer: [];
createTimeEntry: [];
startBreak: [];
resumeAfterBreak: [];
}>();
function updateProject() {
setBillableDefaultForProject();
emit('updateTimeEntry');
}
function setAndStartTimer(timeEntry: TimeEntry) {
setCurrentTimeEntry(timeEntry);
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
function setCurrentTimeEntry(timeEntry: TimeEntry) {
currentTimeEntry.value.description = timeEntry.description;
currentTimeEntry.value.project_id = timeEntry.project_id;
currentTimeEntry.value.task_id = timeEntry.task_id;
currentTimeEntry.value.tags = timeEntry.tags;
currentTimeEntry.value.billable = timeEntry.billable;
}
function startTimerIfNotActive() {
if (highlightedDropdownEntryId.value) {
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
(item) => item.id === highlightedDropdownEntryId.value
);
if (timeEntry) {
setCurrentTimeEntry(timeEntry);
showDropdown.value = false;
}
} else {
currentTimeEntry.value.description = tempDescription.value;
}
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
function setBillableDefaultForProject() {
const project = props.projects.find(
(project) => project.id === currentTimeEntry.value.project_id
);
if (project) {
currentTimeEntry.value.billable = project.is_billable;
}
}
const blockRefocus = ref(false);
const entryInput = ref<InstanceType<typeof TimeTrackerEntryInput> | null>(null);
function onToggleButtonPress(newState: boolean) {
if (newState) {
emit('startTimer');
if (!blockRefocus.value) {
currentTimeEntryDescriptionInput.value?.focus();
}
entryInput.value?.focusAfterStart();
} else {
emit('stopTimer');
}
}
const tempDescription = ref(currentTimeEntry.value.description);
watch(
() => currentTimeEntry.value.description,
() => {
tempDescription.value = currentTimeEntry.value.description;
}
);
function updateTimeEntryDescription() {
if (currentTimeEntry.value.description !== tempDescription.value) {
currentTimeEntry.value.description = tempDescription.value;
emit('updateTimeEntry');
}
// Pressing Enter in the range selector starts the timer, same as in the description input.
function onRangeEnter() {
entryInput.value?.submit();
}
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries
const finishedTimeEntries = props.timeEntries.filter((item) => item.end !== null);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
return (
index ===
self.findIndex(
(t) =>
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
)
);
});
// filter time entries based on current description
return nonDuplicateTimeEntries
.filter((item) => {
return item.description
?.toLowerCase()
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
})
.slice(0, 5);
});
const showDropdown = ref(false);
const { focused } = useFocus(currentTimeEntryDescriptionInput);
watch(focused, (focused) => {
nextTick(() => {
// make sure the click event on the dropdown does not get interrupted
showDropdown.value = focused;
// make sure that the input does not get refocused after the dropdown is closed
if (!focused) {
blockRefocus.value = true;
setTimeout(() => {
blockRefocus.value = false;
}, 100);
// After a break ends the tracker returns to the idle input; focus it so a fresh
// entry is just type + Enter.
watch(
() => props.isOnBreak,
async (isOnBreak, wasOnBreak) => {
if (wasOnBreak && !isOnBreak) {
await nextTick();
entryInput.value?.focusAfterStart();
}
});
});
const floating = ref(null);
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(10),
shift({
limiter: limitShift({
offset: 5,
}),
}),
flip({
fallbackAxisSideDirection: 'start',
}),
],
});
const highlightedDropdownEntryId = ref<string | null>(null);
useSelectEvents(
filteredRecentlyTrackedTimeEntries,
highlightedDropdownEntryId,
(item) => item.id,
showDropdown
}
);
</script>
<template>
<div class="flex items-center relative @container" data-testid="dashboard_timer">
<div
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg bg-card-background border-card-border border transition shadow-card">
class="flex flex-col @2xl:flex-row w-full justify-between rounded-lg border transition shadow-card"
:class="
isOnBreak
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-card-background border-card-border'
">
<div class="flex flex-1 items-center relative">
<input
ref="currentTimeEntryDescriptionInput"
v-model="tempDescription"
placeholder="What are you working on?"
data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
type="text"
@keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" />
<div class="@2xl:hidden pr-3 shrink-0">
<div
v-if="isOnBreak"
class="flex w-full items-center gap-2 py-4 sm:py-2.5 px-3.5 @2xl:px-4 text-base font-medium text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5 shrink-0" />
<span>On break</span>
</div>
<TimeTrackerEntryInput
v-else
ref="entryInput"
v-model:current-time-entry="currentTimeEntry"
:time-entries="timeEntries"
:projects="projects"
:tasks="tasks"
:is-active="isActive"
@start-timer="emit('startTimer')"
@update-time-entry="emit('updateTimeEntry')"></TimeTrackerEntryInput>
<div class="@2xl:hidden pr-3 shrink-0 flex items-center space-x-2">
<button
v-if="breaksEnabled && !isOnBreak && isActive"
type="button"
title="Take a break"
aria-label="Take a break"
class="flex items-center justify-center w-8 h-8 rounded-full bg-quaternary text-text-tertiary hover:text-amber-500 focus:ring-2 focus:ring-border-tertiary transition"
@click="emit('startBreak')">
<Coffee class="w-4 h-4" />
</button>
<TimeTrackerStartStop
:active="isActive"
:variant="isOnBreak ? 'break' : 'primary'"
@changed="onToggleButtonPress"></TimeTrackerStartStop>
</div>
<div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating"
class="z-50 w-[min(640px,100vw-2rem)]"
:style="floatingStyles">
<div
class="rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background">
<div
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
Recently Tracked Time Entries
</div>
<div class="text-text-secondary py-1 px-1.5">
<TimeTrackerRecentlyTrackedEntry
v-for="timeEntry in filteredRecentlyTrackedTimeEntries"
:key="timeEntry.id"
:time-entry="timeEntry"
:highlighted="highlightedDropdownEntryId === timeEntry.id"
:projects="projects"
:tasks="tasks"
@mousedown="setAndStartTimer(timeEntry)"
@mouseenter="
highlightedDropdownEntryId = timeEntry.id
"></TimeTrackerRecentlyTrackedEntry>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between pl-2 shrink min-w-0">
<div class="flex items-center w-[130px] @2xl:w-auto shrink min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="currentTimeEntry.project_id"
v-model:task="currentTimeEntry.task_id"
variant="outline"
:create-client
:can-create-project
:clients
:create-project
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:projects="projects"
:tasks="tasks"
:enable-estimated-time="enableEstimatedTime"
@changed="updateProject"></TimeTrackerProjectTaskDropdown>
</div>
<div class="flex items-center space-x-0 @4xl:space-x-2 px-2 @4xl:px-4 shrink-0">
<TimeTrackerTagDropdown
v-model="currentTimeEntry.tags"
:create-tag
:tags="tags"
@changed="$emit('updateTimeEntry')"></TimeTrackerTagDropdown>
<BillableToggleButton
v-model="currentTimeEntry.billable"
@changed="$emit('updateTimeEntry')"></BillableToggleButton>
</div>
<div class="border-l border-card-border">
<TimeTrackerProjectControls
v-if="!isOnBreak && timeTrackerMode !== 'simple'"
v-model:current-time-entry="currentTimeEntry"
:projects="projects"
:tasks="tasks"
:tags="tags"
:clients="clients"
:create-tag="createTag"
:create-project="createProject"
:create-client="createClient"
:currency="currency"
:organization-billable-rate="organizationBillableRate"
:enable-estimated-time="enableEstimatedTime"
:can-create-project="canCreateProject"
@update-time-entry="emit('updateTimeEntry')"></TimeTrackerProjectControls>
<button
v-if="isOnBreak && canResumeAfterBreak"
type="button"
class="mx-2 flex min-w-0 shrink items-center gap-1.5 h-8 px-3 rounded-md bg-transparent border border-amber-500/40 hover:bg-amber-500/15 text-sm font-medium text-amber-600 dark:text-amber-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 transition"
@click="emit('resumeAfterBreak')">
<Play class="w-4 h-4 shrink-0" />
<span class="truncate">{{
resumeDescription ? `Resume "${resumeDescription}"` : 'Resume'
}}</span>
</button>
<div
class="border-l"
:class="isOnBreak ? 'border-amber-500/40' : 'border-card-border'">
<TimeTrackerRangeSelector
v-model:current-time-entry="currentTimeEntry"
v-model:live-timer="liveTimer"
:is-on-break="isOnBreak"
@start-live-timer="emit('startLiveTimer')"
@stop-live-timer="emit('stopLiveTimer')"
@update-timer="emit('updateTimeEntry')"
@start-timer="emit('startTimer')"
@create-time-entry="emit('createTimeEntry')"
@keydown.enter="startTimerIfNotActive"></TimeTrackerRangeSelector>
@keydown.enter="onRangeEnter"></TimeTrackerRangeSelector>
</div>
</div>
</div>
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:block">
<div class="pl-4 @2xl:pl-6 pr-3 hidden @2xl:flex items-center space-x-3">
<button
v-if="breaksEnabled && !isOnBreak && isActive"
type="button"
title="Take a break"
aria-label="Take a break"
class="flex items-center justify-center w-9 h-9 rounded-full bg-quaternary text-text-tertiary hover:text-amber-500 focus:ring-2 focus:ring-border-tertiary transition"
@click="emit('startBreak')">
<Coffee class="w-5 h-5" />
</button>
<TimeTrackerStartStop
:active="isActive"
:variant="isOnBreak ? 'break' : 'primary'"
size="large"
@changed="onToggleButtonPress"></TimeTrackerStartStop>
</div>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useFocus } from '@vueuse/core';
import { autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/vue';
import TimeTrackerRecentlyTrackedEntry from '@/packages/ui/src/TimeTracker/TimeTrackerRecentlyTrackedEntry.vue';
import { useSelectEvents } from '@/packages/ui/src/utils/select';
import type { Project, Task, TimeEntry } from '@/packages/api/src';
const currentTimeEntry = defineModel<TimeEntry>('currentTimeEntry', { required: true });
const props = defineProps<{
timeEntries: TimeEntry[];
projects: Project[];
tasks: Task[];
isActive: boolean;
}>();
const emit = defineEmits<{ startTimer: []; updateTimeEntry: [] }>();
const currentTimeEntryDescriptionInput = ref<HTMLInputElement | null>(null);
const tempDescription = ref(currentTimeEntry.value.description);
watch(
() => currentTimeEntry.value.description,
() => {
tempDescription.value = currentTimeEntry.value.description;
}
);
function updateTimeEntryDescription() {
if (currentTimeEntry.value.description !== tempDescription.value) {
currentTimeEntry.value.description = tempDescription.value;
emit('updateTimeEntry');
}
}
function setCurrentTimeEntry(timeEntry: TimeEntry) {
currentTimeEntry.value.description = timeEntry.description;
currentTimeEntry.value.project_id = timeEntry.project_id;
currentTimeEntry.value.task_id = timeEntry.task_id;
currentTimeEntry.value.tags = timeEntry.tags;
currentTimeEntry.value.billable = timeEntry.billable;
currentTimeEntry.value.type = timeEntry.type;
}
function setAndStartTimer(timeEntry: TimeEntry) {
setCurrentTimeEntry(timeEntry);
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
// Starts the timer from the description input / range selector Enter: picks the highlighted
// recently-tracked entry if one is active, otherwise commits the typed description.
function submit() {
if (highlightedDropdownEntryId.value) {
const timeEntry = filteredRecentlyTrackedTimeEntries.value.find(
(item) => item.id === highlightedDropdownEntryId.value
);
if (timeEntry) {
setCurrentTimeEntry(timeEntry);
showDropdown.value = false;
}
} else {
currentTimeEntry.value.description = tempDescription.value;
}
if (!props.isActive) {
emit('startTimer');
} else {
emit('updateTimeEntry');
}
}
const filteredRecentlyTrackedTimeEntries = computed(() => {
// do not include running time entries and breaks (breaks are started via the break button)
const finishedTimeEntries = props.timeEntries.filter(
(item) => item.end !== null && item.type !== 'break'
);
// filter out duplicates based on description, task, project, tags and billable
const nonDuplicateTimeEntries = finishedTimeEntries.filter((item, index, self) => {
return (
index ===
self.findIndex(
(t) =>
t.description === item.description &&
t.task_id === item.task_id &&
t.project_id === item.project_id &&
t.tags.length === item.tags.length &&
t.tags.every((tag) => item.tags.includes(tag)) &&
t.billable === item.billable
)
);
});
// filter time entries based on current description
return nonDuplicateTimeEntries
.filter((item) => {
return item.description
?.toLowerCase()
?.includes(tempDescription.value?.toLowerCase()?.trim() || '');
})
.slice(0, 5);
});
const showDropdown = ref(false);
const blockRefocus = ref(false);
const { focused } = useFocus(currentTimeEntryDescriptionInput);
watch(focused, (focused) => {
nextTick(() => {
// make sure the click event on the dropdown does not get interrupted
showDropdown.value = focused;
// make sure that the input does not get refocused after the dropdown is closed
if (!focused) {
blockRefocus.value = true;
setTimeout(() => {
blockRefocus.value = false;
}, 100);
}
});
});
const floating = ref(null);
const { floatingStyles } = useFloating(currentTimeEntryDescriptionInput, floating, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(10),
shift({
limiter: limitShift({
offset: 5,
}),
}),
flip({
fallbackAxisSideDirection: 'start',
}),
],
});
const highlightedDropdownEntryId = ref<string | null>(null);
useSelectEvents(
filteredRecentlyTrackedTimeEntries,
highlightedDropdownEntryId,
(item) => item.id,
showDropdown
);
// Called by the shell after the start/stop button starts a timer, so typing can continue.
function focusAfterStart() {
if (!blockRefocus.value) {
currentTimeEntryDescriptionInput.value?.focus();
}
}
defineExpose({ submit, focusAfterStart });
</script>
<template>
<input
ref="currentTimeEntryDescriptionInput"
v-model="tempDescription"
placeholder="What are you working on?"
data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
type="text"
@keydown.enter="submit"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" />
<div
v-if="showDropdown && filteredRecentlyTrackedTimeEntries.length > 0"
ref="floating"
class="z-50 w-[min(640px,100vw-2rem)]"
:style="floatingStyles">
<div
class="rounded-lg w-full border border-card-border overflow-hidden shadow-dropdown bg-card-background">
<div
class="text-text-tertiary text-xs font-semibold border-b border-border-tertiary px-2 py-1.5">
Recently Tracked Time Entries
</div>
<div class="text-text-secondary py-1 px-1.5">
<TimeTrackerRecentlyTrackedEntry
v-for="timeEntry in filteredRecentlyTrackedTimeEntries"
:key="timeEntry.id"
:time-entry="timeEntry"
:highlighted="highlightedDropdownEntryId === timeEntry.id"
:projects="projects"
:tasks="tasks"
@mousedown="setAndStartTimer(timeEntry)"
@mouseenter="
highlightedDropdownEntryId = timeEntry.id
"></TimeTrackerRecentlyTrackedEntry>
</div>
</div>
</div>
</template>

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