mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-10 09:12:15 +01:00
Compare commits
21 Commits
5b89be04ce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de13c07855 | ||
|
|
29a2e994cd | ||
|
|
f6d886b218 | ||
|
|
80d98b30a1 | ||
|
|
32f2f1431b | ||
|
|
8f6d584ee9 | ||
|
|
1905cbf40c | ||
|
|
c8f668238e | ||
|
|
bf11bacdee | ||
|
|
f3c6a0b8ae | ||
|
|
619c602571 | ||
|
|
600daf44d9 | ||
|
|
a1d6c92806 | ||
|
|
ce1bd6a435 | ||
|
|
79d85227c2 | ||
|
|
885abf3333 | ||
|
|
c0c8fee6be | ||
|
|
cbcd1e51f6 | ||
|
|
114a32536d | ||
|
|
ff8a0f065b | ||
|
|
44fd0ffb91 |
@@ -1,5 +1,7 @@
|
||||
.git
|
||||
**/.git
|
||||
.gitmodules
|
||||
**/.gitmodules
|
||||
.github
|
||||
.DS_Store
|
||||
.fleet
|
||||
@@ -8,6 +10,13 @@
|
||||
*.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
k8s
|
||||
docs
|
||||
e2e
|
||||
tests
|
||||
|
||||
docker-compose.yml
|
||||
docker/local
|
||||
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
@@ -16,6 +25,18 @@ test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
playwright/.cache
|
||||
openapi.json
|
||||
playwright
|
||||
playwright.config.ts
|
||||
vitest.config.ts
|
||||
phpunit.xml
|
||||
phpstan.neon
|
||||
pint.json
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
jsconfig.json
|
||||
postcss.config.js
|
||||
tailwind.config.js
|
||||
|
||||
node_modules
|
||||
extensions/*/node_modules
|
||||
@@ -30,3 +51,4 @@ _ide_helper.php
|
||||
.phpstorm.meta.php
|
||||
|
||||
storage/logs/*
|
||||
storage/*.key
|
||||
|
||||
25
.github/VOUCHED.td
vendored
Normal file
25
.github/VOUCHED.td
vendored
Normal 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
|
||||
1
.github/workflows/build-onpremise.yml
vendored
1
.github/workflows/build-onpremise.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-onpremise.yml'
|
||||
- '.dockerignore'
|
||||
- 'extensions/manifest.json'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
1
.github/workflows/build-private.yml
vendored
1
.github/workflows/build-private.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-private.yml'
|
||||
- '.dockerignore'
|
||||
- 'extensions/manifest.json'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
1
.github/workflows/build-public.yml
vendored
1
.github/workflows/build-public.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
- '.dockerignore'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
75
.github/workflows/vouch-check-pr.yml
vendored
Normal file
75
.github/workflows/vouch-check-pr.yml
vendored
Normal 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 }}
|
||||
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal file
33
.github/workflows/vouch-manage-by-discussion.yml
vendored
Normal 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 }}
|
||||
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal file
35
.github/workflows/vouch-manage-by-issue.yml
vendored
Normal 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 }}
|
||||
@@ -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 we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,7 +68,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$query = TimeEntry::query()
|
||||
->where('organization_id', $organization->getKey())
|
||||
->where('user_id', $member->user_id)
|
||||
->where('member_id', $member->getKey())
|
||||
->when($exclude !== null, function (Builder $q) use ($exclude): void {
|
||||
$q->where('id', '!=', $exclude->getKey());
|
||||
})
|
||||
@@ -108,8 +108,8 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Get time entries in organization
|
||||
*
|
||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
|
||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||
*
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
*
|
||||
@@ -119,16 +119,17 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
}
|
||||
|
||||
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||
|
||||
$totalCount = $timeEntriesQuery->count();
|
||||
|
||||
@@ -159,7 +160,7 @@ class TimeEntryController extends Controller
|
||||
if ($timeEntries->count() === 0) {
|
||||
Log::warning('User has has more than '.$limit.' time entries on one date', [
|
||||
'date' => $lastDate->toDateString(),
|
||||
'user_id' => $request->input('user_id'),
|
||||
'member_id' => $request->input('member_id'),
|
||||
'auth_user_id' => Auth::id(),
|
||||
'limit' => $limit,
|
||||
]);
|
||||
@@ -223,9 +224,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -242,7 +244,7 @@ class TimeEntryController extends Controller
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
|
||||
$timeEntriesQuery->with([
|
||||
'task',
|
||||
'client',
|
||||
@@ -265,7 +267,7 @@ class TimeEntryController extends Controller
|
||||
if ($viewFile === false) {
|
||||
throw new \LogicException('View file not found');
|
||||
}
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery,
|
||||
null,
|
||||
@@ -372,9 +374,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -385,7 +388,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$group1Type = $request->getGroup();
|
||||
$group2Type = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -421,9 +424,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($member !== null && $member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $memberFilter */
|
||||
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:view:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:view:all');
|
||||
@@ -439,7 +443,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
$group = $request->getGroup();
|
||||
$subGroup = $request->getSubGroup();
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
|
||||
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
|
||||
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
|
||||
|
||||
@@ -583,7 +587,7 @@ class TimeEntryController extends Controller
|
||||
{
|
||||
/** @var Member $member */
|
||||
$member = Member::query()->findOrFail($request->input('member_id'));
|
||||
if ($member->user_id === Auth::id()) {
|
||||
if ($member->getKey() === $this->member($organization)->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:create:own');
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:create:all');
|
||||
@@ -630,9 +634,10 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
|
||||
{
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||
$member = $this->member($organization);
|
||||
/** @var Member|null $newMember */
|
||||
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
|
||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
@@ -664,6 +669,10 @@ class TimeEntryController extends Controller
|
||||
}
|
||||
|
||||
$timeEntry->fill($request->validated());
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user()->associate($newMember->user);
|
||||
}
|
||||
$timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
|
||||
$timeEntry->setComputedAttributeValue('billable_rate');
|
||||
$timeEntry->save();
|
||||
@@ -693,6 +702,7 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
|
||||
{
|
||||
$member = $this->member($organization);
|
||||
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
|
||||
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
|
||||
|
||||
@@ -717,6 +727,9 @@ class TimeEntryController extends Controller
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
/** @var Member|null $newMember */
|
||||
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
|
||||
|
||||
$project = null;
|
||||
$client = null;
|
||||
$overwriteClient = false;
|
||||
@@ -743,7 +756,7 @@ class TimeEntryController extends Controller
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
|
||||
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
@@ -766,6 +779,10 @@ class TimeEntryController extends Controller
|
||||
$oldTask = $timeEntry->task;
|
||||
|
||||
$timeEntry->fill($changes);
|
||||
if ($newMember !== null) {
|
||||
$timeEntry->member()->associate($newMember);
|
||||
$timeEntry->user_id = $newMember->user_id;
|
||||
}
|
||||
// If project is changed, but task is not, we remove the old task from the time entry
|
||||
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
|
||||
$timeEntry->task()->disassociate();
|
||||
@@ -806,7 +823,8 @@ class TimeEntryController extends Controller
|
||||
*/
|
||||
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
|
||||
{
|
||||
if ($timeEntry->member->user_id === Auth::id()) {
|
||||
$member = $this->member($organization);
|
||||
if ($timeEntry->member_id === $member->getKey()) {
|
||||
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
|
||||
@@ -863,7 +881,7 @@ class TimeEntryController extends Controller
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
|
||||
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
|
||||
$error->push($id);
|
||||
|
||||
continue;
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
app/Http/Middleware/TrustHosts.php
Normal file
56
app/Http/Middleware/TrustHosts.php
Normal 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)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -189,7 +189,9 @@ ENV WITH_HORIZON=false \
|
||||
WITH_SCHEDULER=false \
|
||||
WITH_REVERB=false
|
||||
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./
|
||||
|
||||
RUN test -z "$(find . -name .git -print -quit)"
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||
|
||||
RUN mkdir -p \
|
||||
|
||||
@@ -2315,7 +2315,9 @@ test('test that break entries show a break badge and split day total on the time
|
||||
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 the break portion separately from worked time
|
||||
// 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()).toContainText('break');
|
||||
await expect(page.getByTestId('day_break_duration').first().locator('..')).toContainText(
|
||||
'2h 00min work · 0h 30min break'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createTimeEntryWithTimestampsViaApi,
|
||||
getTimeEntriesViaApi,
|
||||
updateOrganizationSettingViaApi,
|
||||
type TestContext,
|
||||
} from './utils/api';
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
@@ -65,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();
|
||||
|
||||
@@ -665,37 +694,27 @@ test('test that adding a timesheet break to a full day splits the work entry via
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
// The break row is always present — enter a 30m break on Monday
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
const breakCell = await fillBreakCell(page, '0.5');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
// The placement modal opens with the split preview
|
||||
// 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([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
waitForBreakCreated(page),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
// The day now has two work halves and one break, none overlapping
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
expect(dayEntries).toHaveLength(3);
|
||||
expect(dayEntries.map((e) => e.type)).toEqual(['work', 'break', 'work']);
|
||||
// The break sits flush between the two halves
|
||||
expect(dayEntries[0].end).toBe(dayEntries[1].start);
|
||||
expect(dayEntries[1].end).toBe(dayEntries[2].start);
|
||||
// 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 ({
|
||||
@@ -722,28 +741,11 @@ test('test that adding a break into an oversized gap places it without moving ot
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
breakCell.press('Enter'),
|
||||
]);
|
||||
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 entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
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`],
|
||||
@@ -775,12 +777,7 @@ test('test that the placement modal warns when the chosen time would leave the b
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.5');
|
||||
const breakCell = await fillBreakCell(page, '0.5');
|
||||
await breakCell.press('Enter');
|
||||
|
||||
// Default suggestion sits flush between work → no warning
|
||||
@@ -804,64 +801,23 @@ test('test that the placement modal warns when the chosen time would leave the b
|
||||
|
||||
// The warning is non-blocking: the break can still be added as chosen
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
async (resp) =>
|
||||
resp.url().includes('/time-entries') &&
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.status() === 201 &&
|
||||
(await resp.json()).data.type === 'break'
|
||||
),
|
||||
waitForBreakCreated(page),
|
||||
page.getByRole('button', { name: 'Add break' }).click(),
|
||||
]);
|
||||
|
||||
const entries = await getTimeEntriesViaApi(ctx);
|
||||
const dayEntries = entries
|
||||
.filter((e) => e.start.startsWith(day))
|
||||
.sort((a, b) => a.start.localeCompare(b.start));
|
||||
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
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'does not align with your work entries' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('test that a misplaced break shows a warning on its timesheet day cell', async ({
|
||||
page,
|
||||
ctx,
|
||||
}) => {
|
||||
// Work ends at 10:00 and the break starts hours later with no work after it,
|
||||
// so it is misplaced and its day header should carry the warning hint.
|
||||
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
|
||||
const day = getCurrentWeekMonday().toISOString().slice(0, 10);
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T09:00:00Z`,
|
||||
end: `${day}T10:00:00Z`,
|
||||
});
|
||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||
start: `${day}T14:00:00Z`,
|
||||
end: `${day}T14:30:00Z`,
|
||||
type: 'break',
|
||||
});
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
|
||||
// Exactly one warning, sitting in Monday's day header
|
||||
const hint = page.getByRole('button', {
|
||||
name: 'does not align with your work entries',
|
||||
});
|
||||
await expect(hint).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByTestId('timesheet_day_header').first().getByRole('button', {
|
||||
name: 'does not align with your work entries',
|
||||
})
|
||||
).toBeVisible();
|
||||
await expect(hint).toBeVisible();
|
||||
|
||||
// The hint links to the calendar on the affected date
|
||||
// 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',
|
||||
@@ -894,12 +850,7 @@ test('test that editing a timesheet break re-places it as one entry instead of f
|
||||
|
||||
await goToTimesheet(page);
|
||||
await expect(page.getByTestId('timesheet_view')).toBeVisible();
|
||||
const breakRow = page
|
||||
.locator('[data-testid="timesheet_row"]')
|
||||
.filter({ has: page.getByText('Break', { exact: true }) });
|
||||
const breakCell = breakRow.locator('[data-testid="timesheet_cell"]').nth(0).locator('input');
|
||||
await breakCell.click();
|
||||
await breakCell.fill('0.75'); // 45 minutes — still fits the 1h gap, so it stays anchored
|
||||
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.
|
||||
@@ -915,10 +866,61 @@ test('test that editing a timesheet break re-places it as one entry instead of f
|
||||
|
||||
// Still exactly one break on the day (not fragmented). It stays anchored at its current
|
||||
// start (12:15) rather than re-centering, growing its end to 13:00 to reach 45 minutes.
|
||||
const after = await getTimeEntriesViaApi(ctx);
|
||||
const breaks = after.filter((e) => e.start.startsWith(day) && e.type === 'break');
|
||||
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`],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.1"
|
||||
"ref": "v0.0.3"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
@@ -9,6 +9,6 @@
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.1"
|
||||
"ref": "v0.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
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: [] }>();
|
||||
@@ -54,7 +56,11 @@ const durationSeconds = computed(() =>
|
||||
|
||||
const splitPlan = computed(() => {
|
||||
if (!props.request || mode.value !== 'split' || durationSeconds.value <= 0) return null;
|
||||
return planSplitEntry(props.request.workEntries[0]!, durationSeconds.value, utcStart.value);
|
||||
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(() => {
|
||||
@@ -116,31 +122,43 @@ function fmt(iso: string): string {
|
||||
const explanation = computed(() => {
|
||||
if (!props.request) return '';
|
||||
return mode.value === 'split'
|
||||
? "There's no free gap that fits this break, so the work entry will be split and the break placed inside it."
|
||||
? "There's no free gap that fits this break, so the 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.";
|
||||
});
|
||||
|
||||
// Human-readable summary of what will change, so the user can confirm the edit.
|
||||
const changeSummary = computed<string[]>(() => {
|
||||
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 [
|
||||
`${fmt(plan.firstHalf.start)}–${fmt(plan.firstHalf.end)} (work)`,
|
||||
`${fmt(plan.breakSlot.start)}–${fmt(plan.breakSlot.end)} (break)`,
|
||||
`${fmt(plan.secondHalf.start)}–${fmt(plan.secondHalf.end)} (work)`,
|
||||
{ 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 ['No entries need to move.'];
|
||||
if (plan.shifted.length === 0) return [{ times: 'No entries need to move.', label: '' }];
|
||||
return plan.shifted.map((shift) => {
|
||||
const isBreak = props.request!.otherEntries.some((e) => e.id === shift.id);
|
||||
const original =
|
||||
props.request!.workEntries.find((e) => e.id === shift.id) ??
|
||||
props.request!.otherEntries.find((e) => e.id === shift.id)!;
|
||||
const label = `${fmt(original.start)}–${fmt(original.end)} → ${fmt(shift.start)}–${fmt(shift.end)}`;
|
||||
return isBreak ? `${label} (break)` : label;
|
||||
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) };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,8 +200,14 @@ async function submit() {
|
||||
<div class="text-xs uppercase tracking-wide text-text-tertiary">
|
||||
{{ mode === 'split' ? 'Result' : 'Entries that move' }}
|
||||
</div>
|
||||
<div v-for="(line, index) in changeSummary" :key="index" class="tabular-nums">
|
||||
{{ line }}
|
||||
<div
|
||||
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
|
||||
@@ -202,7 +226,7 @@ async function submit() {
|
||||
class="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{{
|
||||
mode === 'split'
|
||||
? "This break doesn't fit there — it must lie inside the work entry, leaving at least a minute of work on each side."
|
||||
? "This break doesn't fit 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>
|
||||
|
||||
@@ -186,19 +186,27 @@ const emit = defineEmits<{
|
||||
? 'text-text-primary'
|
||||
: 'text-text-secondary',
|
||||
]">
|
||||
<span>{{ 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>
|
||||
class="font-normal text-text-tertiary"
|
||||
>{{ formatDuration(breakDayTotals[dayIndex] ?? 0) }} break</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
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 }}</span>
|
||||
<span v-if="breakGrandTotal > 0" class="font-normal text-text-tertiary">
|
||||
+{{ formatDuration(breakGrandTotal) }} break
|
||||
</span>
|
||||
<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>
|
||||
|
||||
@@ -125,7 +125,24 @@ const {
|
||||
breakPlacementRequest,
|
||||
applyBreakPlacement,
|
||||
dismissBreakPlacement,
|
||||
} = useTimesheetCellMutations(weekDays, allTimeEntries, rows, removeSlot);
|
||||
} = useTimesheetCellMutations(
|
||||
weekDays,
|
||||
allTimeEntries,
|
||||
rows,
|
||||
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.
|
||||
@@ -251,6 +268,7 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
||||
<BreakPlacementModal
|
||||
:request="breakPlacementRequest"
|
||||
:apply="applyBreakPlacement"
|
||||
:entry-label="breakPlanEntryLabel"
|
||||
@cancel="dismissBreakPlacement" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,27 @@ 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>
|
||||
@@ -24,12 +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) }}
|
||||
<template v-if="breakSecondsValue > 0">
|
||||
· {{ formatHumanReadableDuration(breakSecondsValue, intervalFormat, numberFormat) }}
|
||||
break
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="block text-xs text-muted-foreground font-medium mt-0.5"
|
||||
data-testid="day_duration_summary"
|
||||
>{{ durationSummary }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,13 +6,13 @@ 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');
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
date: string;
|
||||
duration: number;
|
||||
@@ -23,6 +23,26 @@ withDefaults(
|
||||
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: [];
|
||||
@@ -60,29 +80,14 @@ function selectUnselectAll(value: boolean) {
|
||||
{{ formatDate(date, organization?.date_format) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-text-primary pr-2 @lg:pr-[92px]">
|
||||
<div class="flex items-center text-text-primary pr-2 @lg:pr-[92px]">
|
||||
<span class="font-medium">{{ workLabel }}</span>
|
||||
<span
|
||||
v-if="breakDuration > 0"
|
||||
v-if="hasBreak"
|
||||
data-testid="day_break_duration"
|
||||
class="text-text-secondary font-normal mr-2">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
breakDuration,
|
||||
organization?.interval_format,
|
||||
organization?.number_format
|
||||
)
|
||||
}}
|
||||
break ·
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
{{
|
||||
formatHumanReadableDuration(
|
||||
duration,
|
||||
organization?.interval_format,
|
||||
organization?.number_format
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
class="text-text-secondary font-normal whitespace-pre"
|
||||
>{{ breakLabel }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</MainContainer>
|
||||
|
||||
@@ -2,14 +2,31 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import {
|
||||
findMisplacedBreak,
|
||||
getBreakPlacementHint,
|
||||
type BreakPlacementHint,
|
||||
} from '@/packages/ui/src/utils/breakPlacement';
|
||||
|
||||
// Decision logic behind the aggregate (collapsed grouped-break) row's placement
|
||||
// warning: the row shows the hint — and navigates the calendar — based on the
|
||||
// first misplaced break in the group.
|
||||
function breakEntry(id: string): TimeEntry {
|
||||
return { id, type: 'break', start: '2026-07-14T10:00:00Z' } as TimeEntry;
|
||||
function entry(
|
||||
id: string,
|
||||
start: string,
|
||||
end: string | null,
|
||||
type: 'work' | 'break' = 'work'
|
||||
): TimeEntry {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
start,
|
||||
end,
|
||||
duration: end ? (Date.parse(end) - Date.parse(start)) / 1000 : null,
|
||||
organization_id: 'organization-1',
|
||||
user_id: 'user-1',
|
||||
member_id: 'member-1',
|
||||
project_id: type === 'break' ? null : 'project-1',
|
||||
task_id: null,
|
||||
billable: false,
|
||||
description: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
function hint(misplaced: boolean): BreakPlacementHint {
|
||||
@@ -24,34 +41,99 @@ function hint(misplaced: boolean): BreakPlacementHint {
|
||||
|
||||
describe('findMisplacedBreak', () => {
|
||||
it('returns the first misplaced break in a group', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
const entries = [
|
||||
entry('break-a', '2026-07-14T10:00:00Z', '2026-07-14T10:30:00Z', 'break'),
|
||||
entry('break-b', '2026-07-14T12:00:00Z', '2026-07-14T12:30:00Z', 'break'),
|
||||
];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-b': hint(true),
|
||||
});
|
||||
expect(result?.id).toBe('break-b');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no break in the group is misplaced', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-b': hint(false),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
describe('getBreakPlacementHint', () => {
|
||||
const breakEntry = entry('break', '2026-07-14T12:00:00Z', '2026-07-14T12:30:00Z', 'break');
|
||||
|
||||
it('accepts work touching both sides of the break', () => {
|
||||
const result = getBreakPlacementHint(breakEntry, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
|
||||
entry('afternoon', '2026-07-14T12:30:00Z', '2026-07-14T17:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the group has no placement hints', () => {
|
||||
const entries = [breakEntry('break-a'), breakEntry('break-b')];
|
||||
expect(findMisplacedBreak(entries, {})).toBeNull();
|
||||
it('accepts gaps exactly at the placement tolerance', () => {
|
||||
const result = getBreakPlacementHint(breakEntry, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T11:30:00Z'),
|
||||
entry('afternoon', '2026-07-14T13:00:00Z', '2026-07-14T17:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 30 * 60,
|
||||
gapAfterSeconds: 30 * 60,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores hints for entries that are not in the group', () => {
|
||||
const entries = [breakEntry('break-a')];
|
||||
const result = findMisplacedBreak(entries, {
|
||||
'break-a': hint(false),
|
||||
'break-elsewhere': hint(true),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
it('flags a completed break when work is missing on either side', () => {
|
||||
const noPreviousWork = getBreakPlacementHint(breakEntry, [
|
||||
entry('afternoon', '2026-07-14T12:30:00Z', '2026-07-14T17:00:00Z'),
|
||||
]);
|
||||
const noNextWork = getBreakPlacementHint(breakEntry, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(noPreviousWork).toEqual(
|
||||
expect.objectContaining({ misplaced: true, gapBeforeSeconds: null })
|
||||
);
|
||||
expect(noNextWork).toEqual(
|
||||
expect.objectContaining({ misplaced: true, gapAfterSeconds: null })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not require work after a running break', () => {
|
||||
const runningBreak = entry('break', '2026-07-14T12:00:00Z', null, 'break');
|
||||
const result = getBreakPlacementHint(runningBreak, [
|
||||
entry('morning', '2026-07-14T09:00:00Z', '2026-07-14T12:00:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats work overlapping the break as touching both sides', () => {
|
||||
const result = getBreakPlacementHint(breakEntry, [
|
||||
entry('overlapping', '2026-07-14T11:45:00Z', '2026-07-14T12:15:00Z'),
|
||||
]);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
misplaced: false,
|
||||
gapBeforeSeconds: 0,
|
||||
gapAfterSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for work entries', () => {
|
||||
expect(
|
||||
getBreakPlacementHint(entry('work', '2026-07-14T09:00:00Z', '2026-07-14T10:00:00Z'), [])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"./dist",
|
||||
"./node_modules",
|
||||
"./__tests__",
|
||||
"./coverage"
|
||||
"./coverage",
|
||||
"src/**/*.test.ts"
|
||||
],
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BREAK_GAP_TOLERANCE_SECONDS,
|
||||
buildDayPlacementContext,
|
||||
decideBreakPlacement,
|
||||
findAdjacentBreakSlot,
|
||||
findBreakSlotNearInDay,
|
||||
findValidBreakGap,
|
||||
findValidBreakGapNear,
|
||||
planMoveInsert,
|
||||
@@ -19,6 +22,7 @@ const HOUR = 3600;
|
||||
const DAY = '2026-07-14';
|
||||
const dayStart = `${DAY}T00:00:00Z`;
|
||||
const dayEnd = `${DAY}T24:00:00Z`;
|
||||
const MIDNIGHT = '2026-07-15T00:00:00Z';
|
||||
|
||||
function iv(startH: number, endH: number) {
|
||||
const h = (n: number) => {
|
||||
@@ -30,6 +34,84 @@ function iv(startH: number, endH: number) {
|
||||
return { start: h(startH), end: h(endH) };
|
||||
}
|
||||
|
||||
describe('decideBreakPlacement', () => {
|
||||
const context = (work: MovableInterval[] = []) => ({
|
||||
work,
|
||||
breaks: [],
|
||||
blocked: [],
|
||||
dayStart,
|
||||
dayEnd: MIDNIGHT,
|
||||
});
|
||||
const movable = (id: string, startH: number, endH: number): MovableInterval => ({
|
||||
id,
|
||||
...iv(startH, endH),
|
||||
});
|
||||
|
||||
it('returns a direct save when an existing gap fits', () => {
|
||||
const decision = decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context([movable('morning', 9, 12), movable('afternoon', 13, 17)]),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: 'save',
|
||||
slot: { start: `${DAY}T12:00:00Z`, end: `${DAY}T13:00:00Z` },
|
||||
});
|
||||
});
|
||||
|
||||
it('selects generic free-window placement for an empty day', () => {
|
||||
expect(
|
||||
decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context(),
|
||||
})
|
||||
).toEqual({ kind: 'place-in-free-window' });
|
||||
});
|
||||
|
||||
it('returns a modal request when work must be rearranged', () => {
|
||||
const decision = decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context([movable('work', 9, 17)]),
|
||||
});
|
||||
|
||||
expect(decision).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'needs-input',
|
||||
request: expect.objectContaining({
|
||||
date: DAY,
|
||||
defaultBreakStart: `${DAY}T13:00:00Z`,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('saves the break next to work too short to split', () => {
|
||||
const decision = decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HALF_HOUR,
|
||||
context: context([movable('quick', 9, 9 + 1 / 60)]),
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: 'save',
|
||||
slot: { start: `${DAY}T09:01:00Z`, end: `${DAY}T09:31:00Z` },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a day that cannot fit or rearrange the break', () => {
|
||||
expect(
|
||||
decideBreakPlacement({
|
||||
date: DAY,
|
||||
durationSeconds: HOUR,
|
||||
context: context([movable('all-day', 0, 24)]),
|
||||
})
|
||||
).toEqual({ kind: 'reject' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidBreakGap', () => {
|
||||
it('centers the break in a gap that fits within tolerance', () => {
|
||||
// 09-12 and 13-17 → 1h gap, 30m break → centered at 12:15-12:45
|
||||
@@ -106,53 +188,197 @@ describe('findValidBreakGap', () => {
|
||||
});
|
||||
|
||||
describe('planSplitEntry', () => {
|
||||
it('splits a single entry and centers the break', () => {
|
||||
const workSeconds = (plan: NonNullable<ReturnType<typeof planSplitEntry>>) =>
|
||||
dayjs.utc(plan.firstHalf.end).diff(dayjs.utc(plan.firstHalf.start), 'second') +
|
||||
dayjs.utc(plan.secondHalf.end).diff(dayjs.utc(plan.secondHalf.start), 'second');
|
||||
|
||||
it('splits a single entry in the middle and keeps the work length', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf.start).toBe(`${DAY}T09:00:00Z`);
|
||||
expect(plan!.breakSlot.start).toBe(plan!.firstHalf.end);
|
||||
expect(plan!.secondHalf.start).toBe(plan!.breakSlot.end);
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T17:00:00Z`);
|
||||
// break is 30m and centered → 12:45-13:15
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:45:00Z`, end: `${DAY}T13:15:00Z` });
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T13:00:00Z`, end: `${DAY}T13:30:00Z` });
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T17:30:00Z`);
|
||||
expect(workSeconds(plan!)).toBe(8 * HOUR);
|
||||
});
|
||||
|
||||
it('accepts a break far longer than the work entry', () => {
|
||||
const plan = planSplitEntry(iv(9, 9.5), 4 * HOUR);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:15:00Z` });
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T09:15:00Z`, end: `${DAY}T13:15:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T13:15:00Z`, end: `${DAY}T13:30:00Z` });
|
||||
expect(workSeconds(plan!)).toBe(HALF_HOUR);
|
||||
});
|
||||
|
||||
it('honors an explicit break start', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T10:00:00Z`);
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T10:00:00Z` });
|
||||
expect(plan!.secondHalf.start).toBe(`${DAY}T10:30:00Z`);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T10:30:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T17:30:00Z` });
|
||||
expect(workSeconds(plan!)).toBe(8 * HOUR);
|
||||
});
|
||||
|
||||
it('returns null when the entry is too short to leave work on both sides', () => {
|
||||
expect(planSplitEntry(iv(9, 9.25), HALF_HOUR)).toBeNull();
|
||||
expect(
|
||||
planSplitEntry({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:30Z` }, HALF_HOUR)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start before the entry instead of clamping it', () => {
|
||||
// 07:00 lies before the 09:00-17:00 entry — relocating it silently would
|
||||
// leave a hair-thin first fragment at a time the user never picked.
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T07:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start whose break would reach past the entry end', () => {
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:45:00Z`)).toBeNull();
|
||||
it('rejects an explicit break start at or after the end of the entry', () => {
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T17:00:00Z`)).toBeNull();
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T18:00:00Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an explicit break start that leaves less than the minimum fragment', () => {
|
||||
// 09:00:30 would leave only 30s of work before the break.
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T09:00:30Z`)).toBeNull();
|
||||
expect(planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T16:59:30Z`)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts an explicit break start leaving exactly the minimum fragment on each side', () => {
|
||||
// 09:01 leaves 60s before; on a 09:00-09:32 entry a 30m break also leaves 60s after.
|
||||
const plan = planSplitEntry(iv(9, 9 + 32 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
|
||||
const plan = planSplitEntry(iv(9, 9 + 2 / 60), HALF_HOUR, `${DAY}T09:01:00Z`);
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T09:00:00Z`, end: `${DAY}T09:01:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T09:31:00Z`, end: `${DAY}T09:32:00Z` });
|
||||
});
|
||||
|
||||
it('returns null when the entry cannot hold the break plus a minimum fragment per side', () => {
|
||||
// 31 minutes of work cannot hold a 30m break with 60s of work on each side.
|
||||
expect(planSplitEntry(iv(9, 9 + 31 / 60), HALF_HOUR)).toBeNull();
|
||||
it('leaves later entries alone when the extended work does not reach them', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
|
||||
otherEntries: [
|
||||
{ id: 'early', ...iv(8, 8.5) },
|
||||
{ id: 'later', ...iv(18, 19) },
|
||||
],
|
||||
});
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T12:30:00Z`, end: `${DAY}T17:30:00Z` });
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('pushes only the collision chain and preserves later entry times', () => {
|
||||
const plan = planSplitEntry(iv(9, 17), HALF_HOUR, `${DAY}T12:00:00Z`, {
|
||||
otherEntries: [
|
||||
{ id: 'first', ...iv(17.25, 17.5) },
|
||||
{ id: 'second', ...iv(17.6, 17.9) },
|
||||
{ id: 'distant', ...iv(20, 21) },
|
||||
],
|
||||
});
|
||||
|
||||
// Moving the first break also collides with the second.
|
||||
expect(plan!.shifted).toEqual([
|
||||
{ id: 'first', ...iv(17.5, 17.75) },
|
||||
{ id: 'second', ...iv(17.75, 18.05) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts the work earlier when pushing it later would leave the day', () => {
|
||||
const plan = planSplitEntry(iv(22, 23.5), HOUR, undefined, { dayStart, dayEnd });
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.firstHalf).toEqual({ start: `${DAY}T21:30:00Z`, end: `${DAY}T22:15:00Z` });
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T22:15:00Z`, end: `${DAY}T23:15:00Z` });
|
||||
expect(plan!.secondHalf).toEqual({ start: `${DAY}T23:15:00Z`, end: MIDNIGHT });
|
||||
expect(workSeconds(plan!)).toBe(1.5 * HOUR);
|
||||
});
|
||||
|
||||
it('reproduces its own plan when re-planned from the break start it chose', () => {
|
||||
const options = { dayStart, dayEnd };
|
||||
const suggested = planSplitEntry(iv(22, 23.5), HOUR, undefined, options);
|
||||
const replanned = planSplitEntry(iv(22, 23.5), HOUR, suggested!.breakSlot.start, options);
|
||||
expect(replanned).toEqual(suggested);
|
||||
});
|
||||
|
||||
it('returns null when the block cannot fit in the day at all', () => {
|
||||
expect(planSplitEntry(iv(0, 23), 2 * HOUR, undefined, { dayStart, dayEnd })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when starting earlier would run into an entry that stays put', () => {
|
||||
expect(
|
||||
planSplitEntry(iv(22, 23.75), HOUR, undefined, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: [{ id: 'earlier', ...iv(21, 21.5) }],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the actual collision chain would leave the day', () => {
|
||||
expect(
|
||||
planSplitEntry(iv(22, 23.5), HOUR, undefined, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: [{ id: 'late', ...iv(23.75, 24) }],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not reject a valid split because of an unrelated late entry', () => {
|
||||
const plan = planSplitEntry(iv(9, 10), HOUR, `${DAY}T09:30:00Z`, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: [{ id: 'late', ...iv(23.5, 24) }],
|
||||
});
|
||||
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan!.secondHalf.end).toBe(`${DAY}T11:00:00Z`);
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBreakSlotNearInDay', () => {
|
||||
const anchor = `${DAY}T23:00:00Z`;
|
||||
|
||||
it('keeps the break where it is when the new duration still fits', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, dayEnd, HALF_HOUR, anchor)).toEqual({
|
||||
start: anchor,
|
||||
end: `${DAY}T23:30:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('slides the break earlier instead of growing past midnight', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, dayEnd, 2 * HOUR, anchor)).toEqual({
|
||||
start: `${DAY}T22:00:00Z`,
|
||||
end: MIDNIGHT,
|
||||
});
|
||||
});
|
||||
|
||||
it('respects a day window shortened by a running entry', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, `${DAY}T12:00:00Z`, HOUR, anchor)).toEqual({
|
||||
start: `${DAY}T11:00:00Z`,
|
||||
end: `${DAY}T12:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the day has no free window big enough', () => {
|
||||
expect(findBreakSlotNearInDay(dayStart, dayEnd, 20 * HOUR, anchor, [iv(6, 18)])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAdjacentBreakSlot', () => {
|
||||
it('parks the break flush after the last work entry', () => {
|
||||
expect(findAdjacentBreakSlot([iv(9, 9.5)], dayStart, dayEnd, HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T09:30:00Z`,
|
||||
end: `${DAY}T10:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to flush before the first work entry', () => {
|
||||
expect(findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR)).toEqual({
|
||||
start: `${DAY}T08:30:00Z`,
|
||||
end: `${DAY}T09:00:00Z`,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a candidate blocked by an existing break', () => {
|
||||
expect(
|
||||
findAdjacentBreakSlot([iv(9, 24)], dayStart, dayEnd, HALF_HOUR, [iv(8.75, 9)])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when neither side fits inside the day', () => {
|
||||
expect(findAdjacentBreakSlot([iv(0, 24)], dayStart, dayEnd, HALF_HOUR)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,32 +405,6 @@ describe('planMoveInsert', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an oversized gap alone instead of pulling the right block flush', () => {
|
||||
// 09-12 and 15-17 (3h gap). Break flush after first at 12:00 fits in the gap
|
||||
// → nothing moves; the user's gap is preserved.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T12:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.breakSlot).toEqual({ start: `${DAY}T12:00:00Z`, end: `${DAY}T12:30:00Z` });
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not drag entries flush when the break sits mid-gap', () => {
|
||||
// Break at 13:00 in the middle of the 12:00-15:00 gap → neither side moves.
|
||||
const plan = planMoveInsert(
|
||||
[movable('a', 9, 12), movable('b', 15, 17)],
|
||||
dayStart,
|
||||
dayEnd,
|
||||
`${DAY}T13:00:00Z`,
|
||||
HALF_HOUR
|
||||
);
|
||||
expect(plan!.shifted).toEqual([]);
|
||||
});
|
||||
|
||||
it('shifts each side only as much as needed to clear the slot', () => {
|
||||
// Break 14:45-15:15 overlaps only the start of 'b' → 'b' pushed 15m later,
|
||||
// 'a' untouched.
|
||||
@@ -420,12 +620,6 @@ describe('findValidBreakGapNear', () => {
|
||||
expect(gap).toEqual({ start: `${DAY}T10:00:00Z`, end: `${DAY}T11:00:00Z` });
|
||||
});
|
||||
|
||||
it('clamps the anchor into the tolerance window when it sits too late', () => {
|
||||
// Anchored at 11:00 (beyond the window) → clamped back to 10:30.
|
||||
const gap = findValidBreakGapNear(work, HOUR, `${DAY}T11:00:00Z`);
|
||||
expect(gap).toEqual({ start: `${DAY}T10:30:00Z`, end: `${DAY}T11:30:00Z` });
|
||||
});
|
||||
|
||||
it('keeps the break in place inside an oversized gap', () => {
|
||||
// 09-10 and 14-15 → 4h gap. The break stays exactly where the user left it;
|
||||
// its distance from work is a soft hint, not a reason to move it.
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface SplitPlan {
|
||||
firstHalf: Interval;
|
||||
breakSlot: Interval;
|
||||
secondHalf: Interval;
|
||||
// Entries pushed later to clear the extended second half.
|
||||
shifted: MovableInterval[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,79 @@ export interface DayPlacementContext {
|
||||
dayEnd: string;
|
||||
}
|
||||
|
||||
export type BreakPlacementDecision =
|
||||
| { kind: 'save'; slot: Interval }
|
||||
| { kind: 'place-in-free-window' }
|
||||
| { kind: 'needs-input'; request: BreakPlacementRequest }
|
||||
| { kind: 'reject' };
|
||||
|
||||
/**
|
||||
* Decide how a requested break should be placed without performing any writes.
|
||||
* Keeping this policy pure lets the composable focus on UI state and persistence.
|
||||
*/
|
||||
export function decideBreakPlacement({
|
||||
date,
|
||||
durationSeconds,
|
||||
context,
|
||||
anchorStart = null,
|
||||
replaceBreakId = null,
|
||||
}: {
|
||||
date: string;
|
||||
durationSeconds: number;
|
||||
context: DayPlacementContext;
|
||||
anchorStart?: string | null;
|
||||
replaceBreakId?: string | null;
|
||||
}): BreakPlacementDecision {
|
||||
const { work, breaks, blocked, dayStart, dayEnd } = context;
|
||||
const obstacles = [...breaks, ...blocked];
|
||||
const gap =
|
||||
(anchorStart !== null
|
||||
? findValidBreakGapNear(work, durationSeconds, anchorStart, obstacles)
|
||||
: null) ?? findValidBreakGap(work, durationSeconds, obstacles);
|
||||
|
||||
if (gap) return { kind: 'save', slot: gap };
|
||||
|
||||
if (work.length === 0) {
|
||||
if (anchorStart === null) return { kind: 'place-in-free-window' };
|
||||
const slot = findBreakSlotNearInDay(
|
||||
dayStart,
|
||||
dayEnd,
|
||||
durationSeconds,
|
||||
anchorStart,
|
||||
obstacles
|
||||
);
|
||||
return slot ? { kind: 'save', slot } : { kind: 'reject' };
|
||||
}
|
||||
|
||||
const defaultPlan =
|
||||
work.length === 1
|
||||
? planSplitEntry(work[0]!, durationSeconds, undefined, {
|
||||
dayStart,
|
||||
dayEnd,
|
||||
otherEntries: breaks,
|
||||
})
|
||||
: suggestMovePlan(work, dayStart, dayEnd, durationSeconds, breaks);
|
||||
|
||||
if (!defaultPlan) {
|
||||
const adjacent = findAdjacentBreakSlot(work, dayStart, dayEnd, durationSeconds, obstacles);
|
||||
return adjacent ? { kind: 'save', slot: adjacent } : { kind: 'reject' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'needs-input',
|
||||
request: {
|
||||
date,
|
||||
durationSeconds,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
workEntries: work,
|
||||
otherEntries: breaks,
|
||||
defaultBreakStart: defaultPlan.breakSlot.start,
|
||||
replaceBreakId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDayPlacementContext(
|
||||
entries: DayEntryLike[],
|
||||
dayStart: string,
|
||||
@@ -169,6 +244,14 @@ function toIntervalMs(interval: Interval): IntervalMs {
|
||||
};
|
||||
}
|
||||
|
||||
function slotAt(startMs: number, durationMs: number): Interval {
|
||||
const dayjs = getDayJsInstance();
|
||||
return {
|
||||
start: dayjs.utc(startMs).format(),
|
||||
end: dayjs.utc(startMs + durationMs).format(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge overlapping/touching work intervals so the space between two
|
||||
* consecutive merged intervals is genuinely work-free. Without this, an entry
|
||||
@@ -218,17 +301,12 @@ export function findValidBreakGap(
|
||||
toleranceSeconds: number = BREAK_GAP_TOLERANCE_SECONDS
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const gaps = workFreeGapsMs(work);
|
||||
const obstaclesMs = obstacles.map(toIntervalMs);
|
||||
|
||||
const blockers = (startMs: number): IntervalMs[] =>
|
||||
obstaclesMs.filter((o) => startMs < o.endMs && o.startMs < startMs + durationMs);
|
||||
const slot = (startMs: number): Interval => ({
|
||||
start: dayjs.utc(startMs).format(),
|
||||
end: dayjs.utc(startMs + durationMs).format(),
|
||||
});
|
||||
|
||||
// Pass 1: a gap where the centered break keeps both sides within tolerance.
|
||||
for (const gap of gaps) {
|
||||
@@ -236,7 +314,7 @@ export function findValidBreakGap(
|
||||
if (gapMs < durationMs || gapMs > durationMs + 2 * toleranceSeconds * 1000) continue;
|
||||
const startMs = gap.startMs + Math.floor((gapMs - durationMs) / 2000) * 1000;
|
||||
if (blockers(startMs).length > 0) continue;
|
||||
return slot(startMs);
|
||||
return slotAt(startMs, durationMs);
|
||||
}
|
||||
|
||||
// Pass 2: any gap that can physically hold the break. Start flush after the
|
||||
@@ -245,7 +323,7 @@ export function findValidBreakGap(
|
||||
let startMs = gap.startMs;
|
||||
while (startMs + durationMs <= gap.endMs) {
|
||||
const blocking = blockers(startMs);
|
||||
if (blocking.length === 0) return slot(startMs);
|
||||
if (blocking.length === 0) return slotAt(startMs, durationMs);
|
||||
startMs = Math.max(...blocking.map((o) => o.endMs));
|
||||
}
|
||||
}
|
||||
@@ -274,98 +352,198 @@ export function findValidBreakGapNear(
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const anchorMs = dayjs.utc(anchorStart).valueOf();
|
||||
const obstaclesMs = obstacles.map(toIntervalMs);
|
||||
|
||||
for (const gap of workFreeGapsMs(work)) {
|
||||
// The anchor must fall inside this gap for it to be "where the break is".
|
||||
if (anchorMs < gap.startMs || anchorMs >= gap.endMs) continue;
|
||||
if (gap.endMs - gap.startMs < durationMs) return null;
|
||||
|
||||
// Walk the gap's free windows around obstacles and pick the start
|
||||
// closest to the anchor, so the break moves as little as possible
|
||||
// from where the user left it.
|
||||
const blockers = obstacles
|
||||
.map(toIntervalMs)
|
||||
.filter((o) => o.startMs < gap.endMs && o.endMs > gap.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
let best: number | null = null;
|
||||
const consider = (winStartMs: number, winEndMs: number) => {
|
||||
if (winEndMs - winStartMs < durationMs) return;
|
||||
const candidate = Math.min(Math.max(anchorMs, winStartMs), winEndMs - durationMs);
|
||||
if (best === null || Math.abs(candidate - anchorMs) < Math.abs(best - anchorMs)) {
|
||||
best = candidate;
|
||||
}
|
||||
};
|
||||
let cursor = gap.startMs;
|
||||
for (const blocker of blockers) {
|
||||
consider(cursor, blocker.startMs);
|
||||
cursor = Math.max(cursor, blocker.endMs);
|
||||
}
|
||||
consider(cursor, gap.endMs);
|
||||
|
||||
const best = closestFreeStartMs(gap, durationMs, anchorMs, obstaclesMs);
|
||||
if (best === null) return null;
|
||||
return { start: dayjs.utc(best).format(), end: dayjs.utc(best + durationMs).format() };
|
||||
return slotAt(best, durationMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A split must leave a meaningful chunk of work on each side of the break;
|
||||
// hair-thin fragments would only exist to make a bad placement "fit".
|
||||
/** Closest obstacle-free start to `anchorMs` that fits inside `range`. */
|
||||
function closestFreeStartMs(
|
||||
range: IntervalMs,
|
||||
durationMs: number,
|
||||
anchorMs: number,
|
||||
obstaclesMs: IntervalMs[]
|
||||
): number | null {
|
||||
if (range.endMs - range.startMs < durationMs) return null;
|
||||
const blockers = obstaclesMs
|
||||
.filter((o) => o.startMs < range.endMs && o.endMs > range.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
let best: number | null = null;
|
||||
const consider = (winStartMs: number, winEndMs: number) => {
|
||||
if (winEndMs - winStartMs < durationMs) return;
|
||||
const candidate = Math.min(Math.max(anchorMs, winStartMs), winEndMs - durationMs);
|
||||
if (best === null || Math.abs(candidate - anchorMs) < Math.abs(best - anchorMs)) {
|
||||
best = candidate;
|
||||
}
|
||||
};
|
||||
let cursor = range.startMs;
|
||||
for (const blocker of blockers) {
|
||||
consider(cursor, blocker.startMs);
|
||||
cursor = Math.max(cursor, blocker.endMs);
|
||||
}
|
||||
consider(cursor, range.endMs);
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Keep a workless-day break near its current start without leaving the day. */
|
||||
export function findBreakSlotNearInDay(
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
durationSeconds: number,
|
||||
anchorStart: string,
|
||||
obstacles: Interval[] = []
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const range = {
|
||||
startMs: dayjs.utc(dayStart).valueOf(),
|
||||
endMs: dayjs.utc(dayEnd).valueOf(),
|
||||
};
|
||||
const best = closestFreeStartMs(
|
||||
range,
|
||||
durationMs,
|
||||
dayjs.utc(anchorStart).valueOf(),
|
||||
obstacles.map(toIntervalMs)
|
||||
);
|
||||
if (best === null) return null;
|
||||
return slotAt(best, durationMs);
|
||||
}
|
||||
|
||||
export const MIN_SPLIT_FRAGMENT_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Split a single work entry to insert a break. `breakStart` (UTC ISO) lets the
|
||||
* caller position it; without one the break is centered. Returns null when the
|
||||
* entry is too short to leave at least MIN_SPLIT_FRAGMENT_SECONDS of work on
|
||||
* both sides of the break, or when an explicit `breakStart` would not — an
|
||||
* out-of-range request is rejected rather than clamped, because silently
|
||||
* relocating the break would contradict the time the user picked.
|
||||
*/
|
||||
export interface SplitOptions {
|
||||
// Omitted bounds are unbounded.
|
||||
dayStart?: string;
|
||||
dayEnd?: string;
|
||||
// Existing breaks that may block or move with the split.
|
||||
otherEntries?: MovableInterval[];
|
||||
}
|
||||
|
||||
/** Insert a break while preserving work duration and respecting the supplied bounds. */
|
||||
export function planSplitEntry(
|
||||
entry: Interval,
|
||||
durationSeconds: number,
|
||||
breakStart?: string
|
||||
breakStart?: string,
|
||||
options: SplitOptions = {}
|
||||
): SplitPlan | null {
|
||||
if (durationSeconds <= 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const entryStart = dayjs.utc(entry.start);
|
||||
const entryEnd = dayjs.utc(entry.end);
|
||||
const total = entryEnd.diff(entryStart, 'second');
|
||||
if (total < durationSeconds + 2 * MIN_SPLIT_FRAGMENT_SECONDS) return null;
|
||||
const toMs = (iso: string) => dayjs.utc(iso).valueOf();
|
||||
const iso = (ms: number) => dayjs.utc(ms).format();
|
||||
|
||||
const earliest = entryStart.add(MIN_SPLIT_FRAGMENT_SECONDS, 'second');
|
||||
const latest = entryEnd.subtract(durationSeconds + MIN_SPLIT_FRAGMENT_SECONDS, 'second');
|
||||
const entryStartMs = toMs(entry.start);
|
||||
const totalMs = toMs(entry.end) - entryStartMs;
|
||||
const minMs = MIN_SPLIT_FRAGMENT_SECONDS * 1000;
|
||||
if (totalMs < 2 * minMs) return null;
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const dayEndMs = options.dayEnd !== undefined ? toMs(options.dayEnd) : Infinity;
|
||||
|
||||
let bStart = breakStart
|
||||
? dayjs.utc(breakStart)
|
||||
: entryStart.add(Math.floor((total - durationSeconds) / 2), 'second');
|
||||
// Pull the block earlier only enough to keep it inside the day.
|
||||
const blockStartMs = Math.min(entryStartMs, dayEndMs - totalMs - durationMs);
|
||||
|
||||
let bStartMs = breakStart ? toMs(breakStart) : blockStartMs + Math.floor(totalMs / 2000) * 1000;
|
||||
if (breakStart) {
|
||||
if (bStart.isBefore(earliest) || bStart.isAfter(latest)) return null;
|
||||
if (bStartMs < blockStartMs + minMs || bStartMs > blockStartMs + totalMs - minMs) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Safety net for rounding of the centered position only.
|
||||
if (bStart.isBefore(earliest)) bStart = earliest;
|
||||
if (bStart.isAfter(latest)) bStart = latest;
|
||||
bStartMs = Math.min(
|
||||
Math.max(bStartMs, blockStartMs + minMs),
|
||||
blockStartMs + totalMs - minMs
|
||||
);
|
||||
}
|
||||
const bEndMs = bStartMs + durationMs;
|
||||
const firstHalfMs = bStartMs - blockStartMs;
|
||||
|
||||
const secondHalfEndMs = bEndMs + totalMs - firstHalfMs;
|
||||
|
||||
// Carry the occupied end through the collision chain; stop at the first gap.
|
||||
const others = options.otherEntries ?? [];
|
||||
const later = others
|
||||
.filter((other) => toMs(other.start) >= bStartMs)
|
||||
.sort((a, b) => toMs(a.start) - toMs(b.start));
|
||||
const shifted: MovableInterval[] = [];
|
||||
let occupiedEndMs = secondHalfEndMs;
|
||||
for (const other of later) {
|
||||
const otherStartMs = toMs(other.start);
|
||||
if (otherStartMs >= occupiedEndMs) break;
|
||||
|
||||
const otherEndMs = toMs(other.end);
|
||||
const shiftMs = occupiedEndMs - otherStartMs;
|
||||
const shiftedEndMs = otherEndMs + shiftMs;
|
||||
if (shiftedEndMs > dayEndMs) return null;
|
||||
shifted.push({
|
||||
id: other.id,
|
||||
start: iso(otherStartMs + shiftMs),
|
||||
end: iso(shiftedEndMs),
|
||||
});
|
||||
occupiedEndMs = shiftedEndMs;
|
||||
}
|
||||
|
||||
const bEnd = bStart.add(durationSeconds, 'second');
|
||||
if (!bStart.isAfter(entryStart) || !bEnd.isBefore(entryEnd)) return null;
|
||||
// Earlier breaks constrain how far the block may move back.
|
||||
let earliestStartMs = options.dayStart !== undefined ? toMs(options.dayStart) : -Infinity;
|
||||
for (const other of others) {
|
||||
const otherEndMs = toMs(other.end);
|
||||
if (
|
||||
toMs(other.start) < bStartMs &&
|
||||
otherEndMs <= bStartMs &&
|
||||
otherEndMs > earliestStartMs
|
||||
) {
|
||||
earliestStartMs = otherEndMs;
|
||||
}
|
||||
}
|
||||
if (blockStartMs < earliestStartMs) return null;
|
||||
|
||||
return {
|
||||
firstHalf: { start: entryStart.format(), end: bStart.format() },
|
||||
breakSlot: { start: bStart.format(), end: bEnd.format() },
|
||||
secondHalf: { start: bEnd.format(), end: entryEnd.format() },
|
||||
firstHalf: { start: iso(blockStartMs), end: iso(bStartMs) },
|
||||
breakSlot: { start: iso(bStartMs), end: iso(bEndMs) },
|
||||
secondHalf: { start: iso(bEndMs), end: iso(secondHalfEndMs) },
|
||||
shifted,
|
||||
};
|
||||
}
|
||||
|
||||
/** Last resort: place the break after the last work entry or before the first. */
|
||||
export function findAdjacentBreakSlot(
|
||||
work: Interval[],
|
||||
dayStart: string,
|
||||
dayEnd: string,
|
||||
durationSeconds: number,
|
||||
obstacles: Interval[] = []
|
||||
): Interval | null {
|
||||
if (durationSeconds <= 0 || work.length === 0) return null;
|
||||
const dayjs = getDayJsInstance();
|
||||
const durationMs = durationSeconds * 1000;
|
||||
const dayStartMs = dayjs.utc(dayStart).valueOf();
|
||||
const dayEndMs = dayjs.utc(dayEnd).valueOf();
|
||||
|
||||
const merged = mergedWorkMs(work);
|
||||
const blockers = [...merged, ...obstacles.map(toIntervalMs)];
|
||||
const candidates = [merged[merged.length - 1]!.endMs, merged[0]!.startMs - durationMs];
|
||||
|
||||
for (const startMs of candidates) {
|
||||
if (startMs < dayStartMs || startMs + durationMs > dayEndMs) continue;
|
||||
const clear = blockers.every(
|
||||
(blocker) => blocker.endMs <= startMs || blocker.startMs >= startMs + durationMs
|
||||
);
|
||||
if (clear) return slotAt(startMs, durationMs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a break at `breakStart`, shifting the surrounding entries only as much
|
||||
* as needed to clear the slot. Entries starting before the break form the left
|
||||
* block: when it reaches into the slot it is translated earlier so its latest
|
||||
* end meets the break start. The rest form the right block: when the slot
|
||||
* reaches into it, it is translated later so its earliest start meets the break
|
||||
* end. Blocks that already clear the slot are left untouched — existing gaps
|
||||
* are preserved, never tightened. Returns null if a required shift would push
|
||||
* an entry outside `[dayStart, dayEnd]`.
|
||||
* Insert a break by translating overlapping entries on either side just enough
|
||||
* to clear it. Existing gaps remain unchanged. Returns null if a shift would
|
||||
* leave the day.
|
||||
*/
|
||||
export function planMoveInsert(
|
||||
entries: MovableInterval[],
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
import { useBreakPlacement } from './useBreakPlacement';
|
||||
import { NoFreeWindowError } from './cellMath';
|
||||
import { api } from '@/packages/api/src';
|
||||
import type { TimeEntry } from '@/packages/api/src';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
|
||||
const addNotification = vi.fn();
|
||||
const mutationOptionsSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/useUser', () => ({
|
||||
getCurrentOrganizationId: vi.fn(() => 'org-1'),
|
||||
@@ -15,6 +17,28 @@ vi.mock('@/utils/useUser', () => ({
|
||||
|
||||
vi.mock('@tanstack/vue-query', () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: (options: {
|
||||
mutationFn: (variables: unknown) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown, variables: unknown) => void | Promise<void>;
|
||||
onError?: (error: unknown, variables: unknown) => void | Promise<void>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => {
|
||||
mutationOptionsSpy(options);
|
||||
return {
|
||||
mutateAsync: async (variables: unknown) => {
|
||||
try {
|
||||
const data = await options.mutationFn(variables);
|
||||
await options.onSuccess?.(data, variables);
|
||||
return data;
|
||||
} catch (error) {
|
||||
await options.onError?.(error, variables);
|
||||
throw error;
|
||||
} finally {
|
||||
await options.onSettled?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/notification', () => ({
|
||||
@@ -61,24 +85,29 @@ const breakRow: TimesheetRow = {
|
||||
totalSeconds: 0,
|
||||
};
|
||||
|
||||
function setup(allEntries: TimeEntry[]) {
|
||||
function setup(allEntries: TimeEntry[], preventOverlaps = false) {
|
||||
const createCell = vi.fn(async () => undefined);
|
||||
const updateEntry = vi.fn(async () => undefined);
|
||||
const updateEntry = vi.fn(async (_entry: TimeEntry) => undefined);
|
||||
const deleteEntry = vi.fn(async (_id: string) => undefined);
|
||||
const bp = useBreakPlacement({
|
||||
weekDays: ref([DATE, '2026-04-11', '2026-04-12']),
|
||||
timeEntries: ref(allEntries),
|
||||
requireOrgId: () => 'org-1',
|
||||
createCell,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
preventOverlappingTimeEntries: () => preventOverlaps,
|
||||
});
|
||||
return { bp, createCell, updateEntry };
|
||||
return { bp, createCell, updateEntry, deleteEntry };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
apiMocks.createTimeEntry.mockClear();
|
||||
apiMocks.updateTimeEntry.mockClear();
|
||||
apiMocks.deleteTimeEntry.mockClear();
|
||||
addNotification.mockClear();
|
||||
mutationOptionsSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.placeBreak', () => {
|
||||
@@ -126,13 +155,36 @@ describe('useBreakPlacement.placeBreak', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('defers to the split modal when a single work entry blocks every gap', async () => {
|
||||
it('drops a new break into the first free window when the day has no work', async () => {
|
||||
const { bp, createCell } = setup([]);
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
expect(createCell).toHaveBeenCalledWith(breakRow, 0, HOUR);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resizes a break on a workless day in place while it still fits', async () => {
|
||||
const brk = entry('2026-04-10T20:00:00Z', '2026-04-10T20:30:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([brk]);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR, 'b1');
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T20:00:00Z',
|
||||
end: '2026-04-10T21:00:00Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requests input in the split modal when a single work entry blocks every gap', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp } = setup([work]);
|
||||
|
||||
await expect(bp.placeBreak(breakRow, 0, HOUR)).rejects.toBeInstanceOf(
|
||||
BreakPlacementDeferred
|
||||
);
|
||||
await expect(bp.placeBreak(breakRow, 0, HOUR)).resolves.toBe('needs-input');
|
||||
expect(bp.breakPlacementRequest.value).toEqual(
|
||||
expect.objectContaining({
|
||||
durationSeconds: HOUR,
|
||||
@@ -145,15 +197,25 @@ describe('useBreakPlacement.placeBreak', () => {
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
it('shrinks the original, creates the second half, and saves the break', async () => {
|
||||
it('configures the logical placement as a serialized, non-retrying mutation', () => {
|
||||
setup([]);
|
||||
|
||||
expect(mutationOptionsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mutationKey: ['timesheet', 'break-placement'],
|
||||
scope: { id: 'timesheet-break-placement' },
|
||||
retry: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shrinks the original, pushes the rest of the work out, and saves the break', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
|
||||
// Open the placement request, then commit the break at noon.
|
||||
await bp.placeBreak(breakRow, 0, HOUR).catch(() => undefined);
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
|
||||
// Original work shrunk to its first half.
|
||||
expect(updateEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
@@ -161,14 +223,13 @@ describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
end: '2026-04-10T12:00:00Z',
|
||||
})
|
||||
);
|
||||
// Second half of work + the break both created.
|
||||
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
|
||||
expect(created).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T17:00:00Z',
|
||||
end: '2026-04-10T18:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
@@ -177,14 +238,208 @@ describe('useBreakPlacement.applyBreakPlacement (split)', () => {
|
||||
}),
|
||||
])
|
||||
);
|
||||
// Request cleared and a success toast surfaced.
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
expect(addNotification).toHaveBeenCalledWith('success', 'Break added', expect.any(String));
|
||||
});
|
||||
|
||||
it('does nothing when there is no pending placement request', async () => {
|
||||
const { bp, updateEntry } = setup([]);
|
||||
it('moves an existing break out of the way of the pushed-out work', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' });
|
||||
const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([work, existingBreak]);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await bp.applyBreakPlacement('2026-04-10T10:00:00Z', HOUR);
|
||||
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T10:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T13:15:00Z',
|
||||
})
|
||||
);
|
||||
const created = apiMocks.createTimeEntry.mock.calls.map((c) => c[0]);
|
||||
expect(created).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T11:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'break',
|
||||
start: '2026-04-10T10:00:00Z',
|
||||
end: '2026-04-10T11:00:00Z',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('moves a replacement break before creating work through its old slot', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const existingBreak = entry('2026-04-10T17:00:00Z', '2026-04-10T17:30:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([work, existingBreak], true);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR, 'b1');
|
||||
await bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR);
|
||||
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T12:00:00Z' })
|
||||
);
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T13:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.createTimeEntry.mock.calls[0]![0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'work',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T18:00:00Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the original entry when creating the second half fails', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow('boom');
|
||||
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T12:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(updateEntry).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
id: 'w1',
|
||||
start: '2026-04-10T09:00:00Z',
|
||||
end: '2026-04-10T17:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(addNotification).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to add break',
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it('restores shifted breaks as well as the original when creating work fails', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'w1' });
|
||||
const existingBreak = entry('2026-04-10T12:00:00Z', '2026-04-10T12:15:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([work, existingBreak]);
|
||||
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('work create failed'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T10:00:00Z', HOUR)).rejects.toThrow(
|
||||
'work create failed'
|
||||
);
|
||||
|
||||
expect(updateEntry.mock.calls.map((call) => call[0])).toEqual([
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T10:00:00Z' }),
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T13:00:00Z',
|
||||
end: '2026-04-10T13:15:00Z',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'b1',
|
||||
start: '2026-04-10T12:00:00Z',
|
||||
end: '2026-04-10T12:15:00Z',
|
||||
}),
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T12:00:00Z' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes the created second half and restores updates when saving the break fails', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry, deleteEntry } = setup([work]);
|
||||
apiMocks.createTimeEntry
|
||||
.mockResolvedValueOnce({ data: { id: 'second-half' } } as never)
|
||||
.mockRejectedValueOnce(new Error('break create failed'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow(
|
||||
'break create failed'
|
||||
);
|
||||
|
||||
expect(deleteEntry).toHaveBeenCalledWith('second-half');
|
||||
expect(updateEntry).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'w1', end: '2026-04-10T17:00:00Z' })
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).not.toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces incomplete recovery and closes a stale placement request', async () => {
|
||||
const work = entry('2026-04-10T09:00:00Z', '2026-04-10T17:00:00Z', { id: 'w1' });
|
||||
const { bp, updateEntry } = setup([work]);
|
||||
updateEntry
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('restore failed'));
|
||||
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('create failed'));
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR);
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toThrow(
|
||||
'create failed'
|
||||
);
|
||||
|
||||
expect(addNotification).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Break placement needs attention',
|
||||
expect.any(String)
|
||||
);
|
||||
expect(bp.breakPlacementRequest.value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBreakPlacement.applyBreakPlacement (move)', () => {
|
||||
it('rejects a replacement dependency cycle before changing any entry', async () => {
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', {
|
||||
id: 'morning',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T12:00:00Z', '2026-04-10T17:00:00Z', {
|
||||
id: 'afternoon',
|
||||
});
|
||||
const existingBreak = entry('2026-04-10T17:00:00Z', '2026-04-10T17:30:00Z', {
|
||||
id: 'b1',
|
||||
type: 'break',
|
||||
});
|
||||
const { bp, updateEntry } = setup([morning, afternoon, existingBreak], true);
|
||||
|
||||
await bp.placeBreak(breakRow, 0, HOUR, 'b1');
|
||||
await expect(bp.applyBreakPlacement('2026-04-10T12:00:00Z', HOUR)).rejects.toBeInstanceOf(
|
||||
NoFreeWindowError
|
||||
);
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled();
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { api, type TimeEntry } from '@/packages/api/src';
|
||||
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query';
|
||||
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
|
||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||
import { getCurrentMembershipId } from '@/utils/useUser';
|
||||
import type { TimesheetRow } from '@/utils/useTimesheetGrid';
|
||||
@@ -9,24 +8,15 @@ import { useNotificationsStore } from '@/utils/notification';
|
||||
import { localDayBounds, NoFreeWindowError } from './cellMath';
|
||||
import {
|
||||
buildDayPlacementContext,
|
||||
findValidBreakGap,
|
||||
findValidBreakGapNear,
|
||||
decideBreakPlacement,
|
||||
placementMode,
|
||||
planMoveInsert,
|
||||
planSplitEntry,
|
||||
suggestMovePlan,
|
||||
type BreakPlacementRequest,
|
||||
type DayPlacementContext,
|
||||
type MovableInterval,
|
||||
} from './breakPlacementMath';
|
||||
|
||||
/** Signals the caller that a break create/edit is waiting on the placement modal. */
|
||||
export class BreakPlacementDeferred extends Error {
|
||||
constructor() {
|
||||
super('Break placement deferred to modal');
|
||||
this.name = 'BreakPlacementDeferred';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic entry primitives the break subsystem borrows from the cell-mutation
|
||||
* layer. `createCell` drops an entry in the first free window (used when there
|
||||
@@ -45,17 +35,51 @@ export interface BreakPlacementDeps {
|
||||
afterCursor?: string
|
||||
) => Promise<void>;
|
||||
updateEntry: (entry: TimeEntry) => Promise<void>;
|
||||
deleteEntry: (id: string) => Promise<void>;
|
||||
preventOverlappingTimeEntries?: () => boolean;
|
||||
}
|
||||
|
||||
interface EntryChange {
|
||||
original: TimeEntry;
|
||||
next: TimeEntry;
|
||||
}
|
||||
|
||||
interface PlacementCommit {
|
||||
updates: EntryChange[];
|
||||
creates: CreateTimeEntryBody[];
|
||||
entriesAdjusted: boolean;
|
||||
}
|
||||
|
||||
export type PlaceBreakResult = 'committed' | 'needs-input';
|
||||
|
||||
type UndoOperation = () => Promise<void>;
|
||||
|
||||
export class BreakPlacementSagaError extends Error {
|
||||
constructor(
|
||||
public readonly operationError: unknown,
|
||||
public readonly rollbackErrors: unknown[]
|
||||
) {
|
||||
super(operationError instanceof Error ? operationError.message : 'Break placement failed');
|
||||
this.name = 'BreakPlacementSagaError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-placement subsystem for the timesheet. Owns the placement-modal request
|
||||
* state and everything that positions a break relative to work — auto-placing it
|
||||
* into a valid gap when one exists, or deferring to the split/move modal when the
|
||||
* into a valid gap when one exists, or asking for input in the split/move modal when the
|
||||
* day has to be rearranged.
|
||||
*/
|
||||
export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
const { weekDays, timeEntries, requireOrgId, createCell, updateEntry } = deps;
|
||||
const dayjs = getDayJsInstance();
|
||||
const {
|
||||
weekDays,
|
||||
timeEntries,
|
||||
requireOrgId,
|
||||
createCell,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
preventOverlappingTimeEntries = () => false,
|
||||
} = deps;
|
||||
const queryClient = useQueryClient();
|
||||
const notifications = useNotificationsStore();
|
||||
|
||||
@@ -82,24 +106,35 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function createBreakEntry(start: string, end: string, memberId?: string): Promise<void> {
|
||||
const orgId = requireOrgId();
|
||||
function breakEntryBody(start: string, end: string, memberId?: string): CreateTimeEntryBody {
|
||||
const member = memberId ?? getCurrentMembershipId();
|
||||
if (!member) throw new Error('No member context');
|
||||
await api.createTimeEntry(
|
||||
{
|
||||
member_id: member,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
start,
|
||||
end,
|
||||
billable: false,
|
||||
type: 'break',
|
||||
description: null,
|
||||
tags: [],
|
||||
},
|
||||
{ params: { organization: orgId } }
|
||||
);
|
||||
return {
|
||||
member_id: member,
|
||||
project_id: null,
|
||||
task_id: null,
|
||||
start,
|
||||
end,
|
||||
billable: false,
|
||||
type: 'break',
|
||||
description: null,
|
||||
tags: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function createEntry(body: CreateTimeEntryBody): Promise<string> {
|
||||
const response = await api.createTimeEntry(body, {
|
||||
params: { organization: requireOrgId() },
|
||||
});
|
||||
return response.data.id;
|
||||
}
|
||||
|
||||
async function createBreakEntry(
|
||||
start: string,
|
||||
end: string,
|
||||
memberId?: string
|
||||
): Promise<string> {
|
||||
return createEntry(breakEntryBody(start, end, memberId));
|
||||
}
|
||||
|
||||
async function saveBreakEntry(
|
||||
@@ -119,116 +154,169 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
await createBreakEntry(start, end, memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a break on the day (new, or re-placing an existing one when `replaceBreakId`
|
||||
* is given). Prefers a gap that already satisfies the placement tolerance; otherwise
|
||||
* raises BreakPlacementDeferred so the page opens the modal. With no work to anchor to,
|
||||
* the break is just dropped in / resized in the first free window.
|
||||
*/
|
||||
/** Place or resize a break directly when possible, otherwise open the placement modal. */
|
||||
async function placeBreak(
|
||||
row: TimesheetRow,
|
||||
dayIndex: number,
|
||||
durationSeconds: number,
|
||||
replaceBreakId?: string
|
||||
): Promise<void> {
|
||||
): Promise<PlaceBreakResult> {
|
||||
const date = weekDays.value[dayIndex]!;
|
||||
const tz = getUserTimezone();
|
||||
const { work, breaks, blocked, dayStart, dayEnd } = dayPlacementContext(
|
||||
date,
|
||||
tz,
|
||||
replaceBreakId
|
||||
);
|
||||
// Existing breaks block auto-placement into a gap (obstacles), but move
|
||||
// along with the surrounding work when a move plan shifts entries.
|
||||
// Running entries block everything from their start (never movable).
|
||||
const obstacles = [...breaks, ...blocked];
|
||||
|
||||
// On edit, keep the break where it is when its current gap still fits it; only
|
||||
// fall back to the first-gap-centered placement when it can't stay put.
|
||||
const context = dayPlacementContext(date, tz, replaceBreakId);
|
||||
const anchorStart = replaceBreakId
|
||||
? (timeEntries.value.find((e) => e.id === replaceBreakId)?.start ?? null)
|
||||
: null;
|
||||
const validGap =
|
||||
(anchorStart !== null
|
||||
? findValidBreakGapNear(work, durationSeconds, anchorStart, obstacles)
|
||||
: null) ?? findValidBreakGap(work, durationSeconds, obstacles);
|
||||
if (validGap) {
|
||||
await saveBreakEntry(validGap.start, validGap.end, replaceBreakId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (work.length === 0) {
|
||||
// No work to sit between: for an edit, resize the break in place; for a new
|
||||
// break, drop it in the first free window. Nothing to align to either way.
|
||||
if (replaceBreakId) {
|
||||
const existing = timeEntries.value.find((e) => e.id === replaceBreakId);
|
||||
if (existing) {
|
||||
const newEnd = dayjs
|
||||
.utc(existing.start)
|
||||
.add(durationSeconds, 'second')
|
||||
.format();
|
||||
await updateEntry({ ...existing, end: newEnd });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await createCell(row, dayIndex, durationSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const mode: 'split' | 'move' = work.length === 1 ? 'split' : 'move';
|
||||
const defaultBreakStart =
|
||||
mode === 'split'
|
||||
? (planSplitEntry(work[0]!, durationSeconds)?.breakSlot.start ?? null)
|
||||
: (suggestMovePlan(work, dayStart, dayEnd, durationSeconds, breaks)?.breakSlot
|
||||
.start ?? null);
|
||||
|
||||
if (!defaultBreakStart) {
|
||||
// Even splitting/moving can't open a slot on this day.
|
||||
throw new NoFreeWindowError(date, durationSeconds);
|
||||
}
|
||||
|
||||
breakPlacementRequest.value = {
|
||||
const decision = decideBreakPlacement({
|
||||
date,
|
||||
durationSeconds,
|
||||
dayStart,
|
||||
dayEnd,
|
||||
workEntries: work,
|
||||
otherEntries: breaks,
|
||||
defaultBreakStart,
|
||||
replaceBreakId: replaceBreakId ?? null,
|
||||
};
|
||||
throw new BreakPlacementDeferred();
|
||||
context,
|
||||
anchorStart,
|
||||
replaceBreakId,
|
||||
});
|
||||
|
||||
switch (decision.kind) {
|
||||
case 'save':
|
||||
await saveBreakEntry(decision.slot.start, decision.slot.end, replaceBreakId);
|
||||
return 'committed';
|
||||
case 'place-in-free-window':
|
||||
await createCell(row, dayIndex, durationSeconds);
|
||||
return 'committed';
|
||||
case 'needs-input':
|
||||
breakPlacementRequest.value = decision.request;
|
||||
return 'needs-input';
|
||||
case 'reject':
|
||||
throw new NoFreeWindowError(date, durationSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBreakPlacement(): void {
|
||||
breakPlacementRequest.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a break at `breakStart` by executing the split or move plan. Shifts
|
||||
* happen before the break is saved so its target slot is free first.
|
||||
*/
|
||||
async function applyBreakPlacement(breakStart: string, durationSeconds: number): Promise<void> {
|
||||
const req = breakPlacementRequest.value;
|
||||
if (!req) return;
|
||||
function shiftChanges(shifted: MovableInterval[]): EntryChange[] {
|
||||
return shifted.map((shift) => {
|
||||
const original = timeEntries.value.find((e) => e.id === shift.id);
|
||||
if (!original) throw new Error('An entry to move no longer exists');
|
||||
return {
|
||||
original,
|
||||
next: { ...original, start: shift.start, end: shift.end },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// The timesheet is the current member's own, so all created/edited entries stay with them.
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (!memberId) throw new Error('No member context');
|
||||
function intervalsOverlap(
|
||||
left: Pick<TimeEntry, 'start' | 'end'>,
|
||||
right: Pick<TimeEntry, 'start' | 'end'>
|
||||
): boolean {
|
||||
const leftEndMs = left.end === null ? Infinity : Date.parse(left.end);
|
||||
const rightEndMs = right.end === null ? Infinity : Date.parse(right.end);
|
||||
return Date.parse(left.start) < rightEndMs && Date.parse(right.start) < leftEndMs;
|
||||
}
|
||||
|
||||
let entriesAdjusted = true;
|
||||
/** Order updates so each target is free when overlap prevention is enabled. */
|
||||
function nonOverlappingUpdateOrder(
|
||||
changes: EntryChange[],
|
||||
memberId: string
|
||||
): EntryChange[] | null {
|
||||
if (!preventOverlappingTimeEntries()) return changes;
|
||||
|
||||
const current = new Map(
|
||||
timeEntries.value
|
||||
.filter((entry) => entry.member_id === memberId)
|
||||
.map((entry) => [entry.id, entry] as const)
|
||||
);
|
||||
const pending = [...changes];
|
||||
const ordered: EntryChange[] = [];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const index = pending.findIndex((change) =>
|
||||
[...current.values()].every(
|
||||
(entry) =>
|
||||
entry.id === change.original.id || !intervalsOverlap(change.next, entry)
|
||||
)
|
||||
);
|
||||
if (index === -1) return null;
|
||||
|
||||
const [change] = pending.splice(index, 1);
|
||||
ordered.push(change!);
|
||||
current.set(change!.original.id, change!.next);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
async function applyUpdates(changes: EntryChange[], undo: UndoOperation[]): Promise<void> {
|
||||
for (const change of changes) {
|
||||
await updateEntry(change.next);
|
||||
undo.push(() => updateEntry(change.original));
|
||||
}
|
||||
}
|
||||
|
||||
async function withCompensation<T>(
|
||||
operation: (undo: UndoOperation[]) => Promise<T>
|
||||
): Promise<T> {
|
||||
const undo: UndoOperation[] = [];
|
||||
try {
|
||||
if (placementMode(req) === 'split') {
|
||||
const original = timeEntries.value.find((e) => e.id === req.workEntries[0]!.id);
|
||||
const plan = planSplitEntry(req.workEntries[0]!, durationSeconds, breakStart);
|
||||
if (!original || !plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
// Shrink the original to the first half, then add the second half + break.
|
||||
await updateEntry({
|
||||
...original,
|
||||
start: plan.firstHalf.start,
|
||||
end: plan.firstHalf.end,
|
||||
});
|
||||
await api.createTimeEntry(
|
||||
return await operation(undo);
|
||||
} catch (operationError) {
|
||||
const rollbackErrors: unknown[] = [];
|
||||
for (const rollback of [...undo].reverse()) {
|
||||
try {
|
||||
await rollback();
|
||||
} catch (rollbackError) {
|
||||
rollbackErrors.push(rollbackError);
|
||||
}
|
||||
}
|
||||
throw new BreakPlacementSagaError(operationError, rollbackErrors);
|
||||
}
|
||||
}
|
||||
|
||||
function replacementChange(
|
||||
req: BreakPlacementRequest,
|
||||
start: string,
|
||||
end: string
|
||||
): EntryChange | null {
|
||||
if (!req.replaceBreakId) return null;
|
||||
const original = timeEntries.value.find((entry) => entry.id === req.replaceBreakId);
|
||||
if (!original) throw new Error('Break to update no longer exists');
|
||||
return { original, next: { ...original, start, end } };
|
||||
}
|
||||
|
||||
function buildPlacementCommit({
|
||||
req,
|
||||
breakStart,
|
||||
durationSeconds,
|
||||
memberId,
|
||||
}: {
|
||||
req: BreakPlacementRequest;
|
||||
breakStart: string;
|
||||
durationSeconds: number;
|
||||
memberId: string;
|
||||
}): PlacementCommit {
|
||||
if (placementMode(req) === 'split') {
|
||||
const original = timeEntries.value.find((e) => e.id === req.workEntries[0]!.id);
|
||||
const plan = planSplitEntry(req.workEntries[0]!, durationSeconds, breakStart, {
|
||||
dayStart: req.dayStart,
|
||||
dayEnd: req.dayEnd,
|
||||
otherEntries: req.otherEntries,
|
||||
});
|
||||
if (!original || !plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
|
||||
const replacement = replacementChange(req, plan.breakSlot.start, plan.breakSlot.end);
|
||||
return {
|
||||
updates: [
|
||||
{
|
||||
original,
|
||||
next: {
|
||||
...original,
|
||||
start: plan.firstHalf.start,
|
||||
end: plan.firstHalf.end,
|
||||
},
|
||||
},
|
||||
...(replacement ? [replacement] : []),
|
||||
...shiftChanges(plan.shifted),
|
||||
],
|
||||
creates: [
|
||||
{
|
||||
member_id: memberId,
|
||||
project_id: original.project_id,
|
||||
@@ -240,52 +328,65 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
description: original.description ?? null,
|
||||
tags: original.tags ?? [],
|
||||
},
|
||||
{ params: { organization: requireOrgId() } }
|
||||
);
|
||||
await saveBreakEntry(
|
||||
plan.breakSlot.start,
|
||||
plan.breakSlot.end,
|
||||
req.replaceBreakId ?? undefined,
|
||||
memberId
|
||||
);
|
||||
} else {
|
||||
const plan = planMoveInsert(
|
||||
[...req.workEntries, ...req.otherEntries],
|
||||
req.dayStart,
|
||||
req.dayEnd,
|
||||
breakStart,
|
||||
durationSeconds
|
||||
);
|
||||
if (!plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
entriesAdjusted = plan.shifted.length > 0;
|
||||
// Order the shifts so no intermediate step overlaps (matters when the org
|
||||
// prevents overlapping entries): entries moving earlier are updated left-to-right,
|
||||
// entries moving later right-to-left, so each one vacates before its neighbour moves.
|
||||
const shifts = plan.shifted
|
||||
.map((shift) => ({
|
||||
shift,
|
||||
original: timeEntries.value.find((e) => e.id === shift.id),
|
||||
}))
|
||||
.filter(
|
||||
(x): x is { shift: (typeof plan.shifted)[number]; original: TimeEntry } =>
|
||||
!!x.original
|
||||
);
|
||||
const movingEarlier = shifts
|
||||
.filter((x) => x.shift.start < x.original.start)
|
||||
.sort((a, b) => a.original.start.localeCompare(b.original.start));
|
||||
const movingLater = shifts
|
||||
.filter((x) => x.shift.start >= x.original.start)
|
||||
.sort((a, b) => b.original.start.localeCompare(a.original.start));
|
||||
for (const { shift, original } of [...movingEarlier, ...movingLater]) {
|
||||
await updateEntry({ ...original, start: shift.start, end: shift.end });
|
||||
}
|
||||
await saveBreakEntry(
|
||||
plan.breakSlot.start,
|
||||
plan.breakSlot.end,
|
||||
req.replaceBreakId ?? undefined,
|
||||
memberId
|
||||
);
|
||||
...(!replacement
|
||||
? [breakEntryBody(plan.breakSlot.start, plan.breakSlot.end, memberId)]
|
||||
: []),
|
||||
],
|
||||
entriesAdjusted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = planMoveInsert(
|
||||
[...req.workEntries, ...req.otherEntries],
|
||||
req.dayStart,
|
||||
req.dayEnd,
|
||||
breakStart,
|
||||
durationSeconds
|
||||
);
|
||||
if (!plan) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
|
||||
const replacement = replacementChange(req, plan.breakSlot.start, plan.breakSlot.end);
|
||||
return {
|
||||
updates: [...shiftChanges(plan.shifted), ...(replacement ? [replacement] : [])],
|
||||
creates: replacement
|
||||
? []
|
||||
: [breakEntryBody(plan.breakSlot.start, plan.breakSlot.end, memberId)],
|
||||
entriesAdjusted: plan.shifted.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a declarative commit with reverse-order compensation if a later write fails. */
|
||||
async function executePlacement({
|
||||
req,
|
||||
breakStart,
|
||||
durationSeconds,
|
||||
memberId,
|
||||
}: {
|
||||
req: BreakPlacementRequest;
|
||||
breakStart: string;
|
||||
durationSeconds: number;
|
||||
memberId: string;
|
||||
}): Promise<{ entriesAdjusted: boolean }> {
|
||||
const commit = buildPlacementCommit({ req, breakStart, durationSeconds, memberId });
|
||||
const updates = nonOverlappingUpdateOrder(commit.updates, memberId);
|
||||
if (!updates) throw new NoFreeWindowError(req.date, durationSeconds);
|
||||
|
||||
return withCompensation(async (undo) => {
|
||||
await applyUpdates(updates, undo);
|
||||
for (const body of commit.creates) {
|
||||
const id = await createEntry(body);
|
||||
undo.push(() => deleteEntry(id));
|
||||
}
|
||||
return { entriesAdjusted: commit.entriesAdjusted };
|
||||
});
|
||||
}
|
||||
|
||||
const { mutateAsync: commitPlacement } = useMutation({
|
||||
mutationKey: ['timesheet', 'break-placement'],
|
||||
scope: { id: 'timesheet-break-placement' },
|
||||
retry: false,
|
||||
mutationFn: executePlacement,
|
||||
onSuccess: ({ entriesAdjusted }, { req }) => {
|
||||
notifications.addNotification(
|
||||
'success',
|
||||
req.replaceBreakId ? 'Break updated' : 'Break added',
|
||||
@@ -293,8 +394,19 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
? 'Your entries were adjusted to make room for the break.'
|
||||
: 'The break was added at the selected time.'
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof NoFreeWindowError) {
|
||||
if (breakPlacementRequest.value === req) breakPlacementRequest.value = null;
|
||||
},
|
||||
onError: (error, { req }) => {
|
||||
const operationError =
|
||||
error instanceof BreakPlacementSagaError ? error.operationError : error;
|
||||
if (error instanceof BreakPlacementSagaError && error.rollbackErrors.length > 0) {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
'Break placement needs attention',
|
||||
'Some entries could not be restored. The timesheet has been refreshed.'
|
||||
);
|
||||
if (breakPlacementRequest.value === req) breakPlacementRequest.value = null;
|
||||
} else if (operationError instanceof NoFreeWindowError) {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
"This day can't fit the break",
|
||||
@@ -303,15 +415,23 @@ export function useBreakPlacement(deps: BreakPlacementDeps) {
|
||||
} else {
|
||||
notifications.addNotification(
|
||||
'error',
|
||||
'Failed to add break',
|
||||
req.replaceBreakId ? 'Failed to update break' : 'Failed to add break',
|
||||
'Please try again later.'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
breakPlacementRequest.value = null;
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function applyBreakPlacement(breakStart: string, durationSeconds: number): Promise<void> {
|
||||
const req = breakPlacementRequest.value;
|
||||
if (!req) return;
|
||||
const memberId = getCurrentMembershipId();
|
||||
if (!memberId) throw new Error('No member context');
|
||||
|
||||
await commitPlacement({ req, breakStart, durationSeconds, memberId });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,25 @@ vi.mock('@tanstack/vue-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: vi.fn(),
|
||||
}),
|
||||
useMutation: (options: {
|
||||
mutationFn: (variables: unknown) => Promise<unknown>;
|
||||
onSuccess?: (data: unknown, variables: unknown) => void | Promise<void>;
|
||||
onError?: (error: unknown, variables: unknown) => void | Promise<void>;
|
||||
onSettled?: () => void | Promise<void>;
|
||||
}) => ({
|
||||
mutateAsync: async (variables: unknown) => {
|
||||
try {
|
||||
const data = await options.mutationFn(variables);
|
||||
await options.onSuccess?.(data, variables);
|
||||
return data;
|
||||
} catch (error) {
|
||||
await options.onError?.(error, variables);
|
||||
throw error;
|
||||
} finally {
|
||||
await options.onSettled?.();
|
||||
}
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/notification', () => ({
|
||||
@@ -343,40 +362,6 @@ describe('useTimesheetCellMutations.handleCellUpdate', () => {
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an edited break anchored to its position instead of recentering', async () => {
|
||||
// 09-10 and 11:30-12:30 leave a 90-min gap; a resized 1h break has a valid
|
||||
// window of 10:00-10:30. The break already starts at 10:00, so it must stay
|
||||
// there (10:00-11:00) rather than jump to the centered 10:15-11:15.
|
||||
const morning = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
|
||||
id: 'morning',
|
||||
type: 'work',
|
||||
});
|
||||
const afternoon = entry('2026-04-10T11:30:00Z', '2026-04-10T12:30:00Z', {
|
||||
id: 'afternoon',
|
||||
type: 'work',
|
||||
});
|
||||
const existingBreak = entry('2026-04-10T10:00:00Z', '2026-04-10T10:30:00Z', {
|
||||
id: 'break-1',
|
||||
project_id: null,
|
||||
type: 'break',
|
||||
});
|
||||
const row = buildRow(null, [existingBreak], 'break-row');
|
||||
row.type = 'break';
|
||||
const { cellMutations } = setup([morning, afternoon, existingBreak]);
|
||||
|
||||
await cellMutations.handleCellUpdate(row, 0, HOUR);
|
||||
|
||||
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
|
||||
expect(firstArg(apiMocks.updateTimeEntry)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'break-1',
|
||||
start: '2026-04-10T10:00:00Z',
|
||||
end: '2026-04-10T11:00:00Z',
|
||||
})
|
||||
);
|
||||
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grows a multi-break cell by re-placing the latest break, not fragmenting', async () => {
|
||||
// Two breaks share the break cell. Growing the cell total must extend the
|
||||
// latest-ending break (break-b) in place — never create a third break entry.
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
workDayStartOn,
|
||||
type FreeWindow,
|
||||
} from './cellMath';
|
||||
import { useBreakPlacement, BreakPlacementDeferred } from './useBreakPlacement';
|
||||
import { useBreakPlacement, type PlaceBreakResult } from './useBreakPlacement';
|
||||
|
||||
export type CellSaveStatus = 'saving' | 'saved' | 'error';
|
||||
|
||||
@@ -54,7 +54,8 @@ export function useTimesheetCellMutations(
|
||||
weekDays: Ref<string[]>,
|
||||
timeEntries: Ref<TimeEntry[]>,
|
||||
rows: Ref<TimesheetRow[]>,
|
||||
removeSlot: (key: TimesheetRowKey) => void
|
||||
removeSlot: (key: TimesheetRowKey) => void,
|
||||
preventOverlappingTimeEntries: () => boolean = () => false
|
||||
) {
|
||||
const dayjs = getDayJsInstance();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -70,7 +71,15 @@ export function useTimesheetCellMutations(
|
||||
// modal flow) is its own subsystem — it borrows the generic entry primitives
|
||||
// below (hoisted function declarations, so referenceable here).
|
||||
const { breakPlacementRequest, placeBreak, dismissBreakPlacement, applyBreakPlacement } =
|
||||
useBreakPlacement({ weekDays, timeEntries, requireOrgId, createCell, updateEntry });
|
||||
useBreakPlacement({
|
||||
weekDays,
|
||||
timeEntries,
|
||||
requireOrgId,
|
||||
createCell,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
preventOverlappingTimeEntries,
|
||||
});
|
||||
|
||||
function clearStatusTimer(key: string): void {
|
||||
clearTimeout(statusClearTimers[key]);
|
||||
@@ -125,7 +134,14 @@ export function useTimesheetCellMutations(
|
||||
const wasEmpty = row.totalSeconds === 0;
|
||||
|
||||
try {
|
||||
await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
|
||||
const result = await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
|
||||
if (result === 'needs-input') {
|
||||
// The placement modal owns the actual save, so return the cell to idle.
|
||||
clearStatusTimer(statusKey);
|
||||
delete cellStatus.value[statusKey];
|
||||
delete cellPendingSeconds.value[statusKey];
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasEmpty && newTotalSeconds > 0 && hasDuplicateIdentitySlot(row)) {
|
||||
removeSlot(row.key);
|
||||
@@ -137,14 +153,6 @@ export function useTimesheetCellMutations(
|
||||
}
|
||||
markSaved(statusKey);
|
||||
} catch (err) {
|
||||
if (err instanceof BreakPlacementDeferred) {
|
||||
// The break needs manual placement — revert the cell to idle (the
|
||||
// modal drives the actual save) instead of showing an error.
|
||||
clearStatusTimer(statusKey);
|
||||
delete cellStatus.value[statusKey];
|
||||
delete cellPendingSeconds.value[statusKey];
|
||||
return;
|
||||
}
|
||||
markError(statusKey);
|
||||
if (err instanceof NoFreeWindowError) {
|
||||
const friendlyDuration = formatHumanReadableDuration(
|
||||
@@ -182,25 +190,24 @@ export function useTimesheetCellMutations(
|
||||
row: TimesheetRow,
|
||||
dayIndex: number,
|
||||
newTotalSeconds: number
|
||||
): Promise<void> {
|
||||
): Promise<PlaceBreakResult> {
|
||||
const cell = row.cells.get(dayIndex);
|
||||
const existingSeconds = cell?.totalSeconds ?? 0;
|
||||
const diff = newTotalSeconds - existingSeconds;
|
||||
|
||||
if (newTotalSeconds === 0 && cell) {
|
||||
await deleteCell(cell);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
if (!cell || existingSeconds === 0) {
|
||||
// Breaks are placed relative to work (within tolerance), not just in the
|
||||
// first free slot, and may need the placement modal to resolve.
|
||||
if (row.type === 'break' && newTotalSeconds > 0) {
|
||||
await placeBreak(row, dayIndex, newTotalSeconds);
|
||||
return;
|
||||
return placeBreak(row, dayIndex, newTotalSeconds);
|
||||
}
|
||||
await createCell(row, dayIndex, newTotalSeconds);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
// Re-place breaks rather than extend/shrink them, which would fragment a break into
|
||||
@@ -209,24 +216,23 @@ export function useTimesheetCellMutations(
|
||||
// become obstacles); shrinking just trims the tail, which can't fragment.
|
||||
if (row.type === 'break') {
|
||||
if (cell.entries.length === 1) {
|
||||
await placeBreak(row, dayIndex, newTotalSeconds, cell.entries[0]!.id);
|
||||
return;
|
||||
return placeBreak(row, dayIndex, newTotalSeconds, cell.entries[0]!.id);
|
||||
}
|
||||
const tail = pickLatestEndedEntry(cell);
|
||||
if (diff > 0 && tail?.end) {
|
||||
await placeBreak(row, dayIndex, (tail.duration ?? 0) + diff, tail.id);
|
||||
return;
|
||||
return placeBreak(row, dayIndex, (tail.duration ?? 0) + diff, tail.id);
|
||||
}
|
||||
await shrinkFromEnd(cell, -diff);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
if (diff > 0) {
|
||||
await extendCell(row, dayIndex, cell, diff);
|
||||
return;
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
await shrinkFromEnd(cell, -diff);
|
||||
return 'committed';
|
||||
}
|
||||
|
||||
async function deleteCell(cell: TimesheetCell): Promise<void> {
|
||||
|
||||
49
resources/views/errors/untrusted-host.blade.php
Normal file
49
resources/views/errors/untrusted-host.blade.php
Normal file
@@ -0,0 +1,49 @@
|
||||
{{-- Self-contained on purpose: this page is rendered for a request on an
|
||||
untrusted host, so it must not call url()/route()/asset(), which would
|
||||
re-trigger Host validation and throw again. --}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Untrusted host</title>
|
||||
<style>
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
color: #1f2937;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.card {
|
||||
max-width: 32rem;
|
||||
margin: 1.5rem;
|
||||
padding: 2rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
h1 { margin: 0 0 0.75rem; font-size: 1.25rem; }
|
||||
p { margin: 0; line-height: 1.6; color: #4b5563; }
|
||||
code {
|
||||
padding: 0.1rem 0.35rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Untrusted host</h1>
|
||||
<p>
|
||||
This hostname is not configured for this instance. Set
|
||||
<code>APP_URL</code>, or add the host to <code>TRUSTED_HOSTS</code>.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -380,6 +380,40 @@ class RegistrationTest extends TestCaseWithDatabase
|
||||
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
|
||||
public function test_registration_joins_invited_organization_even_if_invitation_email_casing_differs(): void
|
||||
{
|
||||
// Arrange: invitation stored with a different casing than the registration email
|
||||
$user = $this->createUserWithPermission();
|
||||
OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->role(Role::Employee)
|
||||
->accepted()
|
||||
->create([
|
||||
'email' => 'Invited.User@example.com',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Invited User',
|
||||
'email' => 'invited.user@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => true,
|
||||
]);
|
||||
|
||||
// Assert: joined the inviting organization, no extra personal organization, invitation consumed
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$newUser = User::where('email', 'invited.user@example.com')->first();
|
||||
$this->assertNotNull($newUser);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'email' => 'Invited.User@example.com',
|
||||
]);
|
||||
$organizations = $newUser->organizations;
|
||||
$this->assertCount(1, $organizations);
|
||||
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
|
||||
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -92,6 +92,30 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||
}
|
||||
|
||||
public function test_index_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:own',
|
||||
]);
|
||||
$legacyUser = User::factory()->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($data->member)->create([
|
||||
'user_id' => $legacyUser->getKey(),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'member_id' => $data->member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertJsonCount(1, 'data');
|
||||
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
|
||||
}
|
||||
|
||||
public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -126,7 +150,10 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
|
||||
$response = $this->getJson(route('api.v1.time-entries.index', [
|
||||
$data->organization->getKey(),
|
||||
'member_id' => $member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$this->assertResponseCode($response, 200);
|
||||
@@ -1772,6 +1799,29 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_aggregate_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:view:own',
|
||||
]);
|
||||
$legacyUser = User::factory()->create();
|
||||
TimeEntry::factory()->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create([
|
||||
'user_id' => $legacyUser->getKey(),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
|
||||
$data->organization->getKey(),
|
||||
'member_id' => $data->member->getKey(),
|
||||
]));
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('data.seconds', 100);
|
||||
}
|
||||
|
||||
public function test_aggregate_endpoint_groups_by_two_groups(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -2819,6 +2869,32 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_updates_user_id_when_member_id_changes(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:all',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($data->member)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||
'member_id' => $otherMember->getKey(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry->getKey(),
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'user_id' => $otherUser->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -3155,6 +3231,40 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_multiple_uses_member_id_for_own_permission_checks(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:delete:own',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
|
||||
'user_id' => $data->user->getKey(),
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [
|
||||
'ids' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertExactJson([
|
||||
'success' => [],
|
||||
'error' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -3566,6 +3676,46 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_uses_member_id_for_own_permission_checks(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:own',
|
||||
'projects:view:all',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
|
||||
'user_id' => $data->user->getKey(),
|
||||
]);
|
||||
$timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||
'ids' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
'changes' => [
|
||||
'description' => $timeEntriesFake->description,
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$this->assertResponseCode($response, 200);
|
||||
$response->assertExactJson([
|
||||
'success' => [],
|
||||
'error' => [
|
||||
$timeEntry->getKey(),
|
||||
],
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry->getKey(),
|
||||
'description' => $timeEntry->description,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -3612,6 +3762,51 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'time-entries:update:all',
|
||||
]);
|
||||
$otherUser = User::factory()->create();
|
||||
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create();
|
||||
$timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||
'ids' => [
|
||||
$timeEntry1->getKey(),
|
||||
$timeEntry2->getKey(),
|
||||
],
|
||||
'changes' => [
|
||||
'member_id' => $otherMember->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertValid();
|
||||
$response->assertStatus(200);
|
||||
$response->assertExactJson([
|
||||
'success' => [
|
||||
$timeEntry1->getKey(),
|
||||
$timeEntry2->getKey(),
|
||||
],
|
||||
'error' => [],
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry1->getKey(),
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'user_id' => $otherUser->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas(TimeEntry::class, [
|
||||
'id' => $timeEntry2->getKey(),
|
||||
'member_id' => $otherMember->getKey(),
|
||||
'user_id' => $otherUser->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
271
tests/Unit/Middleware/TrustHostsTest.php
Normal file
271
tests/Unit/Middleware/TrustHostsTest.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Middleware;
|
||||
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Tests\TestCase;
|
||||
|
||||
#[CoversClass(TrustHosts::class)]
|
||||
class TrustHostsTest extends TestCase
|
||||
{
|
||||
private const string CANONICAL = 'https://app.example.com';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['app.url' => self::CANONICAL]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Request::setTrustedHosts([]); // don't leak static state between tests
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* The real middleware, with only the environment gate forced on (it
|
||||
* self-exempts in the testing environment).
|
||||
*/
|
||||
private function middleware(): TrustHosts
|
||||
{
|
||||
return new class($this->app) extends TrustHosts
|
||||
{
|
||||
protected function shouldSpecifyTrustedHosts(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function accepts(Request $request): bool
|
||||
{
|
||||
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
|
||||
|
||||
try {
|
||||
dump($request->getHost());
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function test_canonical_host_is_accepted(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = Request::create(self::CANONICAL.'/login');
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($accepted);
|
||||
}
|
||||
|
||||
public function test_subdomain_of_canonical_host_is_accepted(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = Request::create('https://team.app.example.com/login');
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($accepted);
|
||||
}
|
||||
|
||||
public function test_declared_trusted_host_is_accepted(): void
|
||||
{
|
||||
// Arrange
|
||||
config(['app.trusted_hosts' => ['box.tailnet.ts.net']]);
|
||||
$request = Request::create('https://box.tailnet.ts.net/login');
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($accepted);
|
||||
}
|
||||
|
||||
public function test_wildcard_trusted_host_matches_subdomains_only(): void
|
||||
{
|
||||
// Arrange
|
||||
config(['app.trusted_hosts' => ['*.example.net']]);
|
||||
$subdomainRequest = Request::create('https://foo.example.net/login');
|
||||
$nestedSubdomainRequest = Request::create('https://a.b.example.net/login');
|
||||
$apexRequest = Request::create('https://example.net/login');
|
||||
$suffixInjectionRequest = Request::create('https://example.net.evil.com/login');
|
||||
|
||||
// Act
|
||||
$subdomainAccepted = $this->accepts($subdomainRequest);
|
||||
$nestedSubdomainAccepted = $this->accepts($nestedSubdomainRequest);
|
||||
$apexAccepted = $this->accepts($apexRequest);
|
||||
$suffixInjectionAccepted = $this->accepts($suffixInjectionRequest);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($subdomainAccepted);
|
||||
$this->assertTrue($nestedSubdomainAccepted);
|
||||
$this->assertFalse($apexAccepted);
|
||||
$this->assertFalse($suffixInjectionAccepted);
|
||||
}
|
||||
|
||||
public function test_multiple_trusted_hosts_are_all_accepted(): void
|
||||
{
|
||||
// Arrange
|
||||
config(['app.trusted_hosts' => [
|
||||
'box.tailnet.ts.net',
|
||||
'solidtime.internal',
|
||||
'*.preview.example.com',
|
||||
]]);
|
||||
$tailnetRequest = Request::create('https://box.tailnet.ts.net/login');
|
||||
$internalRequest = Request::create('https://solidtime.internal/login');
|
||||
$previewRequest = Request::create('https://pr-42.preview.example.com/login');
|
||||
$unlistedRequest = Request::create('https://evil.example.com/login');
|
||||
|
||||
// Act
|
||||
$tailnetAccepted = $this->accepts($tailnetRequest);
|
||||
$internalAccepted = $this->accepts($internalRequest);
|
||||
$previewAccepted = $this->accepts($previewRequest);
|
||||
$unlistedAccepted = $this->accepts($unlistedRequest);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($tailnetAccepted);
|
||||
$this->assertTrue($internalAccepted);
|
||||
$this->assertTrue($previewAccepted);
|
||||
$this->assertFalse($unlistedAccepted);
|
||||
}
|
||||
|
||||
public function test_poisoned_host_is_rejected(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = Request::create('https://evil.example.com/login');
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertFalse($accepted);
|
||||
}
|
||||
|
||||
public function test_poisoned_x_forwarded_host_is_rejected(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = Request::create(self::CANONICAL.'/login');
|
||||
$request->headers->set('X-Forwarded-Host', 'evil.example.com');
|
||||
$request->setTrustedProxies(
|
||||
['0.0.0.0/0', '2000::/3'],
|
||||
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
|
||||
);
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertFalse($accepted);
|
||||
}
|
||||
|
||||
public function test_forwarded_host_from_trusted_proxy_is_accepted(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = Request::create('https://evil.example.com/login');
|
||||
$request->headers->set('X-Forwarded-Host', 'app.example.com');
|
||||
$request->setTrustedProxies(
|
||||
['0.0.0.0/0', '2000::/3'],
|
||||
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
|
||||
);
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($accepted);
|
||||
}
|
||||
|
||||
public function test_forwarded_host_from_non_trusted_proxy_is_rejected_if_host_is_allowed(): void
|
||||
{
|
||||
// Arrange
|
||||
$request = Request::create('https://evil.example.com/login');
|
||||
$request->headers->set('X-Forwarded-Host', 'app.example.com');
|
||||
$request->setTrustedProxies(
|
||||
['1.2.3.4/32'], // Not a trusted proxy
|
||||
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
|
||||
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
|
||||
);
|
||||
|
||||
// Act
|
||||
$accepted = $this->accepts($request);
|
||||
|
||||
// Assert
|
||||
$this->assertFalse($accepted);
|
||||
}
|
||||
|
||||
public function test_health_check_endpoint_bypasses_host_validation(): void
|
||||
{
|
||||
// Arrange
|
||||
$internalIpRequest = Request::create('https://0.0.0.0/health-check/up');
|
||||
$localhostRequest = Request::create('http://localhost/health-check/up');
|
||||
|
||||
// Act
|
||||
$internalIpAccepted = $this->accepts($internalIpRequest);
|
||||
$localhostAccepted = $this->accepts($localhostRequest);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($internalIpAccepted);
|
||||
$this->assertTrue($localhostAccepted);
|
||||
}
|
||||
|
||||
public function test_health_check_endpoint_clears_state_before_other_middleware_reads_the_host(): void
|
||||
{
|
||||
// Arrange
|
||||
Request::setTrustedHosts(['^app\.example\.com$']);
|
||||
|
||||
// Act
|
||||
$response = $this->get(self::CANONICAL.'/health-check/up', ['Host' => '0.0.0.0']);
|
||||
|
||||
// Assert
|
||||
$response->assertSuccessful()
|
||||
->assertExactJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_untrusted_host_renders_a_helpful_error(): void
|
||||
{
|
||||
// Arrange
|
||||
$handler = app(ExceptionHandler::class);
|
||||
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
|
||||
$request = Request::create('https://evil.example.com/login');
|
||||
|
||||
// Act
|
||||
$response = $handler->render($request, $exception);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function test_untrusted_host_returns_json_for_api_clients(): void
|
||||
{
|
||||
// Arrange
|
||||
$handler = app(ExceptionHandler::class);
|
||||
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
|
||||
$request = Request::create('https://evil.example.com/api/v1/users');
|
||||
$request->headers->set('Accept', 'application/json');
|
||||
|
||||
// Act
|
||||
$response = $handler->render($request, $exception);
|
||||
|
||||
// Assert
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$this->assertJson((string) $response->getContent());
|
||||
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user