Compare commits

...

31 Commits

Author SHA1 Message Date
Gregor Vostrak
cb5c2547f4 fix profile setting sidebar alignment 2026-06-03 12:24:53 +02:00
Gregor Vostrak
13a25524f3 add saved/saving/error indicators to timesheets 2026-06-02 17:14:32 +02:00
Gregor Vostrak
112f6aa6a6 add invoice clone to openapi client, expose DetailedInvoice type 2026-05-29 19:07:55 +02:00
Gregor Vostrak
8eab0485c9 revert reka-ui update; fix DST cellMath; 2026-05-29 17:14:52 +02:00
Gregor Vostrak
0aa0f0bd77 use cn helper for alert-dialog modals 2026-05-29 17:14:52 +02:00
Gregor Vostrak
eb63c4ef03 fix light mode timesheet background and add missing aria-label 2026-05-29 17:14:52 +02:00
Gregor Vostrak
54fffd07bc add timesheet unit and e2e tests; add unit test CI setup 2026-05-29 17:14:52 +02:00
Gregor Vostrak
da235dfdc8 remove special “Add new project” state in TimeTrackerProjectTaskDropdown 2026-05-29 17:14:52 +02:00
Gregor Vostrak
0debdddef9 set min release age for npm packages to 7 days to prevent supply chain attacks 2026-05-29 17:14:52 +02:00
Gregor Vostrak
62354cfe8b remove timetrackerprojecttaskdropdown test without setup 2026-05-29 17:14:52 +02:00
Gregor Vostrak
396e7b2b6b fix DST boundary issue in timesheets 2026-05-29 17:14:52 +02:00
Gregor Vostrak
221889ff87 fix "No project" duplicating rows, unify no project senitel to null 2026-05-29 17:14:52 +02:00
Gregor Vostrak
7ce3fa2740 change TimeEntryFilter start filter to be inclusive 2026-05-29 17:14:52 +02:00
Gregor Vostrak
df34014bfe fix e2e tests 2026-05-29 17:14:52 +02:00
Gregor Vostrak
faf3ee471c fix formatting 2026-05-29 17:14:52 +02:00
Gregor Vostrak
866e5d8594 clamp running time entry duration to min 0 for FullCalendarHeaderDuration calc 2026-05-29 17:14:52 +02:00
Gregor Vostrak
72cd0b6f05 fix formatting 2026-05-29 17:14:52 +02:00
Gregor Vostrak
6d93e48b1d add missing dayjs plugins for isSameOrBefore and isSameOrAfter 2026-05-29 17:14:52 +02:00
Gregor Vostrak
09af0f775f add timesheets page 2026-05-29 17:14:52 +02:00
Gregor Vostrak
1cc000a584 fix local storage filter migration state for visibility filter 2026-05-26 11:37:24 +02:00
Gregor Vostrak
1a754f6756 improve modal and field group spacing for project modal layout 2026-05-26 11:15:15 +02:00
Gregor Vostrak
d69d25d059 add project table visibility filter 2026-05-26 11:15:15 +02:00
Gregor Vostrak
0e15d9d9c2 add project visibility ui 2026-05-26 11:15:15 +02:00
dependabot[bot]
7d9ecd9526 Bump aglipanci/laravel-pint-action from 2.5 to 2.6
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.5 to 2.6.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.5...2.6)

---
updated-dependencies:
- dependency-name: aglipanci/laravel-pint-action
  dependency-version: '2.6'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:28:48 +02:00
dependabot[bot]
3a17f80f99 Bump codecov/codecov-action from 5.4.3 to 5.5.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.3 to 5.5.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.3...v5.5.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:14:44 +02:00
dependabot[bot]
e29ea2ea42 Bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:13:14 +02:00
dependabot[bot]
fb6e4639ce Bump actions/download-artifact from 4 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:12:01 +02:00
dependabot[bot]
69bc41988a Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-20 15:11:19 +02:00
Gregor Vostrak
f7663b1c8b Clarify out of scope items for vulnerability reports
Added out of scope section for vulnerability reporting.
2026-05-18 19:21:32 +02:00
Gregor Vostrak
793bd11dcf remove member, invitation, and owner email disclosure from Teams/Show inertia props
The Teams/Show Inertia page serialized members, pending invitations, and the
owner email into props using only a belongsToTeam authorization gate, while
the corresponding API endpoints correctly enforced members:view and
invitations:view. The serialized data was unused by the live UI (the
TeamMemberManager partial that referenced it was orphaned), so dropping the
fields removes the disclosure surface without functional impact. The owner
card retains name and photo.
2026-05-18 19:04:57 +02:00
Gregor Vostrak
77a62afd69 add alphabetic sorting to multiselect dropdowns 2026-04-29 18:32:05 +02:00
90 changed files with 8415 additions and 1577 deletions

View File

@@ -91,7 +91,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -177,7 +177,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,12 +68,12 @@ jobs:
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
- name: "Checkout billing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-billing
path: extensions/Billing
@@ -93,7 +93,7 @@ jobs:
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-services
path: extensions/Services
@@ -111,7 +111,7 @@ jobs:
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: "Check out code"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +92,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -169,7 +169,7 @@ jobs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
uses: actions/download-artifact@v6
with:
path: ${{ runner.temp }}/digests
pattern: digests-*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

27
.github/workflows/npm-test-unit.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: NPM Test Unit
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
TZ: UTC
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Run vitest"
run: npm run test:unit

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install"
run: composer install -n --prefer-dist
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: '20.x'
@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.5.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -9,9 +9,9 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Check code style"
uses: aglipanci/laravel-pint-action@2.5
uses: aglipanci/laravel-pint-action@2.6
with:
configPath: "pint.json"

View File

@@ -35,10 +35,10 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Setup node"
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '20.x'

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

View File

@@ -3,3 +3,18 @@
## Reporting a Vulnerability
If you discover a security vulnerability regarding this project, please e-mail me to [security@solidtime.io](mailto:security@solidtime.io)!
## Out of scope
Reports we typically won't issue an advisory for:
* Theoretical findings without a working PoC
* Raw scanner output without manual validation
* Missing/weak security headers in isolation (CSP, X-Frame-Options, HSTS, etc.)
* SPF/DKIM/DMARC on non-mail-sending domains; missing DNSSEC/CAA; TLS cipher preferences
* Self-XSS; CSRF on non-state-changing endpoints (logout, theme)
* CSV / spreadsheet formula injection in exports — treated as a spreadsheet-application issue
* Org owners or admins acting destructively within their own organization
* Anything requiring direct DB, shell, or filesystem access on a self-hosted instance
* Missing OAuth Scope enforcement (this is not implemented yet, but AI scanners flag it which is why it is included in this list until we actually support it)

View File

@@ -304,28 +304,8 @@ class JetstreamServiceProvider extends ServiceProvider
'owner' => [
'id' => $owner->getKey(),
'name' => $owner->name,
'email' => $owner->email,
'profile_photo_url' => $owner->profile_photo_url,
],
'users' => $teamModel->users->map(function (User $user): array {
return [
'id' => $user->getKey(),
'name' => $user->name,
'email' => $user->email,
'profile_photo_url' => $user->profile_photo_url,
'membership' => [
'id' => $user->membership->id,
'role' => $user->membership->role,
],
];
}),
'team_invitations' => $teamModel->teamInvitations->map(function (OrganizationInvitation $invitation): array {
return [
'id' => $invitation->getKey(),
'email' => $invitation->email,
'role' => $invitation->role,
];
}),
],
'currencies' => array_map(function (Currency $currency): string {
return $currency->getName();

View File

@@ -62,7 +62,7 @@ class TimeEntryFilter
if ($start === null) {
return $this;
}
$this->builder->where('start', '>', $start);
$this->builder->where('start', '>=', $start);
return $this;
}

View File

@@ -107,7 +107,7 @@ services:
- sail
- reverse-proxy
playwright:
image: mcr.microsoft.com/playwright:v1.58.1-jammy
image: mcr.microsoft.com/playwright:v1.59.1-jammy
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
working_dir: /src
extra_hosts:

View File

@@ -907,7 +907,7 @@ test.describe('Employee Sidebar Navigation', () => {
// Visible links
await expect(employee.page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Time', exact: true })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Calendar' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Projects' })).toBeVisible();
await expect(employee.page.getByRole('link', { name: 'Clients' })).toBeVisible();

View File

@@ -6,6 +6,7 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
import {
createProjectViaApi,
createPublicProjectViaApi,
createProjectMemberViaApi,
createTaskViaApi,
createClientViaApi,
createTimeEntryViaApi,
@@ -217,6 +218,59 @@ test('test that creating a non-billable project works', async ({ page }) => {
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that creating a public project via the modal works', async ({ page }) => {
const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(newProjectName);
// Visibility defaults to Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Create Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.is_public === true
),
]);
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
});
test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
await projectRow.getByRole('button').click();
await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();
// Loaded as Private — switch it to Public
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
await page.getByRole('dialog').locator('#visibility').click();
await page.getByRole('option', { name: 'Public' }).click();
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.is_public === true
),
]);
});
test('test that switching from custom rate to default rate clears billable rate', async ({
page,
ctx,
@@ -640,7 +694,7 @@ test('test that creating a project with estimated time in human-readable format
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using human-readable format
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2h 30m');
await estimatedTimeInput.press('Tab');
@@ -668,7 +722,7 @@ test('test that creating a project with estimated time using decimal notation wo
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('1.5');
await estimatedTimeInput.press('Tab');
@@ -696,7 +750,7 @@ test('test that creating a project with estimated time using comma decimal notat
await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2,5');
await estimatedTimeInput.press('Tab');
@@ -727,7 +781,7 @@ test('test that updating estimated time on existing project works', async ({ pag
await page.getByRole('menuitem').getByText('Edit').first().click();
// Fill in estimated time
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('4h 15m');
await estimatedTimeInput.press('Tab');
@@ -748,7 +802,7 @@ test('test that estimated time input displays formatted value after blur', async
await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click();
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5');
const estimatedTimeInput = page.getByLabel('Time Estimated');
// Enter time in various formats and check the displayed value
await estimatedTimeInput.fill('90');
@@ -925,6 +979,39 @@ test.describe('Employee Projects Restrictions', () => {
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
).not.toBeVisible();
});
test('employee does not see private projects they are not a member of', async ({
ctx,
employee,
}) => {
const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
await createPublicProjectViaApi(ctx, { name: publicName });
// createProjectViaApi defaults to is_public: false (private); the employee is not a member
await createProjectViaApi(ctx, { name: privateName });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The public project is visible — confirms the list has loaded
await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });
// The private project the employee is not a member of must not appear
await expect(employee.page.getByText(privateName)).not.toBeVisible();
});
test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
const project = await createProjectViaApi(ctx, { name: projectName });
// Add the employee as a project member so the private project becomes visible to them
await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
// The private project is visible because the employee is a member
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
});
});
test.describe('Employee Billable Rate Visibility', () => {

View File

@@ -0,0 +1,437 @@
/**
* E2E coverage for the timesheet overlap-prevention logic introduced
* in `useTimesheetCellMutations` (Phase 1+2+3 of the overlap fix).
*
* Each test:
* 1. Pre-creates entries via the API to set up a deterministic
* day-of-work scenario,
* 2. Triggers ONE cell edit through the UI,
* 3. Reads the resulting entries back via the API and asserts on
* the start/end placement.
*
* Pre-creating rows (rather than driving the "Add row" + project picker
* UI) keeps the tests focused on the placement logic and out of the
* project-dropdown's flake surface.
*/
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page, Request } from '@playwright/test';
import {
createProjectViaApi,
createTimeEntryAtHourViaApi,
getTimeEntriesViaApi,
} from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
async function waitForTimesheetLoad(page: Page) {
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
const HOUR = 3600;
function utcHourOf(iso: string): number {
return new Date(iso).getUTCHours();
}
function utcMinuteOf(iso: string): number {
return new Date(iso).getUTCMinutes();
}
function sortByStart<T extends { start: string }>(entries: T[]): T[] {
return [...entries].sort((a, b) => a.start.localeCompare(b.start));
}
/**
* Returns the locator for the row whose project name matches the given
* substring. Robust against ordering changes.
*/
function rowByProject(page: Page, projectName: string) {
return page.locator('[data-testid="timesheet_row"]').filter({ hasText: projectName });
}
/**
* Returns the locator for the input in the (row, dayIndex) cell, where
* the row is identified by project name.
*/
function cellInputByProject(page: Page, projectName: string, dayIndex: number) {
return rowByProject(page, projectName)
.locator('[data-testid="timesheet_cell"]')
.nth(dayIndex)
.locator('input');
}
/** Asserts that no entries in the list overlap each other. */
function expectNoOverlaps(entries: Array<{ start: string; end: string | null }>) {
const sorted = sortByStart(entries.filter((e) => e.end !== null));
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const curr = sorted[i]!;
expect(
curr.start >= prev.end!,
`entries overlap: ${prev.start}${prev.end} vs ${curr.start}${curr.end}`
).toBe(true);
}
}
// ──────────────────────────────────────────────────
// Phase 1: createCell — overlap avoidance when cell is empty
// ──────────────────────────────────────────────────
test('extendCell on a row that has no entries on the day yet places after another row (Scenario #4)', async ({
page,
ctx,
}) => {
// Setup: project A has Monday 09:0010:00, project B has Tuesday
// 09:0010:00. The B row is therefore visible on the timesheet but
// has an EMPTY cell on Monday. Typing into B's Monday cell exercises
// the createCell path (cell empty → place a new entry).
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectA = await createProjectViaApi(ctx, { name: 'OverlapAlpha' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBravo' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into project B's Monday cell. The createCell path should
// place it AFTER project A's 09:0010:00 (i.e. at 10:00 or later),
// not at 09:00.
const input = cellInputByProject(page, 'OverlapBravo', 0);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const bMondayEntry = entries.find(
(e) =>
e.project_id === projectB.id &&
new Date(e.start).getTime() >= monday.getTime() &&
new Date(e.start).getTime() < tuesday.getTime()
)!;
expect(bMondayEntry).toBeDefined();
// 09:00 is blocked → must be at 10:00 or later.
expect(utcHourOf(bMondayEntry.start)).toBeGreaterThanOrEqual(10);
expectNoOverlaps(entries);
});
test('createCell refuses to cross midnight when day is full (Scenario #3)', async ({
page,
ctx,
}) => {
// Setup: fill Monday 01:0023:00 (22 hours, leaving 1h before and
// 1h after — neither big enough for a 3h ask). Project B is on
// Tuesday so the B row exists with an empty Monday cell. Typing 3h
// into B's Monday cell should be refused.
//
// We start at 01:00 (not 00:00) because the API's time-entry
// filter excludes entries whose `start` equals the query's `start`
// bound exactly. Using 01:00 avoids that boundary condition.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const projectFull = await createProjectViaApi(ctx, { name: 'OverlapFull' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapNoRoom' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 1,
durationSeconds: 22 * HOUR,
projectId: projectFull.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: tuesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapNoRoom', 0);
const seenMutationRequests: string[] = [];
const onRequest = (request: Request) => {
if (request.url().includes('/time-entries') && request.method() !== 'GET') {
seenMutationRequests.push(request.method());
}
};
page.on('request', onRequest);
await input.click();
await input.fill('3');
await input.press('Enter');
await expect(page.getByText("This day can't fit any more work")).toBeVisible();
page.off('request', onRequest);
const entries = await getTimeEntriesViaApi(ctx);
// The new project should still only have its Tuesday entry.
const newEntries = entries.filter((e) => e.project_id === projectNew.id);
expect(seenMutationRequests).toEqual([]);
expect(newEntries).toHaveLength(1);
expect(utcHourOf(newEntries[0]!.start)).toBe(9);
// The Tuesday entry's date is unchanged (still Tuesday).
expect(new Date(newEntries[0]!.start).getUTCDay()).toBe(2);
});
// ──────────────────────────────────────────────────
// Phase 2: extendCell — collision detection + split
// ──────────────────────────────────────────────────
test('extendCell splits the extension when another row blocks the path (Scenario #5)', async ({
page,
ctx,
}) => {
// Setup:
// - project A on Monday 09:0010:00 (1h)
// - project B on Monday 10:3011:30 (1h, blocker)
// Bumping A's Monday cell from 1h to 3h (+2h) should:
// - extend A to 09:0010:30 (filling the 30min gap)
// - place a new A entry at 11:3013:00 (the remaining 90min)
const monday = getCurrentWeekMonday();
const projectA = await createProjectViaApi(ctx, { name: 'OverlapExtend' });
const projectB = await createProjectViaApi(ctx, { name: 'OverlapBlocker' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectA.id,
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
startMinute: 30,
durationSeconds: HOUR,
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
const input = cellInputByProject(page, 'OverlapExtend', 0);
await input.click();
await input.fill('3');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const aEntries = entries.filter((e) => e.project_id === projectA.id);
const bEntries = entries.filter((e) => e.project_id === projectB.id);
// The blocker is unchanged.
expect(bEntries).toHaveLength(1);
expect(utcHourOf(bEntries[0]!.start)).toBe(10);
expect(utcMinuteOf(bEntries[0]!.start)).toBe(30);
// Project A should now have 2 entries.
expect(aEntries).toHaveLength(2);
const sortedA = sortByStart(aEntries);
// Extended entry: 09:00 → 10:30
expect(utcHourOf(sortedA[0]!.start)).toBe(9);
expect(utcHourOf(sortedA[0]!.end!)).toBe(10);
expect(utcMinuteOf(sortedA[0]!.end!)).toBe(30);
// Split remainder: 11:30 → 13:00
expect(utcHourOf(sortedA[1]!.start)).toBe(11);
expect(utcMinuteOf(sortedA[1]!.start)).toBe(30);
// No overlaps anywhere on the day.
expectNoOverlaps(entries);
});
test('extendCell prefers latest-end (not latest-start) when nested entries exist (Scenario #6)', async ({
page,
ctx,
}) => {
// Pre-existing nested overlap on the same project:
// - outer: 09:00 → 12:00 (3h)
// - inner: 10:00 → 11:00 (1h, contained inside outer)
// The cell total is 3h + 1h = 4h. Bumping to 5h (+1h) should grow
// the OUTER entry's end to 13:00, not the inner.
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'OverlapNested' });
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 9,
durationSeconds: 3 * HOUR,
projectId: project.id,
description: 'outer',
});
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 10,
durationSeconds: HOUR,
projectId: project.id,
description: 'inner',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(1);
const input = cellInputByProject(page, 'OverlapNested', 0);
await input.click();
await input.fill('5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const outer = entries.find((e) => e.description === 'outer')!;
const inner = entries.find((e) => e.description === 'inner')!;
expect(utcHourOf(outer.start)).toBe(9);
expect(utcHourOf(outer.end!)).toBe(13); // extended from 12:00 → 13:00
expect(utcHourOf(inner.start)).toBe(10);
expect(utcHourOf(inner.end!)).toBe(11); // unchanged
});
// ──────────────────────────────────────────────────
// Phase 1+2 spillover from previous day
// ──────────────────────────────────────────────────
test('createCell handles intra-week spillover from previous day (Scenario #2)', async ({
page,
ctx,
}) => {
// Setup: an entry that starts on Monday 22:00 and ends Tuesday 03:00
// (5h, crosses midnight INTO Tuesday). This spillover starts inside
// the loaded week, so the timesheet query loads it.
//
// Then we try to place 1h on Tuesday for a different project. The
// expected behavior: the new entry must NOT overlap the spillover.
// Tuesday 09:00 is well clear of the [00:00, 03:00) spillover, so
// 09:00 is the correct placement.
const monday = getCurrentWeekMonday();
const tuesday = new Date(monday);
tuesday.setUTCDate(monday.getUTCDate() + 1);
const wednesday = new Date(monday);
wednesday.setUTCDate(monday.getUTCDate() + 2);
const projectSpill = await createProjectViaApi(ctx, { name: 'OverlapSpill' });
const projectNew = await createProjectViaApi(ctx, { name: 'OverlapToday' });
// Monday 22:00 → Tuesday 03:00 (5h spillover into Tuesday).
await createTimeEntryAtHourViaApi(ctx, {
date: monday,
startHour: 22,
durationSeconds: 5 * HOUR,
projectId: projectSpill.id,
});
// Stub Wednesday entry on the new project so its row is visible
// even before we type anything in Tuesday's cell.
await createTimeEntryAtHourViaApi(ctx, {
date: wednesday,
startHour: 9,
durationSeconds: HOUR,
projectId: projectNew.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(2);
// Type 1h into the new project's Tuesday cell (day index 1).
const input = cellInputByProject(page, 'OverlapToday', 1);
await input.click();
await input.fill('1');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
input.press('Enter'),
]);
const entries = await getTimeEntriesViaApi(ctx);
const newTuesdayEntry = entries.find(
(e) =>
e.project_id === projectNew.id &&
new Date(e.start).getTime() >= tuesday.getTime() &&
new Date(e.start).getTime() < wednesday.getTime()
)!;
expect(newTuesdayEntry).toBeDefined();
// 09:00 is well past the spillover end (03:00) → should land at 09:00.
expect(utcHourOf(newTuesdayEntry.start)).toBe(9);
expectNoOverlaps(entries);
});

641
e2e/timesheet.spec.ts Normal file
View File

@@ -0,0 +1,641 @@
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
import { expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { createProjectViaApi, createTaskViaApi, createTimeEntryOnDateViaApi } from './utils/api';
// ──────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────
async function goToTimesheet(page: Page) {
await page.addInitScript(() => {
window.localStorage.setItem('showReleaseInfo-desktop', 'false');
});
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
}
function getMonday(d: Date): Date {
const date = new Date(d);
const day = date.getUTCDay();
const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1);
date.setUTCDate(diff);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getCurrentWeekMonday(): Date {
return getMonday(new Date());
}
function getLastWeekMonday(): Date {
const monday = getCurrentWeekMonday();
monday.setUTCDate(monday.getUTCDate() - 7);
return monday;
}
function getDayOfWeek(weekStart: Date, dayOffset: number): Date {
const date = new Date(weekStart);
date.setUTCDate(date.getUTCDate() + dayOffset);
return date;
}
async function waitForTimesheetLoad(page: Page) {
await page.waitForURL(/\/timesheet(?:$|\?)/);
await expect(page.getByTestId('timesheet_view')).toBeVisible();
await expect(page.getByTestId('timesheet_week_display')).toBeVisible();
const timezoneMismatchModal = page
.getByRole('dialog')
.filter({ hasText: 'Timezone mismatch detected' });
if (await timezoneMismatchModal.isVisible().catch(() => false)) {
await timezoneMismatchModal.getByRole('button', { name: 'Cancel' }).click();
await expect(timezoneMismatchModal).not.toBeVisible();
}
}
function addRowButton(page: Page) {
return page.getByRole('button', { name: /Add row/i }).first();
}
async function chooseRowIdentity(page: Page, optionName: string) {
await addRowButton(page).click();
const dialog = page.getByRole('dialog', { name: /Add row/i });
const dialogVisible = await dialog
.waitFor({ state: 'visible', timeout: 1000 })
.then(() => true)
.catch(() => false);
if (dialogVisible) {
await dialog.getByRole('option', { name: optionName }).click();
return;
}
if (optionName === 'No Project') return;
const row = page.locator('[data-testid="timesheet_row"]').first();
await row.getByText('No Project').click();
await page.getByText(optionName).click();
}
// ──────────────────────────────────────────────────
// Navigation & Page Load
// ──────────────────────────────────────────────────
test('timesheet renders empty with add row + copy last week actions', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
await expect(addRowButton(page)).toBeVisible();
await expect(page.getByRole('button', { name: /Copy last week/i })).toBeVisible();
});
// ──────────────────────────────────────────────────
// Display Existing Time Entries
// ──────────────────────────────────────────────────
test('timesheet displays existing time entries grouped by project', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const tuesday = getDayOfWeek(monday, 1);
const wednesday = getDayOfWeek(monday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Project Alpha' });
const projectB = await createProjectViaApi(ctx, { name: 'Project Beta' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: wednesday,
duration: '1h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: tuesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// Check that the grand total is shown
await expect(page.getByTestId('timesheet_grand_total')).toBeVisible();
});
test('timesheet groups entries by project and task combination', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Task Project' });
const taskA = await createTaskViaApi(ctx, { name: 'Task A', project_id: project.id });
const taskB = await createTaskViaApi(ctx, { name: 'Task B', project_id: project.id });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
taskId: taskA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
taskId: taskB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
});
// ──────────────────────────────────────────────────
// Enter Duration in Cell
// ──────────────────────────────────────────────────
test('entering duration in empty cell creates a time entry', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Duration Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Duration Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Click the first day cell and enter duration
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayCell = cells.first();
const mondayInput = mondayCell.locator('input');
await mondayInput.click();
await mondayInput.fill('2');
// Submit and wait for create response
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(201);
// Verify the cell shows the duration
await expect(mondayInput).not.toHaveValue('');
});
// ──────────────────────────────────────────────────
// Edit Duration (Increase)
// ──────────────────────────────────────────────────
test('increasing duration in cell extends the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Increase Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Click and change to 3 hours
await mondayInput.click();
await mondayInput.fill('3');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Edit Duration (Decrease)
// ──────────────────────────────────────────────────
test('decreasing duration in cell shortens the last time entry', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Decrease Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '3h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'PUT' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
// ──────────────────────────────────────────────────
// Clear Cell
// ──────────────────────────────────────────────────
test('clearing a cell deletes all time entries for that project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Clear Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('0');
const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
mondayInput.press('Enter'),
]);
expect(response.status()).toBe(200);
});
test('Escape during cell edit reverts the displayed value without an API call', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Escape Cancel Test' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const row = page.locator('[data-testid="timesheet_row"]').first();
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// Capture the formatted display value before editing.
const originalValue = await mondayInput.inputValue();
expect(originalValue).toMatch(/2/);
let mutationFired = false;
page.on('request', (req) => {
if (req.url().includes('/time-entries') && req.method() !== 'GET') {
mutationFired = true;
}
});
await mondayInput.click();
await mondayInput.fill('5');
await mondayInput.press('Escape');
// The Escape handler reverts the displayed value synchronously, so
// once this assertion passes we know the handler ran. Any mutation
// request would have been queued by then.
await expect(mondayInput).toHaveValue(originalValue);
expect(mutationFired).toBe(false);
});
// ──────────────────────────────────────────────────
// Week Navigation
// ──────────────────────────────────────────────────
test('navigating to previous week shows entries from that week', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Last Week Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no entries
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Go to previous week — the row-count assertion below auto-retries
// until the new week's data arrives.
await page.getByTestId('timesheet_prev_week').click();
// Should now see the entry
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
});
test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the
// header label flips.
await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week
await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
});
// ──────────────────────────────────────────────────
// Copy Last Week
// ──────────────────────────────────────────────────
test('copy last week adds project rows from previous week without hours', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const lastWednesday = getDayOfWeek(lastMonday, 2);
const projectA = await createProjectViaApi(ctx, { name: 'Copy Project A' });
const projectB = await createProjectViaApi(ctx, { name: 'Copy Project B' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: projectA.id,
});
await createTimeEntryOnDateViaApi(ctx, {
date: lastWednesday,
duration: '3h',
projectId: projectB.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should now show 2 rows (one per project)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(2);
// All row totals should be 0
const rowTotals = page.locator('[data-testid="timesheet_row_total"]');
const count = await rowTotals.count();
for (let i = 0; i < count; i++) {
await expect(rowTotals.nth(i)).toContainText('-');
}
});
test('copy last week does not duplicate rows that already exist', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const thisMonday = getCurrentWeekMonday();
const thisTuesday = getDayOfWeek(thisMonday, 1);
const project = await createProjectViaApi(ctx, { name: 'No Dup Project' });
// Create entry for last week
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
// Create entry for current week
await createTimeEntryOnDateViaApi(ctx, {
date: thisTuesday,
duration: '1h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should have 1 row (from current week entry)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Open copy last week dropdown and click "Copy rows only"
await page.getByRole('button', { name: /Copy last week/i }).click();
await page.getByText('Copy rows only').click();
// Should still have only 1 row (not duplicated)
await expect(rows).toHaveCount(1);
});
test('copy last week with time entries creates rows and entries', async ({ page, ctx }) => {
const lastMonday = getLastWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Copy Time Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: lastMonday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Current week should have no populated rows yet.
await expect(page.locator('[data-testid="timesheet_row"]')).toHaveCount(0);
// Open copy last week dropdown and click "Copy rows and time entries"
await page.getByRole('button', { name: /Copy last week/i }).click();
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
page.getByText('Copy rows and time entries').click(),
]);
// Should now show 1 row with time entries
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Row total should not be 0 (entries were copied)
const rowTotal = page.locator('[data-testid="timesheet_row_total"]').first();
await expect(rowTotal).not.toContainText('0 h');
});
// ──────────────────────────────────────────────────
// Row Removal
// ──────────────────────────────────────────────────
test('can remove an empty project row without confirmation', async ({ page, ctx }) => {
const project = await createProjectViaApi(ctx, { name: 'Empty Remove Project' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, project.name);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover the row to reveal the X button, then click it
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Row should be removed immediately (no dialog)
await expect(rows).toHaveCount(0);
});
test('removing a row with entries shows confirmation dialog and deletes entries', async ({
page,
ctx,
}) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Delete Row Project' });
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// Hover and click X
await rows.first().hover();
await rows.first().getByRole('button', { name: 'Remove row' }).click();
// Confirmation dialog should appear
await expect(page.getByRole('alertdialog')).toBeVisible();
await expect(page.getByText('Remove timesheet row?')).toBeVisible();
// Click Delete
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'DELETE' &&
resp.status() === 200
),
page
.getByRole('alertdialog')
.getByRole('button', { name: /Delete/i })
.click(),
]);
// Row should be gone
await expect(rows).toHaveCount(0);
});
// ──────────────────────────────────────────────────
// Multiple Entries Same Cell
// ──────────────────────────────────────────────────
test('cell correctly sums multiple entries for same project+day', async ({ page, ctx }) => {
const monday = getCurrentWeekMonday();
const project = await createProjectViaApi(ctx, { name: 'Sum Test' });
// Create 2 entries for the same project on Monday
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '1h',
projectId: project.id,
description: 'Entry 1',
});
await createTimeEntryOnDateViaApi(ctx, {
date: monday,
duration: '2h',
projectId: project.id,
description: 'Entry 2',
});
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should be 1 row (both entries grouped)
const rows = page.locator('[data-testid="timesheet_row"]');
await expect(rows).toHaveCount(1);
// The Monday cell should show 3h total
const cells = rows.first().locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
// The value should contain "3" (for 3h in some format)
await expect(mondayInput).toHaveValue(/3/);
});
// ──────────────────────────────────────────────────
// Duration Input Formats
// ──────────────────────────────────────────────────
test('cell accepts various duration input formats', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: 'Format Test' });
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
await chooseRowIdentity(page, 'Format Test');
const row = page.locator('[data-testid="timesheet_row"]').first();
// Test entering "1.5" (should be 1h 30min)
const cells = row.locator('[data-testid="timesheet_cell"]');
const mondayInput = cells.first().locator('input');
await mondayInput.click();
await mondayInput.fill('1.5');
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/time-entries') &&
resp.request().method() === 'POST' &&
resp.status() === 201
),
mondayInput.press('Enter'),
]);
// 1.5 hours = 1h 30min
await expect(mondayInput).toHaveValue('1h 30min');
});

View File

@@ -170,10 +170,24 @@ function parseDurationToSeconds(duration: string): number {
return totalSeconds;
}
/**
* Builds a start/end pair anchored to 09:00 UTC on today's UTC date.
*
* Intentionally pinned to UTC (rather than the runner's local time) so
* the produced timestamps are identical regardless of where the suite
* runs. Playwright test users default to UTC, so this matches what the
* app will see and keeps day-of-week / "this week" assertions stable
* for developers running the suite locally in non-UTC timezones.
*/
function createTimestamps(duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0, 0);
const start = createUtcTimestampFromDateParts(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
@@ -186,6 +200,32 @@ function formatTimestamp(date: Date): string {
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
function createUtcTimestampFromDateParts(
year: number,
month: number,
date: number,
hours: number,
minutes: number = 0,
seconds: number = 0
): Date {
return new Date(Date.UTC(year, month, date, hours, minutes, seconds));
}
function createTimestampsOnDate(date: Date, duration: string): { start: string; end: string } {
const durationSeconds = parseDurationToSeconds(duration);
const start = createUtcTimestampFromDateParts(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
9
);
const end = new Date(start.getTime() + durationSeconds * 1000);
return {
start: formatTimestamp(start),
end: formatTimestamp(end),
};
}
function randomColor(): string {
const colors = [
'#ef5350',
@@ -375,6 +415,39 @@ export async function createTimeEntryViaApi(
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createTimeEntryOnDateViaApi(
ctx: TestContext,
data: {
date: Date;
duration: string;
description?: string;
projectId?: string | null;
taskId?: string | null;
tags?: string[];
billable?: boolean;
}
) {
const { start, end } = createTimestampsOnDate(data.date, data.duration);
const response = await ctx.request.post(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries`,
{
data: {
member_id: ctx.memberId,
start,
end,
description: data.description ?? '',
project_id: data.projectId ?? null,
task_id: data.taskId ?? null,
tags: data.tags ?? [],
billable: data.billable ?? false,
},
}
);
expect(response.status()).toBe(201);
const body = await response.json();
return body.data as { id: string; start: string; end: string; description: string };
}
export async function createProjectMemberViaApi(
ctx: TestContext,
projectId: string,
@@ -613,6 +686,72 @@ export async function getInvitationsViaApi(ctx: TestContext) {
// Timestamp-based time entry helpers
// ──────────────────────────────────────────────────
/**
* Creates a time entry on `date` at a specific UTC hour with a duration
* in seconds. Playwright test users default to the UTC timezone, so this
* keeps time-placement scenarios stable across runner locales.
*/
export async function createTimeEntryAtHourViaApi(
ctx: TestContext,
data: {
date: Date;
startHour: number;
startMinute?: number;
durationSeconds: number;
projectId?: string | null;
taskId?: string | null;
description?: string;
}
) {
const start = createUtcTimestampFromDateParts(
data.date.getUTCFullYear(),
data.date.getUTCMonth(),
data.date.getUTCDate(),
data.startHour,
data.startMinute ?? 0
);
const end = new Date(start.getTime() + data.durationSeconds * 1000);
return createTimeEntryWithTimestampsViaApi(ctx, {
start: formatTimestamp(start),
end: formatTimestamp(end),
projectId: data.projectId ?? null,
taskId: data.taskId ?? null,
description: data.description ?? '',
});
}
/**
* Reads time entries for the current member, optionally filtered to a
* date range. Returns the raw API objects (id, start, end, project_id,
* etc.) so tests can assert on the database state after a UI action.
*/
export async function getTimeEntriesViaApi(
ctx: TestContext,
filters: { start?: string; end?: string } = {}
): Promise<
Array<{
id: string;
start: string;
end: string | null;
duration: number | null;
project_id: string | null;
task_id: string | null;
description: string;
}>
> {
const params = new URLSearchParams();
params.set('member_id', ctx.memberId);
if (filters.start) params.set('start', filters.start);
if (filters.end) params.set('end', filters.end);
const response = await ctx.request.get(
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/time-entries?${params.toString()}`
);
expect(response.status()).toBe(200);
const body = await response.json();
return body.data;
}
export async function createTimeEntryWithTimestampsViaApi(
ctx: TestContext,
data: {

2843
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,17 @@
"lint": "eslint resources/js",
"lint:fix": "eslint --fix resources/js",
"type-check": "vue-tsc --noEmit",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "rm -rf test-results/.auth && npx playwright test",
"zod:generate": "npx openapi-zod-client http://localhost:80/docs/api.json --output resources/js/packages/api/src/openapi.json.client.ts --base-url /api",
"format": "prettier --write './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'"
"format:check": "prettier --check './**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}'",
"build:ui": "npm run build --workspace=@solidtime/ui",
"build:api": "npm run build --workspace=@solidtime/api",
"build:packages": "npm run build:api && npm run build:ui",
"watch:ui": "npm run watch --workspace=@solidtime/ui",
"watch:api": "npm run watch --workspace=@solidtime/api"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -27,10 +34,12 @@
"@types/chroma-js": "^3.1.0",
"@types/node": "^22.10.10",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.0",
"autoprefixer": "^10.4.20",
"axios": "^1.6.4",
"eslint-plugin-unused-imports": "^4.1.4",
"happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0",
"openapi-zod-client": "^1.16.2",
"postcss": "^8.4.47",
@@ -40,6 +49,7 @@
"typescript": "^5.7.3",
"vite": "^7.0.0",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4",
"vue": "^3.5.0",
"vue-tsc": "^3.0.0"
},
@@ -68,7 +78,7 @@
"parse-duration": "^2.0.1",
"pinia": "^3.0.0",
"radix-vue": "^1.9.6",
"reka-ui": "^2.8.2",
"reka-ui": "2.8.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vue-echarts": "^8.0.0",

View File

@@ -19,6 +19,7 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
@@ -44,6 +45,7 @@ const project = ref<CreateProjectBody>({
billable_rate: props.originalProject.billable_rate,
is_billable: props.originalProject.is_billable,
estimated_time: props.originalProject.estimated_time,
is_public: props.originalProject.is_public,
});
async function submit() {
@@ -126,6 +128,7 @@ async function submitBillableRate() {
v-if="isAllowedToPerformPremiumAction()"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup>
</template>
<template #footer>

View File

@@ -13,7 +13,8 @@ export type SortColumn =
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status';
| 'status'
| 'visibility';
export type SortDirection = 'asc' | 'desc';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
@@ -102,6 +103,10 @@ const columns = computed(() => [
id: 'status',
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
},
{
id: 'visibility',
accessorFn: (row: Project) => (row.is_public ? 1 : 0),
},
]);
// Columns with sortDescFirst get desc as default direction on first click.
@@ -149,7 +154,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
}
const gridTemplate = computed(() => {
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
});
</script>
@@ -171,7 +176,7 @@ const gridTemplate = computed(() => {
:sort-direction="props.sortDirection"
:desc-first-columns="descFirstColumns"
@sort="handleSort"></ProjectTableHeading>
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
<h3 class="text-text-primary font-semibold">
{{

View File

@@ -86,6 +86,14 @@ function isChevronUp(column: SortColumn): boolean {
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
@click="handleSort('visibility')">
Visibility
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
<span v-else class="w-4 h-4"></span>
</div>
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<span class="sr-only">Edit</span>
</div>

View File

@@ -7,6 +7,8 @@ import {
PencilSquareIcon,
ArchiveBoxIcon as ArchiveBoxIconSolid,
TrashIcon,
GlobeAltIcon,
LockClosedIcon,
} from '@heroicons/vue/20/solid';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
@@ -141,6 +143,17 @@ const showEditProjectModal = ref(false);
<span>Active</span>
</template>
</div>
<div
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
<template v-if="project.is_public">
<GlobeAltIcon class="w-4 text-icon-default"></GlobeAltIcon>
<span>Public</span>
</template>
<template v-else>
<LockClosedIcon class="w-4 text-icon-default"></LockClosedIcon>
<span>Private</span>
</template>
</div>
<div
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
<ProjectMoreOptionsDropdown

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GlobeAltIcon } from '@heroicons/vue/16/solid';
import { DropdownMenuItem } from '@/packages/ui/src';
import BaseFilterBadge from './BaseFilterBadge.vue';
type VisibilityValue = 'public' | 'private' | 'all';
const props = defineProps<{
value: VisibilityValue;
}>();
const emit = defineEmits<{
remove: [];
'update:value': [value: VisibilityValue];
}>();
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const label = computed(() => {
return visibilityOptions.find((opt) => opt.id === props.value)?.name ?? 'Visibility';
});
function updateVisibility(visibility: VisibilityValue) {
emit('update:value', visibility);
}
</script>
<template>
<BaseFilterBadge
:icon="GlobeAltIcon"
:label="label"
filter-name="Visibility"
@remove="emit('remove')">
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[value === option.id && 'bg-accent text-accent-foreground']"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</BaseFilterBadge>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
import {
DropdownMenu,
@@ -19,6 +19,7 @@ import { NO_CLIENT_ID } from './constants';
export interface ProjectFilters {
status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
clientIds: string[];
}
@@ -36,6 +37,11 @@ const statusOptions = [
{ id: 'archived' as const, name: 'Archived' },
];
const visibilityOptions = [
{ id: 'public' as const, name: 'Public' },
{ id: 'private' as const, name: 'Private' },
];
const open = ref(false);
function updateStatus(status: 'active' | 'archived' | 'all') {
@@ -46,6 +52,14 @@ function updateStatus(status: 'active' | 'archived' | 'all') {
open.value = false;
}
function updateVisibility(visibility: 'public' | 'private' | 'all') {
emit('update:filters', {
...props.filters,
visibility,
});
open.value = false;
}
function toggleClient(clientId: string) {
const clientIds = props.filters.clientIds.includes(clientId)
? props.filters.clientIds.filter((id) => id !== clientId)
@@ -69,7 +83,11 @@ function toggleNoClient() {
}
const hasActiveFilters = computed(() => {
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
return (
props.filters.status !== 'all' ||
props.filters.visibility !== 'all' ||
props.filters.clientIds.length > 0
);
});
</script>
@@ -102,6 +120,25 @@ const hasActiveFilters = computed(() => {
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Visibility Filter -->
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<GlobeAltIcon class="h-4 w-4 text-icon-default" />
<span>Visibility</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
v-for="option in visibilityOptions"
:key="option.id"
:class="[
filters.visibility === option.id && 'bg-accent text-accent-foreground',
]"
@click="updateVisibility(option.id)">
{{ option.name }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<!-- Client Filter -->
<DropdownMenuSub v-if="clients.length > 0">
<DropdownMenuSubTrigger class="gap-2">

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/Components/ui/alert-dialog';
defineProps<{
open: boolean;
entryCount: number;
projectName: string;
}>();
defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'confirm'): void;
}>();
</script>
<template>
<AlertDialog :open="open" @update:open="$emit('update:open', $event)">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove timesheet row?</AlertDialogTitle>
<AlertDialogDescription>
This will delete {{ entryCount }} time
{{ entryCount === 1 ? 'entry' : 'entries' }}
for "{{ projectName }}". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@click="$emit('confirm')">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TimesheetCell from './TimesheetCell.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import type { TimesheetCell as TimesheetCellType } from '@/utils/useTimesheetGrid';
function buildCell(totalSeconds: number): TimesheetCellType {
return {
dayIndex: 0,
date: '2026-04-13',
entries: [],
totalSeconds,
};
}
function mountTimesheetCell(totalSeconds = 2 * 3600) {
return mount(TimesheetCell, {
props: {
cell: buildCell(totalSeconds),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
},
});
}
describe('TimesheetCell', () => {
it('emits 0 when the cleared value is committed on blur', async () => {
const wrapper = mountTimesheetCell();
const input = wrapper.get('input');
await input.trigger('focus');
await input.setValue('');
await input.trigger('blur');
expect(wrapper.emitted('update')).toEqual([[0]]);
});
it('emits 0 when the cleared value is committed with Enter', async () => {
const wrapper = mountTimesheetCell();
const input = wrapper.get('input');
await input.trigger('focus');
await input.setValue('');
await input.trigger('keydown', { key: 'Enter' });
expect(wrapper.emitted('update')).toEqual([[0]]);
});
it('restores the previous value and emits nothing on Escape', async () => {
const wrapper = mountTimesheetCell();
const input = wrapper.get('input');
const previousValue = formatHumanReadableDuration(2 * 3600, 'hours-minutes', 'point');
await input.trigger('focus');
await input.setValue('');
await input.trigger('keydown', { key: 'Escape' });
await nextTick();
expect(wrapper.emitted('update')).toBeUndefined();
expect((input.element as HTMLInputElement).value).toBe(previousValue);
});
it('shows a pending 0 (delete in flight) over the cell total', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
pendingSeconds: 0,
},
});
// `??` (not `||`): a pending 0 must win over the 2h cell total.
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('');
});
it('disables editing while the cell is saving', () => {
const wrapper = mount(TimesheetCell, {
props: {
cell: buildCell(2 * 3600),
dayIndex: 0,
date: '2026-04-13',
isToday: false,
hasRunningEntry: false,
saveStatus: 'saving',
},
});
expect((wrapper.get('input').element as HTMLInputElement).disabled).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CheckIcon } from '@heroicons/vue/16/solid';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const props = defineProps<{
cell?: TimesheetCell;
dayIndex: number;
date: string;
isToday: boolean;
hasRunningEntry: boolean;
saveStatus?: CellSaveStatus;
pendingSeconds?: number;
}>();
const emit = defineEmits<{
update: [newSeconds: number];
}>();
// Show the optimistic value while saving; `??` (not `||`) so a pending 0 (delete) wins.
const displaySeconds = computed(() => props.pendingSeconds ?? props.cell?.totalSeconds ?? 0);
const isSaving = computed(() => props.saveStatus === 'saving');
// Swap the border color (don't layer) to avoid same-specificity fights.
const inputClass = computed(() => {
const border = props.saveStatus === 'error' ? 'border-red-500/70' : 'border-input-border';
return [
'w-[80px] mx-auto text-center font-medium',
'bg-transparent text-text-primary placeholder:text-text-quaternary',
'rounded-lg border shadow-none',
border,
'hover:bg-card-background',
'focus-visible:bg-tertiary focus-visible:border-transparent',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'disabled:cursor-wait disabled:opacity-70',
].join(' ');
});
</script>
<template>
<div
data-testid="timesheet_cell"
class="flex items-center justify-center border-t border-default-background-separator"
:class="{ 'bg-default-background': isToday }">
<TooltipProvider v-if="hasRunningEntry" :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-block cursor-not-allowed">
<DurationSecondsInput
:model-value="cell?.totalSeconds ?? 0"
disabled
default-unit="hours"
placeholder="-"
size="sm"
input-class="w-[80px] mx-auto text-center font-medium
bg-transparent text-text-primary placeholder:text-text-quaternary
rounded-lg border border-input-border shadow-none
pointer-events-none
disabled:opacity-50 disabled:cursor-not-allowed" />
</span>
</TooltipTrigger>
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip>
</TooltipProvider>
<template v-else>
<span class="relative inline-flex items-center">
<DurationSecondsInput
:model-value="displaySeconds"
default-unit="hours"
placeholder="-"
size="sm"
:disabled="isSaving"
:input-class="inputClass"
@commit="(seconds) => emit('update', seconds ?? 0)" />
<span
v-if="saveStatus === 'saving' || saveStatus === 'saved'"
class="pointer-events-none absolute left-full top-1/2 ml-1.5 flex -translate-y-1/2 items-center"
:aria-label="saveStatus === 'saving' ? 'Saving' : 'Saved'">
<LoadingSpinner
v-if="saveStatus === 'saving'"
class="h-3 w-3 m-0 text-text-tertiary" />
<CheckIcon v-else class="h-3 w-3 text-text-tertiary" />
</span>
</span>
</template>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { Button } from '@/packages/ui/src/Buttons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/packages/ui/src/dropdown-menu';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { ChevronDownIcon, ClockIcon, ListBulletIcon } from '@heroicons/vue/20/solid';
defineProps<{
busy: boolean;
}>();
defineEmits<{
(e: 'copy-rows'): void;
(e: 'copy-with-time'): void;
}>();
</script>
<template>
<div class="mt-2 flex items-center pl-4 pr-4">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" :disabled="busy">
<LoadingSpinner v-if="busy" class="h-3.5 w-3.5 m-0" />
Copy last week
<ChevronDownIcon v-if="!busy" class="h-3.5 w-3.5 ml-1 text-icon-default" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="min-w-[220px]">
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="$emit('copy-rows')">
<ListBulletIcon class="w-5 text-icon-default" />
<span>Copy rows only</span>
</DropdownMenuItem>
<DropdownMenuItem
class="flex items-center space-x-3 cursor-pointer"
@click="$emit('copy-with-time')">
<ClockIcon class="w-5 text-icon-default" />
<span>Copy rows and time entries</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { inject, type ComputedRef } from 'vue';
import { Button } from '@/packages/ui/src/Buttons';
import { PlusIcon } from '@heroicons/vue/20/solid';
import TimesheetRow from '@/Components/Timesheet/TimesheetRow.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import type {
Client,
CreateClientBody,
CreateProjectBody,
Organization,
Project,
Tag,
Task,
} from '@/packages/api/src';
import type { TimesheetRow as TimesheetRowType, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
const organization = inject<ComputedRef<Organization>>('organization');
const dayjs = getDayJsInstance();
defineProps<{
rows: TimesheetRowType[];
weekDays: string[];
todayDate: string;
dayTotals: number[];
weekTotalFormatted: string;
projects: Project[];
tasks: Task[];
clients: Client[];
tags: Tag[];
currency: string;
canCreateProject: boolean;
enableEstimatedTime: boolean;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
(e: 'remove-row', key: TimesheetRowKey): void;
(e: 'cell-update', row: TimesheetRowType, dayIndex: number, seconds: number): void;
(
e: 'project-task-change',
row: TimesheetRowType,
projectId: string | null,
taskId: string | null
): void;
(e: 'billable-change', row: TimesheetRowType, billable: boolean): void;
(e: 'tags-change', row: TimesheetRowType, tags: string[]): void;
(e: 'add-row', projectId: string | null, taskId: string | null): void;
}>();
</script>
<template>
<div class="flow-root max-w-[100vw] overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div
class="grid min-w-full w-max border-y border-default-background-separator"
style="
grid-template-columns:
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto)
40px;
">
<!-- Header row -->
<div
class="bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
Project
</div>
<div
v-for="day in weekDays"
:key="day"
class="bg-background dark:bg-secondary px-2 py-1 text-center">
<div class="text-xs font-medium text-text-secondary">
{{ dayjs(day).format('ddd D') }}
</div>
</div>
<div
class="bg-background dark:bg-secondary pl-3 pr-3 py-1 text-right text-xs text-text-tertiary">
Total
</div>
<div class="bg-background dark:bg-secondary"></div>
<!-- Data rows -->
<TimesheetRow
v-for="row in rows"
:key="row.key"
:row="row"
:week-days="weekDays"
:today-date="todayDate"
:projects="projects"
:tasks="tasks"
:clients="clients"
:tags="tags"
:currency="currency"
:can-create-project="canCreateProject"
:enable-estimated-time="enableEstimatedTime"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatuses"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="$emit('remove-row', $event)"
@cell-update="
(dayIndex, seconds) => $emit('cell-update', row, dayIndex, seconds)
"
@project-task-change="(pId, tId) => $emit('project-task-change', row, pId, tId)"
@billable-change="(billable) => $emit('billable-change', row, billable)"
@tags-change="(t) => $emit('tags-change', row, t)" />
<!-- Add row -->
<div
class="col-span-full flex items-center gap-2 border-t border-default-background-separator pl-4 pr-4 py-2">
<TimeTrackerProjectTaskDropdown
:project="null"
:task="null"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject"
:enable-estimated-time="enableEstimatedTime"
:create-project="createProject"
:create-client="createClient"
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
align="start"
@changed="(p, t) => emit('add-row', p, t)">
<template #trigger>
<Button variant="ghost" size="sm" class="text-text-secondary">
<PlusIcon class="h-4 w-4 mr-1 text-icon-default" />
Add row
</Button>
</template>
</TimeTrackerProjectTaskDropdown>
</div>
<!-- Totals row -->
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary pl-7 pr-3 py-1 text-xs text-text-tertiary md:sticky md:left-0 md:z-10">
Total
</div>
<div
v-for="(total, dayIndex) in dayTotals"
:key="dayIndex"
data-testid="timesheet_day_total"
:class="[
'flex items-center justify-center border-t border-default-background-separator bg-background dark:bg-secondary px-2 py-1 text-xs font-medium',
weekDays[dayIndex] === todayDate
? 'text-text-primary'
: 'text-text-secondary',
]">
<span class="w-[80px] text-center">
{{ total > 0 ? formatDuration(total) : '-' }}
</span>
</div>
<div
class="flex items-center justify-end border-t border-default-background-separator bg-background dark:bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary">
{{ weekTotalFormatted }}
</div>
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary"></div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { Button } from '@/packages/ui/src/Buttons';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{
isCurrentWeek: boolean;
weekNumber: number;
weekRangeDisplay: string;
weekTotalFormatted: string;
}>();
defineEmits<{
(e: 'previous'): void;
(e: 'next'): void;
(e: 'current'): void;
}>();
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
<!-- Left: Week navigation -->
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_prev_week"
@click="$emit('previous')">
<ChevronLeftIcon class="h-4 w-4" />
</Button>
<button
data-testid="timesheet_week_display"
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
@click="$emit('current')">
<CalendarIcon class="h-4 w-4 text-icon-default" />
<span v-if="isCurrentWeek">This week</span>
<span v-else>{{ weekRangeDisplay }}</span>
<span class="text-text-tertiary">&middot; W{{ weekNumber }}</span>
</button>
<Button
variant="outline"
size="icon"
class="h-8 w-8"
data-testid="timesheet_next_week"
@click="$emit('next')">
<ChevronRightIcon class="h-4 w-4" />
</Button>
</div>
<!-- Right: Week total -->
<div class="flex items-center gap-2.5">
<span class="text-xs text-text-tertiary uppercase tracking-wider">Week Total</span>
<span
data-testid="timesheet_grand_total"
class="text-sm font-semibold text-text-primary">
{{ weekTotalFormatted }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, inject, type ComputedRef } from 'vue';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import TimesheetCell from './TimesheetCell.vue';
import TimeTrackerProjectTaskDropdown from '@/packages/ui/src/TimeTracker/TimeTrackerProjectTaskDropdown.vue';
import TimeEntryRowTagDropdown from '@/packages/ui/src/TimeEntry/TimeEntryRowTagDropdown.vue';
import BillableToggleButton from '@/packages/ui/src/Input/BillableToggleButton.vue';
import type {
CreateClientBody,
CreateProjectBody,
Project,
Task,
Client,
Tag,
Organization,
} from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import {
makeCellStatusKey,
type CellSaveStatus,
} from '@/utils/timesheet/useTimesheetCellMutations';
import { Button } from '@/packages/ui/src/Buttons';
const organization = inject<ComputedRef<Organization>>('organization');
const props = defineProps<{
row: TimesheetRow;
weekDays: string[];
todayDate: string;
projects: Project[];
tasks: Task[];
clients: Client[];
tags: Tag[];
currency: string;
canCreateProject: boolean;
enableEstimatedTime: boolean;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
createClient: (client: CreateClientBody) => Promise<Client | undefined>;
createTag: (name: string) => Promise<Tag | undefined>;
formatDuration: (seconds: number) => string;
cellStatuses: Record<string, CellSaveStatus>;
cellPendingSeconds: Record<string, number>;
}>();
const emit = defineEmits<{
removeRow: [key: TimesheetRowKey];
cellUpdate: [dayIndex: number, newSeconds: number];
projectTaskChange: [projectId: string | null, taskId: string | null];
billableChange: [billable: boolean];
tagsChange: [tags: string[]];
}>();
const selectedProject = computed({
get: () => props.row.projectId,
set: (val) => emit('projectTaskChange', val, selectedTask.value),
});
const selectedTask = computed({
get: () => props.row.taskId,
set: (val) => emit('projectTaskChange', selectedProject.value, val),
});
const rowTotalFormatted = computed(() => props.formatDuration(props.row.totalSeconds));
function hasRunningEntry(dayIndex: number): boolean {
const cell = props.row.cells.get(dayIndex);
if (!cell) return false;
return cell.entries.some((e) => e.end === null);
}
</script>
<template>
<div data-testid="timesheet_row" class="contents group">
<!-- Project/Task column -->
<div
class="flex items-center gap-1 border-t border-default-background-separator bg-default-background pl-4 pr-3 py-2 md:sticky md:left-0 md:z-10">
<div class="flex-1 min-w-0">
<TimeTrackerProjectTaskDropdown
v-model:project="selectedProject"
v-model:task="selectedTask"
:projects="projects"
:tasks="tasks"
:clients="clients"
:currency="currency"
:can-create-project="canCreateProject"
:enable-estimated-time="enableEstimatedTime"
:create-project="createProject"
:create-client="createClient"
:organization-billable-rate="organization?.billable_rate ?? null"
:no-project-value="null"
variant="ghost"
size="sm"
class="w-full" />
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<TimeEntryRowTagDropdown
:create-tag="createTag"
:tags="tags"
:model-value="row.tags"
@changed="emit('tagsChange', $event)" />
<BillableToggleButton
:model-value="row.billable"
size="small"
faded
@changed="emit('billableChange', $event)" />
</div>
</div>
<!-- Day cells -->
<TimesheetCell
v-for="(day, dayIndex) in weekDays"
:key="day"
:cell="row.cells.get(dayIndex)"
:day-index="dayIndex"
:date="day"
:is-today="day === todayDate"
:has-running-entry="hasRunningEntry(dayIndex)"
:save-status="cellStatuses[makeCellStatusKey(row.key, dayIndex)]"
:pending-seconds="cellPendingSeconds[makeCellStatusKey(row.key, dayIndex)]"
@update="(seconds) => emit('cellUpdate', dayIndex, seconds)" />
<!-- Row total -->
<div
data-testid="timesheet_row_total"
class="flex items-center justify-end border-t border-default-background-separator pl-3 pr-3 py-3 text-sm font-medium text-text-primary">
{{ rowTotalFormatted }}
</div>
<!-- Remove action -->
<div
class="flex items-center justify-center border-t border-default-background-separator pr-4 py-3">
<Button
variant="ghost"
size="icon"
aria-label="Remove row"
class="h-6 w-6 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
@click="emit('removeRow', row.key)">
<XMarkIcon class="h-3.5 w-3.5 text-icon-default" />
</Button>
</div>
</div>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>();
const delegatedProps = computed(() => {
@@ -13,7 +13,7 @@ const delegatedProps = computed(() => {
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="twMerge(buttonVariants(), props.class)">
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { buttonVariants } from '@/packages/ui/src';
import { cn } from '@/lib/utils';
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>();
@@ -16,7 +16,7 @@ const delegatedProps = computed(() => {
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="twMerge(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
:class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>

View File

@@ -17,6 +17,7 @@ import {
UserGroupIcon,
XMarkIcon,
DocumentTextIcon,
TableCellsIcon,
} from '@heroicons/vue/20/solid';
import { PanelLeft } from 'lucide-vue-next';
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
@@ -135,7 +136,7 @@ const page = usePage<{
? 'max-lg:translate-x-0 max-lg:shadow-xl'
: 'max-lg:-translate-x-full',
]"
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] 2xl:w-[250px] 2xl:px-3 lg:border-r-0"
class="flex-shrink-0 h-screen fixed w-[280px] px-2.5 py-4 hidden lg:flex flex-col justify-between bg-background border-r border-default-background-separator max-lg:z-50 max-lg:transition-transform max-lg:duration-200 max-lg:ease-in-out lg:w-[230px] lg:border-r-0"
:style="showSidebarMenu ? { display: 'flex' } : undefined">
<div class="flex flex-col h-full">
<div
@@ -185,6 +186,11 @@ const page = usePage<{
:icon="CalendarIcon"
:current="route().current('calendar')"
:href="route('calendar')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Timesheet"
:icon="TableCellsIcon"
:current="route().current('timesheet')"
:href="route('timesheet')"></NavigationSidebarItem>
<NavigationSidebarItem
title="Reporting"
:icon="ChartBarIcon"
@@ -287,7 +293,7 @@ const page = usePage<{
<div class="justify-self-end">
<UpdateSidebarNotification></UpdateSidebarNotification>
<ul
class="border-t border-default-background-separator pt-3 gap-1 pr-2 flex justify-between items-center">
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
<UserSettingsIcon></UserSettingsIcon>
<NavigationSidebarItem
@@ -308,7 +314,7 @@ const page = usePage<{
</div>
</div>
</div>
<div class="flex-1 lg:ml-[230px] 2xl:ml-[250px] min-w-0">
<div class="flex-1 lg:ml-[230px] min-w-0">
<div
class="h-screen overflow-y-auto flex flex-col bg-default-background border-l border-default-background-separator">
<div

View File

@@ -3,6 +3,7 @@ import AppLayout from '@/Layouts/AppLayout.vue';
import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { computed, ref, onMounted } from 'vue';
import type { Dayjs } from 'dayjs';
import { useQueryClient } from '@tanstack/vue-query';
import {
type Client,
@@ -27,8 +28,8 @@ import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { getCurrentOrganizationId } from '@/utils/useUser';
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const calendarStart = ref<Date | undefined>(undefined);
const calendarEnd = ref<Date | undefined>(undefined);
const calendarStart = ref<Dayjs | undefined>(undefined);
const calendarEnd = ref<Dayjs | undefined>(undefined);
// Test-injectable activity periods (for E2E testing).
// These hooks are no-ops in production — they only take effect when test code
@@ -99,7 +100,7 @@ const { tags } = useTagsQuery();
const queryClient = useQueryClient();
function onDatesChange({ start, end }: { start: Date; end: Date }) {
function onDatesChange({ start, end }: { start: Dayjs; end: Dayjs }) {
calendarStart.value = start;
calendarEnd.value = end;
}

View File

@@ -109,7 +109,7 @@ const shownTasks = computed(() => {
</div>
</li>
</ol>
<div class="px-4">
<div class="px-4 space-x-1">
<Badge v-if="project?.billable_rate">
{{ billableRateFormatted }}
/ h
@@ -118,6 +118,7 @@ const shownTasks = computed(() => {
Default Rate
</Badge>
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
<Badge>{{ project?.is_public ? 'Public' : 'Private' }}</Badge>
</div>
</nav>
<div>

View File

@@ -20,6 +20,7 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useStorage } from '@vueuse/core';
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue';
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
@@ -36,6 +37,7 @@ interface ProjectTableState {
filters: {
clientIds: string[];
status: 'active' | 'archived' | 'all';
visibility: 'public' | 'private' | 'all';
};
}
@@ -47,10 +49,17 @@ const tableState = useStorage<ProjectTableState>(
filters: {
clientIds: [],
status: 'all',
visibility: 'all',
},
},
undefined,
{ mergeDefaults: true }
{
mergeDefaults: (storage, defaults) => ({
...defaults,
...storage,
filters: { ...defaults.filters, ...storage.filters },
}),
}
);
function handleSort(column: SortColumn, direction: SortDirection) {
@@ -69,6 +78,14 @@ const filteredProjects = computed(() => {
return false;
}
// Visibility filter
if (tableState.value.filters.visibility === 'public' && !project.is_public) {
return false;
}
if (tableState.value.filters.visibility === 'private' && project.is_public) {
return false;
}
// Client filter
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
if (hasClientFilter) {
@@ -91,6 +108,10 @@ function removeStatusFilter() {
tableState.value.filters.status = 'all';
}
function removeVisibilityFilter() {
tableState.value.filters.visibility = 'all';
}
function removeClientFilter() {
tableState.value.filters.clientIds = [];
}
@@ -152,6 +173,15 @@ const showBillableRate = computed(() => {
tableState.filters.status = $event as 'active' | 'archived' | 'all'
" />
<ProjectVisibilityFilterBadge
v-if="tableState.filters.visibility !== 'all'"
data-testid="visibility-filter-badge"
:value="tableState.filters.visibility"
@remove="removeVisibilityFilter"
@update:value="
tableState.filters.visibility = $event as 'public' | 'private' | 'all'
" />
<ProjectClientFilterBadge
v-if="tableState.filters.clientIds.length > 0"
data-testid="client-filter-badge"

View File

@@ -1,448 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { router, useForm, usePage } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import ActionSection from '@/Components/ActionSection.vue';
import ConfirmationModal from '@/Components/ConfirmationModal.vue';
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import SectionBorder from '@/Components/SectionBorder.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization, OrganizationInvitation, User } from '@/types/models';
import type { Membership, Permissions, Role } from '@/types/jetstream';
import { filterRoles } from '@/utils/roles';
type UserWithMembership = User & { membership: Membership };
const props = defineProps<{
team: Organization;
availableRoles: Role[];
userPermissions: Permissions;
}>();
const users = computed(() => {
return props.team.users as Array<UserWithMembership>;
});
const page = usePage<{
auth: {
user: User;
};
}>();
const addTeamMemberForm = useForm({
email: '',
role: null as string | null,
});
const updateRoleForm = useForm({
role: null as string | null,
});
const leaveTeamForm = useForm({});
const removeTeamMemberForm = useForm({});
const currentlyManagingRole = ref(false);
const managingRoleFor = ref<User | null>(null);
const confirmingLeavingTeam = ref(false);
const teamMemberBeingRemoved = ref<User | null>(null);
const addTeamMember = () => {
addTeamMemberForm.post(route('team-members.store', props.team.id), {
errorBag: 'addTeamMember',
preserveScroll: true,
onSuccess: () => addTeamMemberForm.reset(),
});
};
const cancelTeamInvitation = (invitation: OrganizationInvitation) => {
router.delete(route('team-invitations.destroy', invitation.id), {
preserveScroll: true,
});
};
const manageRole = (teamMember: User & { membership: Membership }) => {
managingRoleFor.value = teamMember;
updateRoleForm.role = teamMember.membership.role;
currentlyManagingRole.value = true;
};
const updateRole = () => {
updateRoleForm.put(
route('team-members.update', {
team: props.team.id,
user: managingRoleFor.value?.id,
}),
{
preserveScroll: true,
onSuccess: () => (currentlyManagingRole.value = false),
}
);
};
const confirmLeavingTeam = () => {
confirmingLeavingTeam.value = true;
};
const leaveTeam = () => {
leaveTeamForm.delete(route('team-members.destroy', [props.team.id, page.props.auth.user.id]));
};
const confirmTeamMemberRemoval = (teamMember: User) => {
teamMemberBeingRemoved.value = teamMember;
};
const removeTeamMember = () => {
removeTeamMemberForm.delete(
route('team-members.destroy', {
team: props.team.id,
user: teamMemberBeingRemoved.value?.id,
}),
{
errorBag: 'removeTeamMember',
preserveScroll: true,
preserveState: true,
onSuccess: () => (teamMemberBeingRemoved.value = null),
}
);
};
const displayableRole = (role: string) => {
return props.availableRoles.find((r) => r.key === role)?.name;
};
</script>
<template>
<div>
<div v-if="userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Add Organization Member -->
<FormSection @submitted="addTeamMember">
<template #title> Add Organization Member</template>
<template #description>
Add a new member to your organization, allowing them to collaborate with you.
</template>
<template #form>
<div class="col-span-6">
<div class="max-w-xl text-sm text-muted">
Please provide the email address of the person you would like to add to
this organization.
</div>
</div>
<!-- Member Email -->
<Field class="col-span-6 sm:col-span-4">
<FieldLabel for="email">Email</FieldLabel>
<TextInput
id="email"
v-model="addTeamMemberForm.email"
type="email"
class="block w-full" />
<FieldError v-if="addTeamMemberForm.errors.email">{{
addTeamMemberForm.errors.email
}}</FieldError>
</Field>
<!-- Role -->
<div v-if="availableRoles.length > 0" class="col-span-6 lg:col-span-4">
<FieldLabel for="roles">Role</FieldLabel>
<FieldError v-if="addTeamMemberForm.errors.role">{{
addTeamMemberForm.errors.role
}}</FieldError>
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in filterRoles(availableRoles)"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i != Object.keys(availableRoles).length - 1,
}"
@click="addTeamMemberForm.role = role.key">
<div
:class="{
'opacity-50':
addTeamMemberForm.role &&
addTeamMemberForm.role != role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-text-primary"
:class="{
'font-semibold': addTeamMemberForm.role == role.key,
}">
{{ role.name }}
</div>
<svg
v-if="addTeamMemberForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted text-start">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #actions>
<ActionMessage :on="addTeamMemberForm.recentlySuccessful" class="me-3">
Added.
</ActionMessage>
<PrimaryButton
:class="{ 'opacity-25': addTeamMemberForm.processing }"
:disabled="addTeamMemberForm.processing">
Add
</PrimaryButton>
</template>
</FormSection>
</div>
<div v-if="team.team_invitations.length > 0 && userPermissions.canAddTeamMembers">
<SectionBorder />
<!-- Organization Member Invitations -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Pending Organization Invitations</template>
<template #description>
These people have been invited to your organization and have been sent an
invitation email. They may join the organization by accepting the email
invitation.
</template>
<!-- Pending Organization Member Invitation List -->
<template #content>
<div class="space-y-6">
<div
v-for="invitation in team.team_invitations"
:key="invitation.id"
class="flex items-center justify-between">
<div class="text-muted">
{{ invitation.email }}
</div>
<div class="flex items-center">
<!-- Cancel Organization Invitation -->
<button
v-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500 focus:outline-none"
@click="cancelTeamInvitation(invitation)">
Cancel
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<div v-if="users.length > 0">
<SectionBorder />
<!-- Manage Organization Members -->
<ActionSection class="mt-10 sm:mt-0">
<template #title> Organization Members</template>
<template #description>
All of the people that are part of this organization.
</template>
<!-- Organization Member List -->
<template #content>
<div class="space-y-6">
<div
v-for="user in users"
:key="user.id"
class="flex items-center justify-between">
<div class="flex items-center">
<img
class="w-8 h-8 rounded-full object-cover"
:src="user.profile_photo_url"
:alt="user.name" />
<div class="ms-4 text-text-primary">
{{ user.name }}
</div>
</div>
<div class="flex items-center">
<!-- Manage Organization Member Role -->
<button
v-if="
userPermissions.canUpdateTeamMembers &&
availableRoles.length
"
class="ms-2 text-sm text-gray-400 underline"
@click="manageRole(user)">
{{ displayableRole(user.membership.role) }}
</button>
<div
v-else-if="availableRoles.length"
class="ms-2 text-sm text-gray-400">
{{ displayableRole(user.membership.role) }}
</div>
<!-- Leave Organization -->
<button
v-if="page.props.auth.user.id === user.id"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmLeavingTeam">
Leave
</button>
<!-- Remove Organization Member -->
<button
v-else-if="userPermissions.canRemoveTeamMembers"
class="cursor-pointer ms-6 text-sm text-red-500"
@click="confirmTeamMemberRemoval(user)">
Remove
</button>
</div>
</div>
</div>
</template>
</ActionSection>
</div>
<!-- Role Management Modal -->
<DialogModal :show="currentlyManagingRole" @close="currentlyManagingRole = false">
<template #title> Manage Role</template>
<template #content>
<div v-if="managingRoleFor">
<div
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
<button
v-for="(role, i) in availableRoles"
:key="role.key"
type="button"
class="relative px-4 py-3 inline-flex w-full rounded-lg focus:z-10 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="{
'border-t border-card-border focus:border-none rounded-t-none':
i > 0,
'rounded-b-none': i !== Object.keys(availableRoles).length - 1,
}"
@click="updateRoleForm.role = role.key">
<div
:class="{
'opacity-50':
updateRoleForm.role && updateRoleForm.role !== role.key,
}">
<!-- Role Name -->
<div class="flex items-center">
<div
class="text-sm text-muted"
:class="{
'font-semibold': updateRoleForm.role === role.key,
}">
{{ role.name }}
</div>
<svg
v-if="updateRoleForm.role == role.key"
class="ms-2 h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<!-- Role Description -->
<div class="mt-2 text-xs text-muted">
{{ role.description }}
</div>
</div>
</button>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="currentlyManagingRole = false"> Cancel </SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': updateRoleForm.processing }"
:disabled="updateRoleForm.processing"
@click="updateRole">
Save
</PrimaryButton>
</template>
</DialogModal>
<!-- Leave Organization Confirmation Modal -->
<ConfirmationModal :show="confirmingLeavingTeam" @close="confirmingLeavingTeam = false">
<template #title> Leave Organization</template>
<template #content> Are you sure you would like to leave this organization? </template>
<template #footer>
<SecondaryButton @click="confirmingLeavingTeam = false"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': leaveTeamForm.processing }"
:disabled="leaveTeamForm.processing"
@click="leaveTeam">
Leave
</DangerButton>
</template>
</ConfirmationModal>
<!-- Remove Organization Member Confirmation Modal -->
<ConfirmationModal :show="!!teamMemberBeingRemoved" @close="teamMemberBeingRemoved = null">
<template #title> Remove Organization Member</template>
<template #content>
Are you sure you would like to remove this person from the organization?
</template>
<template #footer>
<SecondaryButton @click="teamMemberBeingRemoved = null"> Cancel </SecondaryButton>
<DangerButton
class="ms-3"
:class="{ 'opacity-25': removeTeamMemberForm.processing }"
:disabled="removeTeamMemberForm.processing"
@click="removeTeamMember">
Remove
</DangerButton>
</template>
</ConfirmationModal>
</div>
</template>

View File

@@ -51,9 +51,6 @@ const updateTeamName = () => {
<div class="text-text-primary">
{{ team.owner.name }}
</div>
<div class="text-text-secondary text-sm">
{{ team.owner.email }}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { storeToRefs } from 'pinia';
import AppLayout from '@/Layouts/AppLayout.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import TimesheetHeader from '@/Components/Timesheet/TimesheetHeader.vue';
import TimesheetGrid from '@/Components/Timesheet/TimesheetGrid.vue';
import TimesheetFooterActions from '@/Components/Timesheet/TimesheetFooterActions.vue';
import RemoveRowDialog from '@/Components/Timesheet/RemoveRowDialog.vue';
import { useTimesheetQuery } from '@/utils/useTimesheetQuery';
import { useTimesheetGrid } from '@/utils/useTimesheetGrid';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useTagsStore } from '@/utils/useTags';
import { getCurrentOrganizationId } from '@/utils/useUser';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { canCreateProjects } from '@/utils/permissions';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
import { useTimesheetRowDeletion } from '@/utils/timesheet/useTimesheetRowDeletion';
import { useCopyLastWeek } from '@/utils/timesheet/useCopyLastWeek';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import type { CreateClientBody, CreateProjectBody, Project, Client, Tag } from '@/packages/api/src';
// ── Week state ────────────────────────────────────────────────────
const {
weekStart,
weekEnd,
weekDays,
weekNumber,
isCurrentWeek,
todayDate,
goToPreviousWeek,
goToNextWeek,
goToCurrentWeek,
} = useTimesheetWeek();
// ── Data fetching ─────────────────────────────────────────────────
const { data, isPending } = useTimesheetQuery(weekStart, weekEnd);
const timeEntries = computed(() => data.value?.data ?? []);
const { projects } = useProjectsQuery();
const { tasks } = useTasksQuery();
const { clients } = useClientsQuery();
const { tags } = useTagsQuery();
const { now: currentTimerNow } = storeToRefs(useCurrentTimeEntryStore());
const mutations = useTimeEntriesMutations();
// ── Grid computation ──────────────────────────────────────────────
const { rows, dayTotals, grandTotal, addSlot, removeSlot, updateSlot, clearSlots } =
useTimesheetGrid(timeEntries, weekDays, projects, tasks, currentTimerNow);
// Wipe slots on week navigation so the new week starts fresh — the
// grid's watcher will reseed from the newly fetched entries.
watch(weekStart, () => clearSlots());
// ── Formatters ────────────────────────────────────────────────────
// Pull number/interval format off the org via its query rather than
// inject('organization'), which is undefined during the page's setup
// (AppLayout provides it later in the lifecycle).
const { organization } = useOrganizationQuery(getCurrentOrganizationId()!);
const intervalFormat = computed(() => organization.value?.interval_format ?? 'hours-minutes');
const numberFormat = computed(() => organization.value?.number_format ?? 'point');
function formatDuration(seconds: number): string {
if (seconds === 0) return '-';
return formatHumanReadableDuration(seconds, intervalFormat.value, numberFormat.value);
}
const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
);
const weekRangeDisplay = computed(() => {
const start = weekStart.value;
const end = start.add(6, 'day');
return start.month() === end.month()
? `${start.format('MMM D')} - ${end.format('D')}`
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
weekDays,
timeEntries,
rows,
removeSlot
);
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations,
projects,
rows,
addSlot,
updateSlot,
removeSlot
);
const {
showDeleteDialog,
deleteRowEntryCount,
deleteRowProjectName,
requestRemoveRow,
confirmDeleteRow,
} = useTimesheetRowDeletion(projects, mutations, removeSlot);
function handleRemoveRow(key: string) {
const row = rows.value.find((r) => r.key === key);
if (row) requestRemoveRow(row);
}
// ── Copy last week ────────────────────────────────────────────────
const { isCopyingLastWeek, copyLastWeekRows, copyLastWeekWithTime } = useCopyLastWeek(
weekStart,
weekDays,
rows,
timeEntries,
addSlot
);
// ── Inline creation helpers (passed to TimesheetRow) ──────────────
async function createProject(project: CreateProjectBody): Promise<Project | undefined> {
return await useProjectsStore().createProject(project);
}
async function createClient(body: CreateClientBody): Promise<Client | undefined> {
return await useClientsStore().createClient(body);
}
async function createTag(name: string): Promise<Tag | undefined> {
return await useTagsStore().createTag(name);
}
</script>
<template>
<AppLayout title="Timesheet" data-testid="timesheet_view">
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimesheetHeader
:is-current-week="isCurrentWeek"
:week-number="weekNumber"
:week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek"
@next="goToNextWeek"
@current="goToCurrentWeek" />
<TimesheetGrid
v-if="!isPending"
:rows="rows"
:week-days="weekDays"
:today-date="todayDate"
:day-totals="dayTotals"
:week-total-formatted="weekTotalFormatted"
:projects="projects"
:tasks="tasks"
:clients="clients"
:tags="tags"
:currency="getOrganizationCurrencyString()"
:can-create-project="canCreateProjects()"
:enable-estimated-time="isAllowedToPerformPremiumAction()"
:create-project="createProject"
:create-client="createClient"
:create-tag="createTag"
:format-duration="formatDuration"
:cell-statuses="cellStatus"
:cell-pending-seconds="cellPendingSeconds"
@remove-row="handleRemoveRow"
@cell-update="handleCellUpdate"
@project-task-change="
(row, projectId, taskId) => handleRowIdentityChange(row, { projectId, taskId })
"
@billable-change="(row, billable) => handleRowIdentityChange(row, { billable })"
@tags-change="(row, tags) => handleRowIdentityChange(row, { tags })"
@add-row="handleAddRow" />
<TimesheetFooterActions
v-if="!isPending"
:busy="isCopyingLastWeek"
@copy-rows="copyLastWeekRows"
@copy-with-time="copyLastWeekWithTime" />
<div v-else class="flex justify-center items-center py-12">
<LoadingSpinner />
</div>
</div>
<RemoveRowDialog
v-model:open="showDeleteDialog"
:entry-count="deleteRowEntryCount"
:project-name="deleteRowProjectName"
@confirm="confirmDeleteRow" />
</AppLayout>
</template>

View File

@@ -114,6 +114,8 @@ export type ApiToken = ApiTokenIndexResponse['data'][0];
export type DetailedInvoiceResponse = ZodiosResponseByAlias<SolidTimeApi, 'getInvoice'>;
export type DetailedInvoice = DetailedInvoiceResponse['data'];
export type InvoiceIndexEntry = ZodiosResponseByAlias<SolidTimeApi, 'getInvoices'>['data'][0];
export type UpdateInvoiceSettings = ZodiosBodyByAlias<SolidTimeApi, 'updateInvoiceSettings'>;

View File

@@ -1886,6 +1886,54 @@ const endpoints = makeApi([
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/invoices/:invoice/copy',
alias: 'copyInvoice',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z.object({ reference: z.string() }).passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'invoice',
type: 'Path',
schema: z.string(),
},
],
response: z.object({ data: DetailedInvoiceResource }).passthrough(),
errors: [
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({ message: z.string(), errors: z.record(z.array(z.string())) })
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/invoices/:invoice',

View File

@@ -57,7 +57,7 @@ import type {
import type { Dayjs } from 'dayjs';
const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void;
(e: 'dates-change', payload: { start: Dayjs; end: Dayjs }): void;
(e: 'refresh'): void;
}>();

View File

@@ -265,9 +265,9 @@ export function useCalendarEvents(params: {
'seconds'
);
} else {
durationSeconds = params.currentTime.value.diff(
getDayJsInstance()(entry.start),
'seconds'
durationSeconds = Math.max(
0,
params.currentTime.value.diff(getDayJsInstance()(entry.start), 'seconds')
);
}

View File

@@ -1,27 +1,17 @@
import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
import { getWeekStart } from '../utils/settings';
import { getWeekStartDayNumber } from '../utils/settings';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Date; end: Date }) => void;
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
scrollToCurrentTime: () => void;
}) {
const activeView = ref('timeGridWeek');
const currentDate = ref(getLocalizedDayJs());
function getFirstDay(): number {
const weekStart = getWeekStart();
const weekStartMap: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
return weekStartMap[weekStart] ?? 1;
return getWeekStartDayNumber();
}
const viewDays = computed<Dayjs[]>(() => {
@@ -67,8 +57,8 @@ export function useCalendarNavigation(callbacks: {
const days = viewDays.value;
if (days.length === 0) return;
const start = days[0]!.toDate();
const end = days[days.length - 1]!.add(1, 'day').toDate();
const start = days[0]!;
const end = days[days.length - 1]!.add(1, 'day');
callbacks.onDatesChange({ start, end });
}

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, inject, ref, type ComputedRef } from 'vue';
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import type { Organization } from '@/packages/api/src';
const organization = inject<ComputedRef<Organization>>('organization');
const organizationSettings = computed(() => ({
intervalFormat: organization?.value?.interval_format ?? 'hours-minutes',
numberFormat: organization?.value?.number_format ?? 'point',
}));
const props = withDefaults(
defineProps<{
modelValue?: number | null;
placeholder?: string;
disabled?: boolean;
inputClass?: string;
size?: 'sm' | 'base';
defaultUnit?: 'auto' | 'hours' | 'minutes';
}>(),
{
modelValue: null,
placeholder: '-',
disabled: false,
inputClass: '',
size: 'base',
defaultUnit: 'auto',
}
);
const emit = defineEmits<{
'update:modelValue': [value: number | null];
commit: [value: number | null];
submit: [];
}>();
const temporaryValue = ref('');
const isEditing = ref(false);
const hasPendingEdit = ref(false);
const skipNextCommit = ref(false);
function formatModelValue(value: number | null | undefined): string {
if (!value || value === 0) {
return '';
}
return formatHumanReadableDuration(
value,
organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat
);
}
const displayValue = computed({
get() {
if (isEditing.value) {
return temporaryValue.value;
}
return formatModelValue(props.modelValue);
},
set(newValue: string) {
temporaryValue.value = newValue;
hasPendingEdit.value = true;
},
});
function selectInput(event: Event) {
isEditing.value = true;
hasPendingEdit.value = false;
skipNextCommit.value = false;
temporaryValue.value = formatModelValue(props.modelValue);
const target = event.target as HTMLInputElement;
target.select();
}
function resetEditingState() {
temporaryValue.value = '';
isEditing.value = false;
hasPendingEdit.value = false;
}
function commitValue() {
if (skipNextCommit.value) {
skipNextCommit.value = false;
return;
}
const input = temporaryValue.value.trim();
const shouldCommit = hasPendingEdit.value;
resetEditingState();
if (!shouldCommit) {
return;
}
// Blank or literal "0" → null. Consumers decide what null means
// (clear estimate, delete cell, etc.) by reading their own emit.
if (input === '' || input === '0') {
emit('update:modelValue', null);
emit('commit', null);
return;
}
const defaultUnit =
props.defaultUnit === 'auto'
? organizationSettings.value.intervalFormat === 'decimal'
? 'hours'
: 'minutes'
: props.defaultUnit;
const seconds = parseTimeInput(input, organizationSettings.value.numberFormat, defaultUnit);
if (seconds !== null && seconds >= 0) {
emit('update:modelValue', seconds);
emit('commit', seconds);
}
}
function cancelEdit(event: Event) {
skipNextCommit.value = true;
resetEditingState();
(event.target as HTMLInputElement).blur();
}
function commitAndSubmit() {
commitValue();
emit('submit');
}
</script>
<template>
<TextInput
v-model="displayValue"
data-testid="duration_seconds_input"
name="Duration"
:size="size"
:disabled="disabled"
:placeholder="isEditing ? '0' : placeholder"
:class="inputClass"
@focus="selectInput"
@blur="commitValue"
@keydown.enter.prevent="commitAndSubmit"
@keydown.escape="cancelEdit" />
</template>

View File

@@ -1,12 +1,6 @@
<script setup lang="ts">
import { onMounted, ref, watch, inject } from 'vue';
import { formatHumanReadableDuration, parseTimeInput } from '@/packages/ui/src/utils/time';
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import { twMerge } from 'tailwind-merge';
import { TextInput } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src';
import { type ComputedRef } from 'vue';
const temporaryInput = ref<string>('');
const model = defineModel<number | null>({
default: null,
@@ -16,64 +10,16 @@ const emit = defineEmits<{
submit: [];
}>();
const organization = inject<ComputedRef<Organization>>('organization');
function updateDuration() {
const input = temporaryInput.value.trim();
if (input === '') {
model.value = null;
return;
}
const seconds = parseTimeInput(input, organization?.value?.number_format, 'hours');
if (seconds !== null && seconds > 0) {
model.value = seconds;
}
updateInputDisplay();
}
const props = defineProps<{
class?: string;
}>();
watch(model, updateInputDisplay);
onMounted(() => updateInputDisplay());
function updateInputDisplay() {
if (model.value !== null && model.value > 0) {
temporaryInput.value = formatHumanReadableDuration(
model.value,
organization?.value?.interval_format,
organization?.value?.number_format
);
} else {
temporaryInput.value = '';
}
}
function selectInput(event: Event) {
const target = event.target as HTMLInputElement;
target.select();
}
function updateAndSubmit() {
updateDuration();
emit('submit');
}
</script>
<template>
<TextInput
ref="inputField"
v-model="temporaryInput"
:class="twMerge('text-text-secondary', props.class)"
type="text"
<DurationSecondsInput
v-model="model"
:input-class="twMerge('placeholder:text-text-tertiary', props.class)"
placeholder="e.g. 2h 30m or 1.5"
@focus="selectInput"
@blur="updateDuration"
@keydown.enter="updateAndSubmit" />
default-unit="hours"
@submit="emit('submit')" />
</template>
<style scoped></style>

View File

@@ -35,7 +35,8 @@ watch(open, (isOpen) => {
sortedItems.value = [...props.items].sort((a, b) => {
const aSelected = model.value.includes(props.getKeyFromItem(a)) ? 0 : 1;
const bSelected = model.value.includes(props.getKeyFromItem(b)) ? 0 : 1;
return aSelected - bSelected;
if (aSelected !== bSelected) return aSelected - bSelected;
return props.getNameForItem(a).localeCompare(props.getNameForItem(b));
});
}
});

View File

@@ -1,11 +1,15 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { twMerge } from 'tailwind-merge';
const props = defineProps<{
name?: string;
class?: string;
}>();
const props = withDefaults(
defineProps<{
name?: string;
class?: string;
size?: 'sm' | 'base';
}>(),
{ size: 'base' }
);
const input = ref<HTMLInputElement | null>(null);
@@ -17,6 +21,10 @@ onMounted(() => {
defineExpose({ focus: () => input.value?.focus() });
const model = defineModel();
const sizeClasses = computed(() =>
props.size === 'sm' ? 'h-7 px-2 py-0.5 text-xs' : 'h-9 px-3 py-1 text-base sm:text-sm'
);
</script>
<template>
@@ -25,7 +33,8 @@ const model = defineModel();
v-model="model"
:class="
twMerge(
'h-9 px-3 py-1 text-base sm:text-sm border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
'border-input-border border bg-input-background text-text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent rounded-md shadow-sm',
sizeClasses,
props.class
)
"

View File

@@ -15,6 +15,7 @@ import { UserCircleIcon } from '@heroicons/vue/20/solid';
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
import { Field, FieldGroup, FieldLabel } from '../field';
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
import type { Client } from '@/packages/api/src';
const show = defineModel('show', { default: false });
@@ -41,6 +42,7 @@ const project = ref<CreateProjectBody>({
billable_rate: null,
is_billable: false,
estimated_time: null,
is_public: false,
});
async function submit() {
@@ -53,6 +55,7 @@ async function submit() {
billable_rate: null,
is_billable: false,
estimated_time: null,
is_public: false,
};
}
@@ -123,6 +126,7 @@ const currentClientName = computed(() => {
v-if="enableEstimatedTime"
v-model="project.estimated_time"
@submit="submit()"></EstimatedTimeSection>
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
</FieldGroup>
</template>
<template #footer>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Field, FieldDescription, FieldLabel } from '../field';
import { GlobeAltIcon } from '@heroicons/vue/20/solid';
const isPublic = defineModel<boolean>({ default: false });
const visibility = computed({
get: () => (isPublic.value ? 'public' : 'private'),
set: (value: string) => {
isPublic.value = value === 'public';
},
});
const description = computed(() =>
isPublic.value
? 'This project is visible to all members of the organization.'
: 'This project is only visible to its project members.'
);
</script>
<template>
<Field>
<FieldLabel :icon="GlobeAltIcon" for="visibility">Visibility</FieldLabel>
<Select v-model="visibility">
<SelectTrigger id="visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private</SelectItem>
<SelectItem value="public">Public</SelectItem>
</SelectContent>
</Select>
<FieldDescription>{{ description }}</FieldDescription>
</Field>
</template>
<style scoped></style>

View File

@@ -0,0 +1,90 @@
/* eslint-disable vue/one-component-per-file */
import { mount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, onMounted } from 'vue';
import TimeTrackerProjectTaskDropdown from './TimeTrackerProjectTaskDropdown.vue';
import type { Client, Project, Task } from '@/packages/api/src';
const DropdownStub = defineComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(_, { emit, slots }) {
onMounted(() => emit('update:modelValue', true));
return () => h('div', [slots.trigger?.(), slots.content?.()]);
},
});
const FocusTrapStub = defineComponent({
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
});
function mountDropdown(props: Record<string, unknown> = {}) {
return mount(TimeTrackerProjectTaskDropdown, {
props: {
project: null,
task: null,
projects: [] as Project[],
tasks: [] as Task[],
clients: [] as Client[],
createProject: vi.fn(),
createClient: vi.fn(),
currency: 'EUR',
enableEstimatedTime: false,
organizationBillableRate: null,
canCreateProject: false,
...props,
},
global: {
stubs: {
Dropdown: DropdownStub,
UseFocusTrap: FocusTrapStub,
},
},
});
}
async function openDropdown() {
const wrapper = mountDropdown();
await nextTick();
await nextTick();
return wrapper;
}
describe('TimeTrackerProjectTaskDropdown', () => {
it('keeps the existing empty-string no-project value by default', async () => {
const wrapper = await openDropdown();
await wrapper.find('[data-project-id=""]').trigger('click');
expect(wrapper.emitted('update:project')?.at(-1)).toEqual(['']);
expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]);
});
it('can emit null for no-project consumers that use null as the domain value', async () => {
const wrapper = mountDropdown({ project: 'p-1', noProjectValue: null });
await nextTick();
await nextTick();
await wrapper.find('[data-project-id=""]').trigger('click');
expect(wrapper.emitted('update:project')?.at(-1)).toEqual([null]);
expect(wrapper.emitted('changed')?.at(-1)).toEqual([null, null]);
});
it('still exposes "No Project" when projects are empty and project creation is allowed', async () => {
const wrapper = mountDropdown({ canCreateProject: true });
await nextTick();
await nextTick();
await wrapper.find('[data-project-id=""]').trigger('click');
expect(wrapper.emitted('changed')?.at(-1)).toEqual(['', null]);
});
});

View File

@@ -11,11 +11,13 @@ import type {
Client,
} from '@/packages/api/src';
import { PlusIcon, PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import { PlusCircleIcon, MinusIcon, XMarkIcon } from '@heroicons/vue/16/solid';
import ProjectCreateModal from '@/packages/ui/src/Project/ProjectCreateModal.vue';
import { twMerge } from 'tailwind-merge';
import { Button } from '@/packages/ui/src/Buttons';
const NO_PROJECT_ID = '';
const task = defineModel<string | null>('task', {
default: null,
});
@@ -57,6 +59,7 @@ const props = withDefaults(
currency: string;
emptyPlaceholder?: string;
allowReset?: boolean;
noProjectValue?: string | null;
enableEstimatedTime: boolean;
organizationBillableRate: number | null;
canCreateProject: boolean;
@@ -68,6 +71,7 @@ const props = withDefaults(
{
emptyPlaceholder: 'No Project',
allowReset: false,
noProjectValue: NO_PROJECT_ID,
variant: 'ghost',
align: 'center',
size: 'sm',
@@ -164,10 +168,10 @@ function updateFilteredResults() {
is_archived: false,
projects: [
{
id: '',
id: NO_PROJECT_ID,
name: 'No Project',
color: 'var(--theme-color-icon-default)',
value: '',
value: NO_PROJECT_ID,
client_id: null,
billable_rate: null,
is_archived: false,
@@ -490,7 +494,7 @@ function selectTask(taskId: string) {
}
function selectProject(projectId: string) {
project.value = projectId;
project.value = projectId === NO_PROJECT_ID ? props.noProjectValue : projectId;
task.value = null;
open.value = false;
searchValue.value = '';
@@ -507,41 +511,35 @@ const showCreateProject = ref(false);
</script>
<template>
<template v-if="projects.length === 0 && canCreateProject">
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start', props.class)"
@click="showCreateProject = true">
<PlusIcon class="w-4" />
<span class="truncate">Add new project</span>
</Button>
</template>
<Dropdown v-else v-model="open" :close-on-content-click="false" :align="props.align">
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger>
<div class="flex items-center gap-1">
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
<div
class="w-3 h-3 rounded-full shrink-0"
:style="{ backgroundColor: selectedProjectColor }"></div>
<span class="truncate shrink-[1] pr-1">{{ selectedProjectName }}</span>
<template v-if="currentTask">
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
</template>
</Button>
<button
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
<slot name="trigger">
<div class="flex items-center gap-1">
<Button
:variant="props.variant"
:size="props.size"
:class="twMerge('w-full justify-start overflow-hidden', props.class)">
<div
class="w-3 h-3 rounded-full shrink-0"
:style="{ backgroundColor: selectedProjectColor }"></div>
<span class="truncate shrink-[1] text-text-primary pr-1">{{
selectedProjectName
}}</span>
<template v-if="currentTask">
<ChevronRightIcon class="w-4 h-4 text-text-tertiary shrink-0" />
<span class="truncate shrink-[100]">{{ currentTask.name }}</span>
</template>
</Button>
<button
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</slot>
</template>
<template #content>
<UseFocusTrap v-if="open" :options="{ immediate: true, allowOutsideClick: true }">

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui';
import { computed, type HTMLAttributes } from 'vue';
import { twMerge } from 'tailwind-merge';
import { cn } from '../utils/cn';
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>();
@@ -17,7 +17,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<CalendarCell
:class="
twMerge(
cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50',
props.class
)

View File

@@ -31,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<div
:class="
cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto'
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
)
">
<DialogContent

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
'group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class
)
">

View File

@@ -8,6 +8,20 @@ export function getWeekStart() {
}
return weekStart;
}
const weekStartMap: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
export function getWeekStartDayNumber(): number {
return weekStartMap[getWeekStart()] ?? 1;
}
export function getUserTimezone() {
const timezone = window?.getTimezoneSetting() as string;
if (!timezone) {

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from 'vitest';
import { formatHumanReadableDuration, formatReportingDuration } from './time';
const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s
describe('formatHumanReadableDuration', () => {
test('decimal', () => {
expect(formatHumanReadableDuration(seconds, 'decimal', 'comma-point')).toBe('14.75 h');
});
test('hours-minutes', () => {
expect(formatHumanReadableDuration(seconds, 'hours-minutes')).toBe('14h 45min');
});
test('hours-minutes-colon-separated', () => {
expect(formatHumanReadableDuration(seconds, 'hours-minutes-colon-separated')).toBe('14:45');
});
test('hours-minutes-seconds-colon-separated', () => {
expect(formatHumanReadableDuration(seconds, 'hours-minutes-seconds-colon-separated')).toBe(
'14:45:06'
);
});
});
describe('formatReportingDuration', () => {
test('decimal', () => {
expect(formatReportingDuration(seconds, 'decimal', 'comma-point')).toBe('14.75 h');
});
test('hours-minutes', () => {
expect(formatReportingDuration(seconds, 'hours-minutes')).toBe('14:45:06');
});
test('hours-minutes-colon-separated', () => {
expect(formatReportingDuration(seconds, 'hours-minutes-colon-separated')).toBe('14:45:06');
});
test('hours-minutes-seconds-colon-separated', () => {
expect(formatReportingDuration(seconds, 'hours-minutes-seconds-colon-separated')).toBe(
'14:45:06'
);
});
});

View File

@@ -3,6 +3,8 @@ import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear';
@@ -68,6 +70,8 @@ function configureParseLocale(numberFormat?: string) {
dayjs.extend(relativeTime);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
dayjs.extend(duration);
dayjs.extend(utc);
dayjs.extend(timezone);
@@ -188,6 +192,15 @@ export function getLocalizedDateFromTimestamp(timestamp: string) {
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
}
/**
* Converts a local Date to a UTC-formatted ISO string.
* Treats the Date as being in the user's timezone and converts to UTC.
* This is the inverse of getLocalizedDayJs (which goes UTC → local).
*/
export function localDateToUtc(date: dayjs.Dayjs): string {
return date.tz(getUserTimezone(), true).utc().format();
}
/*
* Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -104,7 +104,7 @@ export const solidtimeTheme = {
border: 'var(--popover-border)',
},
destructive: {
DEFAULT: 'var(--destructive)',
DEFAULT: 'hsl(var(--destructive))',
foreground: 'var(--destructive-foreground)',
},
border: 'var(--border)',

View File

@@ -0,0 +1,20 @@
// Vitest setup file. Wires up the globals that the production code reads
// off `window` (`getTimezoneSetting`, `getWeekStartSetting`, `getNumberFormat`,
// `getIntervalFormat`) so that helpers under test don't crash when imported
// outside the running app.
import { vi } from 'vitest';
declare global {
interface Window {
getTimezoneSetting: () => string;
getWeekStartSetting: () => string;
getNumberFormat: () => string;
getIntervalFormat: () => string;
}
}
window.getTimezoneSetting = vi.fn(() => 'UTC');
window.getWeekStartSetting = vi.fn(() => 'monday');
window.getNumberFormat = vi.fn(() => 'point');
window.getIntervalFormat = vi.fn(() => 'hours-minutes');

View File

@@ -22,9 +22,7 @@ export interface Organization {
currency: string;
created_at: string | null;
updated_at: string | null;
owner: User;
users: User[];
team_invitations: OrganizationInvitation[];
owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
}
export interface OrganizationInvitation {
id: string;

View File

@@ -29,9 +29,7 @@ export interface Organization {
created_at: string | null;
updated_at: string | null;
// relations
owner: User;
users: User[];
team_invitations: OrganizationInvitation[];
owner: Pick<User, 'id' | 'name' | 'profile_photo_url'>;
}
export interface OrganizationInvitation {

View File

@@ -57,6 +57,13 @@ export const useNotificationsStore = defineStore('notifications', () => {
'organization_has_no_subscription_but_multiple_members'
) {
showActionBlockedModal.value = true;
} else if (error?.response?.data?.key === 'overlapping_time_entry') {
addNotification(
'error',
'Overlapping time entries are not allowed',
error.response?.data?.message ??
'This change would overlap with an existing time entry.'
);
} else {
addNotification(
'error',

View File

@@ -0,0 +1,348 @@
import { describe, it, expect } from 'vitest';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { findFreeWindowOnDay, freeGapSecondsAfter, NoFreeWindowError } from './cellMath';
import type { TimeEntry } from '@/packages/api/src';
dayjs.extend(utc);
dayjs.extend(timezone);
// All times in the tests are in UTC for clarity. The "day" we search is
// 2026-04-10 in UTC (so we use tz='UTC' to avoid local-machine surprises).
const TZ = 'UTC';
const DATE = '2026-04-10';
/** Build a fake TimeEntry from UTC ISO timestamps. */
function entry(start: string, end: string | null, id = `e-${start}-${end}`): TimeEntry {
const startMs = dayjs.utc(start).valueOf();
const endMs = end ? dayjs.utc(end).valueOf() : startMs;
return {
id,
start,
end,
duration: end ? Math.floor((endMs - startMs) / 1000) : null,
description: '',
member_id: 'm-1',
project_id: null,
task_id: null,
billable: false,
tags: [],
// The grid only reads the fields above; the rest are placeholders
// to satisfy the TimeEntry type without pulling in real fixtures.
user_id: 'u-1',
organization_id: 'o-1',
} as unknown as TimeEntry;
}
const HOUR = 3600;
describe('findFreeWindowOnDay', () => {
// ── Empty / trivial cases ──────────────────────────────────────
it('returns the start of the day for a totally empty day', () => {
const result = findFreeWindowOnDay([], DATE, HOUR, TZ);
expect(result).toEqual({
start: '2026-04-10T00:00:00Z',
end: '2026-04-10T01:00:00Z',
});
});
it('returns null for zero or negative required seconds', () => {
expect(findFreeWindowOnDay([], DATE, 0, TZ)).toBeNull();
expect(findFreeWindowOnDay([], DATE, -1, TZ)).toBeNull();
});
it('refuses any duration > 24h on principle (single-day constraint)', () => {
const result = findFreeWindowOnDay([], DATE, 25 * HOUR, TZ);
expect(result).toBeNull();
});
// ── Single obstacle, basic gap finding ────────────────────────
it('finds the gap before a single obstacle if it fits', () => {
const obs = [entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z')];
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ);
expect(result?.start).toBe('2026-04-10T00:00:00Z');
expect(result?.end).toBe('2026-04-10T01:00:00Z');
});
it('finds the gap after a single obstacle when preferredStart skips earlier gaps', () => {
const obs = [entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z')];
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ, '2026-04-10T11:00:00Z');
expect(result?.start).toBe('2026-04-10T11:00:00Z');
expect(result?.end).toBe('2026-04-10T12:00:00Z');
});
// ── Multi-obstacle gap walking ────────────────────────────────
it('finds a gap between two obstacles', () => {
const obs = [
entry('2026-04-10T08:00:00Z', '2026-04-10T10:00:00Z'),
entry('2026-04-10T12:00:00Z', '2026-04-10T14:00:00Z'),
];
// Search for 2h, expecting the [00:00, 08:00) gap (8h available)
const result = findFreeWindowOnDay(obs, DATE, 2 * HOUR, TZ);
expect(result?.start).toBe('2026-04-10T00:00:00Z');
});
it('walks past gaps that are too small', () => {
const obs = [
entry('2026-04-10T00:30:00Z', '2026-04-10T10:00:00Z'),
entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z'),
];
// First gap is 30min, second gap is 1h, third is 12h.
// Asking for 90min → first two gaps are too small, third fits.
const result = findFreeWindowOnDay(obs, DATE, 90 * 60, TZ);
expect(result?.start).toBe('2026-04-10T12:00:00Z');
});
it('uses preferredStart even when it lands inside a gap', () => {
const obs = [
entry('2026-04-10T00:00:00Z', '2026-04-10T08:00:00Z'),
entry('2026-04-10T12:00:00Z', '2026-04-10T14:00:00Z'),
];
// preferredStart 09:00 → gap is [09:00, 12:00) = 3h
const result = findFreeWindowOnDay(obs, DATE, 2 * HOUR, TZ, '2026-04-10T09:00:00Z');
expect(result?.start).toBe('2026-04-10T09:00:00Z');
expect(result?.end).toBe('2026-04-10T11:00:00Z');
});
it('skips ahead when preferredStart lands inside an obstacle', () => {
const obs = [entry('2026-04-10T08:00:00Z', '2026-04-10T12:00:00Z')];
// preferredStart 10:00 lands inside [08:00, 12:00). We must
// advance to the next free position (12:00).
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ, '2026-04-10T10:00:00Z');
expect(result?.start).toBe('2026-04-10T12:00:00Z');
});
// ── Spillover from previous day ───────────────────────────────
it('treats an entry that started yesterday but ends today as an obstacle', () => {
// Yesterday 23:00 → today 02:00 → blocks the first 2h of today.
const obs = [entry('2026-04-09T23:00:00Z', '2026-04-10T02:00:00Z')];
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ);
expect(result?.start).toBe('2026-04-10T02:00:00Z');
});
it('ignores an entry that ended exactly at the start of the day', () => {
// Yesterday 22:00 → today 00:00 (exclusive) → does NOT block today.
const obs = [entry('2026-04-09T22:00:00Z', '2026-04-10T00:00:00Z')];
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ);
expect(result?.start).toBe('2026-04-10T00:00:00Z');
});
// ── Running entries ────────────────────────────────────────────
it('treats a running entry as blocking up to "now"', () => {
const obs = [entry('2026-04-10T08:00:00Z', null)];
const now = '2026-04-10T10:30:00Z';
// The running entry blocks 08:0010:30 → first free window is
// either before 08:00 (8h available, fits a 1h request).
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ, null, now);
expect(result?.start).toBe('2026-04-10T00:00:00Z');
});
it('places after a running entry when preferredStart pushes past it', () => {
const obs = [entry('2026-04-10T08:00:00Z', null)];
const now = '2026-04-10T10:30:00Z';
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ, '2026-04-10T09:00:00Z', now);
// preferredStart 09:00 lands inside the running entry's blocked
// range [08:00, 10:30) → must skip to 10:30.
expect(result?.start).toBe('2026-04-10T10:30:00Z');
});
// ── Midnight refusal ──────────────────────────────────────────
it('refuses to return a window that would cross midnight', () => {
const obs = [entry('2026-04-10T00:00:00Z', '2026-04-10T22:30:00Z')];
// Only 90min remain in the day. Asking for 2h → null.
const result = findFreeWindowOnDay(obs, DATE, 2 * HOUR, TZ);
expect(result).toBeNull();
});
it('accepts a window that ends exactly at midnight', () => {
const obs = [entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z')];
// Exactly 2h remain → 22:0000:00.
const result = findFreeWindowOnDay(obs, DATE, 2 * HOUR, TZ);
expect(result?.start).toBe('2026-04-10T22:00:00Z');
expect(result?.end).toBe('2026-04-11T00:00:00Z');
});
// ── Pre-existing overlapping obstacles ────────────────────────
it('merges overlapping obstacles before computing gaps', () => {
const obs = [
entry('2026-04-10T09:00:00Z', '2026-04-10T11:00:00Z'),
entry('2026-04-10T10:00:00Z', '2026-04-10T13:00:00Z'),
entry('2026-04-10T15:00:00Z', '2026-04-10T16:00:00Z'),
];
// Effective obstacles: [09:00, 13:00) and [15:00, 16:00)
// First gap is [00:00, 09:00) = 9h. Asking for 2h → 00:00.
const result = findFreeWindowOnDay(obs, DATE, 2 * HOUR, TZ);
expect(result?.start).toBe('2026-04-10T00:00:00Z');
});
// ── Day full ──────────────────────────────────────────────────
it('returns null when no gap is large enough', () => {
const obs = [
entry('2026-04-10T00:00:00Z', '2026-04-10T11:00:00Z'),
entry('2026-04-10T11:30:00Z', '2026-04-10T22:00:00Z'),
entry('2026-04-10T22:30:00Z', '2026-04-10T23:30:00Z'),
];
// Gaps: 30min, 30min, 30min. Asking for 1h → null.
const result = findFreeWindowOnDay(obs, DATE, HOUR, TZ);
expect(result).toBeNull();
});
// ── Timezone awareness ────────────────────────────────────────
it('respects the user timezone for day boundaries', () => {
// In Pacific/Auckland (+13 in April 2026), 2026-04-10 starts at
// 2026-04-09T11:00:00Z (NZDT was UTC+13 until April 5 2026, then
// NZST UTC+12 — let's pick a date in NZST so the offset is +12).
// 2026-04-10 NZST = 2026-04-09T12:00:00Z to 2026-04-10T12:00:00Z
const tz = 'Pacific/Auckland';
const obs = [
// This entry is at 2026-04-10T08:00:00Z = 2026-04-10T20:00 NZ
// → falls in the NZ day for 2026-04-10. So it blocks from
// 20:00 to 21:00 NZ time.
entry('2026-04-10T08:00:00Z', '2026-04-10T09:00:00Z'),
];
const result = findFreeWindowOnDay(obs, '2026-04-10', HOUR, tz);
// Day starts at 2026-04-09T12:00:00Z in NZST. First gap is the
// 20h before the obstacle starts.
expect(result?.start).toBe('2026-04-09T12:00:00Z');
});
it('does not place work past local midnight on spring-forward days', () => {
const tz = 'Europe/Vienna';
const obs = [entry('2026-03-28T23:00:00Z', '2026-03-29T22:00:00Z')];
const result = findFreeWindowOnDay(obs, '2026-03-29', HOUR, tz);
expect(result).toBeNull();
});
it('can use the final local hour on fall-back days', () => {
const tz = 'Europe/Vienna';
const result = findFreeWindowOnDay([], '2026-10-25', HOUR, tz, '2026-10-25T22:00:00Z');
expect(result).toEqual({
start: '2026-10-25T22:00:00Z',
end: '2026-10-25T23:00:00Z',
});
});
});
describe('freeGapSecondsAfter', () => {
it('returns the rest of the day for an empty day', () => {
const result = freeGapSecondsAfter([], DATE, TZ, '2026-04-10T09:00:00Z');
// 09:00 → 24:00 = 15 hours
expect(result).toBe(15 * HOUR);
});
it('returns 0 when cursor is at end-of-day', () => {
const result = freeGapSecondsAfter([], DATE, TZ, '2026-04-11T00:00:00Z');
expect(result).toBe(0);
});
it('returns 0 when cursor is after end-of-day', () => {
const result = freeGapSecondsAfter([], DATE, TZ, '2026-04-11T05:00:00Z');
expect(result).toBe(0);
});
it('returns 0 when cursor is before the day starts', () => {
const result = freeGapSecondsAfter([], DATE, TZ, '2026-04-09T23:00:00Z');
expect(result).toBe(0);
});
it('returns the gap to the next obstacle', () => {
const obs = [entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z')];
// cursor 09:00 → next obstacle 11:00 → 2h gap
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T09:00:00Z');
expect(result).toBe(2 * HOUR);
});
it('returns 0 when cursor sits inside an obstacle', () => {
const obs = [entry('2026-04-10T08:00:00Z', '2026-04-10T12:00:00Z')];
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T10:00:00Z');
expect(result).toBe(0);
});
it('returns the rest of the day when no obstacle is ahead', () => {
const obs = [entry('2026-04-10T08:00:00Z', '2026-04-10T09:00:00Z')];
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T09:00:00Z');
// 09:00 → 24:00 = 15h
expect(result).toBe(15 * HOUR);
});
it('skips obstacles strictly before the cursor', () => {
const obs = [
entry('2026-04-10T08:00:00Z', '2026-04-10T09:00:00Z'),
entry('2026-04-10T15:00:00Z', '2026-04-10T16:00:00Z'),
];
// cursor 09:00 → first obstacle (08-09) is behind, next is 15:00 → 6h
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T09:00:00Z');
expect(result).toBe(6 * HOUR);
});
it('treats a running entry as blocking up to "now"', () => {
const obs = [entry('2026-04-10T08:00:00Z', null)];
const now = '2026-04-10T10:00:00Z';
// cursor 11:00 is after the running entry's effective end (10:00),
// so the gap is the rest of the day = 13h
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T11:00:00Z', now);
expect(result).toBe(13 * HOUR);
});
it('returns 0 when cursor is inside a running entry (cursor < now)', () => {
const obs = [entry('2026-04-10T08:00:00Z', null)];
const now = '2026-04-10T12:00:00Z';
// cursor 10:00 falls inside [08:00, 12:00) → blocked
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T10:00:00Z', now);
expect(result).toBe(0);
});
it('clips the gap to midnight, never beyond', () => {
const obs = [entry('2026-04-10T08:00:00Z', '2026-04-10T09:00:00Z')];
// cursor 23:00 → 1h until midnight
const result = freeGapSecondsAfter(obs, DATE, TZ, '2026-04-10T23:00:00Z');
expect(result).toBe(HOUR);
});
it('clips spring-forward days at the next local midnight', () => {
const result = freeGapSecondsAfter(
[],
'2026-03-29',
'Europe/Vienna',
'2026-03-29T22:00:00Z'
);
expect(result).toBe(0);
});
it('includes the final local hour on fall-back days', () => {
const result = freeGapSecondsAfter(
[],
'2026-10-25',
'Europe/Vienna',
'2026-10-25T22:00:00Z'
);
expect(result).toBe(HOUR);
});
});
describe('NoFreeWindowError', () => {
it('carries the date and required seconds', () => {
const err = new NoFreeWindowError('2026-04-10', 7200);
expect(err.code).toBe('no_free_window');
expect(err.date).toBe('2026-04-10');
expect(err.requiredSeconds).toBe(7200);
expect(err.message).toContain('2026-04-10');
expect(err instanceof Error).toBe(true);
});
});

View File

@@ -0,0 +1,212 @@
import { type Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
// `getDayJsInstance()` reads window-injected settings (week-start), which
// aren't available at module load. Each function calls it lazily at use
// time. The cost is a per-call locale update; cellMath doesn't use any
// week-start-aware APIs so it's a no-op functionally.
/**
* UTC ISO of 09:00 local on `date` — the preferred placement for new
* work when an empty day needs a default start time.
*/
export function workDayStartOn(date: string, tz: string): string {
const dayjs = getDayJsInstance();
return dayjs.tz(`${date} 09:00:00`, tz).utc().format();
}
export interface FreeWindow {
start: string;
end: string;
}
interface Interval {
start: Dayjs;
end: Dayjs;
}
function localDayBounds(date: string, tz: string): { dayStart: Dayjs; dayEnd: Dayjs } {
const dayjs = getDayJsInstance();
// `.add(1, 'day')` on a Dayjs instance advances by a fixed 24h, which is
// wrong on DST-transition days (the local day is 23h or 25h long). Derive
// the next calendar date in UTC (no DST) and take its local midnight, so
// `dayEnd` is always the real next local midnight.
const nextDate = dayjs.utc(date).add(1, 'day').format('YYYY-MM-DD');
return {
dayStart: dayjs.tz(`${date} 00:00:00`, tz).utc(),
dayEnd: dayjs.tz(`${nextDate} 00:00:00`, tz).utc(),
};
}
/**
* Collect entries that intersect the day `[dayStart, dayEnd)`, clipped
* to those bounds. Running entries use `nowDayjs` as their end.
*/
function collectDayObstacles(
entries: TimeEntry[],
dayStart: Dayjs,
dayEnd: Dayjs,
nowDayjs: Dayjs
): Interval[] {
const dayjs = getDayJsInstance();
const obstacles: Interval[] = [];
for (const entry of entries) {
const entryStart = dayjs.utc(entry.start);
const entryEnd = entry.end ? dayjs.utc(entry.end) : nowDayjs;
if (entryEnd.isSameOrBefore(dayStart)) continue;
if (entryStart.isSameOrAfter(dayEnd)) continue;
const clippedStart = entryStart.isBefore(dayStart) ? dayStart : entryStart;
const clippedEnd = entryEnd.isAfter(dayEnd) ? dayEnd : entryEnd;
obstacles.push({ start: clippedStart, end: clippedEnd });
}
return obstacles;
}
/**
* First free window on the local calendar day that fits `requiredSeconds`
* without colliding with any existing entry. Returns `null` if nothing fits
* — never crosses midnight.
*
* Obstacles include same-day entries, spillovers from adjacent days, and
* running entries (treated as `end = now`). All are clipped to the day's
* `[00:00, 24:00)` boundaries.
*
* `preferredStart` (UTC ISO) is a hard floor — windows with `start` before
* it are rejected. Use it to place "after some cursor."
*/
export function findFreeWindowOnDay(
entries: TimeEntry[],
date: string,
requiredSeconds: number,
tz: string,
preferredStart?: string | null,
now?: string | Dayjs
): FreeWindow | null {
if (requiredSeconds <= 0) return null;
const dayjs = getDayJsInstance();
const { dayStart, dayEnd } = localDayBounds(date, tz);
if (requiredSeconds > dayEnd.diff(dayStart, 'second')) return null;
const nowDayjs = now ? dayjs.utc(now) : dayjs.utc();
const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs);
// Sort + merge so we can walk a clean [gap, obstacle, gap, ...] sequence.
obstacles.sort((a, b) => a.start.diff(b.start));
// merge overlaps
const merged: Interval[] = [];
for (const obs of obstacles) {
const last = merged[merged.length - 1];
if (last && obs.start.isSameOrBefore(last.end)) {
if (obs.end.isAfter(last.end)) {
last.end = obs.end;
}
} else {
merged.push({ start: obs.start, end: obs.end });
}
}
let cursor: Dayjs = dayStart;
if (preferredStart) {
const pref = dayjs.utc(preferredStart);
if (pref.isAfter(cursor)) cursor = pref;
}
if (cursor.isSameOrAfter(dayEnd)) return null;
for (const obs of merged) {
if (obs.end.isSameOrBefore(cursor)) continue;
if (obs.start.isAfter(cursor)) {
const gapSeconds = obs.start.diff(cursor, 'second');
if (gapSeconds >= requiredSeconds) {
return {
start: cursor.format(),
end: cursor.add(requiredSeconds, 'second').format(),
};
}
}
if (obs.end.isAfter(cursor)) cursor = obs.end;
if (cursor.isSameOrAfter(dayEnd)) return null;
}
const trailingSeconds = dayEnd.diff(cursor, 'second');
if (trailingSeconds >= requiredSeconds) {
return {
start: cursor.format(),
end: cursor.add(requiredSeconds, 'second').format(),
};
}
return null;
}
/**
* Seconds of free space starting at `cursor` until the next obstacle
* (or end of day). Returns 0 if the cursor is inside an obstacle or past
* midnight. Used by the extend path: "how far can I push this end forward?"
*/
export function freeGapSecondsAfter(
entries: TimeEntry[],
date: string,
tz: string,
cursor: string,
now?: string | Dayjs
): number {
const dayjs = getDayJsInstance();
const { dayStart, dayEnd } = localDayBounds(date, tz);
const cursorDjs = dayjs.utc(cursor);
if (cursorDjs.isSameOrAfter(dayEnd)) return 0;
if (cursorDjs.isBefore(dayStart)) return 0;
const nowDayjs = now ? dayjs.utc(now) : dayjs.utc();
// Drop obstacles ending at/before the cursor — they're behind us.
const obstacles = collectDayObstacles(entries, dayStart, dayEnd, nowDayjs).filter((obs) =>
obs.end.isAfter(cursorDjs)
);
obstacles.sort((a, b) => a.start.diff(b.start));
// Cursor inside an obstacle → no gap.
for (const obs of obstacles) {
if (obs.start.isSameOrBefore(cursorDjs) && obs.end.isAfter(cursorDjs)) {
return 0;
}
}
// Distance to first obstacle strictly after cursor, or to end of day.
for (const obs of obstacles) {
if (obs.start.isAfter(cursorDjs)) {
return Math.max(0, obs.start.diff(cursorDjs, 'second'));
}
}
return Math.max(0, dayEnd.diff(cursorDjs, 'second'));
}
/**
* Thrown when a required duration cannot fit on the target day without
* introducing an overlap. Callers reformat the message for end users.
*/
export class NoFreeWindowError extends Error {
public readonly code = 'no_free_window' as const;
public readonly date: string;
public readonly requiredSeconds: number;
constructor(date: string, requiredSeconds: number) {
super(
`Cannot fit ${requiredSeconds} seconds on ${date} without overlapping existing time entries.`
);
this.name = 'NoFreeWindowError';
this.date = date;
this.requiredSeconds = requiredSeconds;
}
}

View File

@@ -0,0 +1,242 @@
import { ref, type Ref } from 'vue';
import type { Dayjs } from 'dayjs';
import axios from 'axios';
import { useQueryClient } from '@tanstack/vue-query';
import {
api,
type CreateTimeEntryBody,
type TimeEntry,
type TimeEntryResponse,
} from '@/packages/api/src';
import {
getDayJsInstance,
getLocalizedDateFromTimestamp,
localDateToUtc,
} from '@/packages/ui/src/utils/time';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { fetchTimesheetEntries } from '@/utils/useTimesheetQuery';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { makeRowKey, type TimesheetRow } from '@/utils/useTimesheetGrid';
import { useNotificationsStore } from '@/utils/notification';
import { findFreeWindowOnDay, workDayStartOn } from './cellMath';
/**
* Implements both variants of "Copy last week":
*
* - `copyLastWeekRows()` — only add rows for each distinct
* (project, task) pair from last week
* that doesn't already exist
* - `copyLastWeekWithTime()` — same, but also duplicates each
* previous-week entry into the same
* day-of-week in the current week,
* stacking copies after any existing
* work on that day
*/
export function useCopyLastWeek(
weekStart: Ref<Dayjs>,
weekDays: Ref<string[]>,
rows: Ref<TimesheetRow[]>,
timeEntries: Ref<TimeEntry[]>,
addSlot: (
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
) => string
) {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const { addNotification } = useNotificationsStore();
const isCopyingLastWeek = ref(false);
async function fetchLastWeekEntries(): Promise<TimeEntryResponse | null> {
const prevStart = weekStart.value.subtract(7, 'day');
const prevEnd = weekStart.value;
const orgId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (!orgId) return null;
return await fetchTimesheetEntries(
orgId,
memberId,
localDateToUtc(prevStart),
localDateToUtc(prevEnd)
);
}
/**
* For every entry in `prevEntries`, if the current week doesn't
* already have a row for that (project, task) combination, add one.
* Deduplicates so each combination is added at most once.
*/
function addMissingRowsFromPreviousWeek(prevEntries: TimeEntry[]): void {
const existingIdentities = new Set(
rows.value.map((r) => makeRowKey(r.projectId, r.taskId, r.billable, r.tags))
);
const addedIdentities = new Set<string>();
for (const entry of prevEntries) {
const tags = entry.tags ?? [];
const identity = makeRowKey(entry.project_id, entry.task_id, entry.billable, tags);
if (!existingIdentities.has(identity) && !addedIdentities.has(identity)) {
addedIdentities.add(identity);
addSlot(entry.project_id, entry.task_id, entry.billable, tags);
}
}
}
async function copyLastWeekRows(): Promise<void> {
isCopyingLastWeek.value = true;
try {
const prev = await fetchLastWeekEntries();
if (!prev) return;
addMissingRowsFromPreviousWeek(prev.data);
} finally {
isCopyingLastWeek.value = false;
}
}
async function copyLastWeekWithTime(): Promise<void> {
isCopyingLastWeek.value = true;
try {
const prev = await fetchLastWeekEntries();
if (!prev) return;
const orgId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (!orgId || !memberId) return;
const tz = getUserTimezone();
addMissingRowsFromPreviousWeek(prev.data);
const prevWeekStart = weekStart.value.subtract(7, 'day');
// Working copy of the current week's entries; placed copies
// are appended so subsequent placement queries see them as
// obstacles (timeEntries.value isn't refreshed until the
// queryClient.invalidate at the end of the loop).
const workingEntries: TimeEntry[] = [...timeEntries.value];
let attempted = 0;
let succeeded = 0;
let overlapFailures = 0;
let otherFailures = 0;
for (const entry of prev.data) {
if (!entry.end || !entry.duration) continue;
// Map previous-week date → same day-of-week in current week.
const entryDate = getLocalizedDateFromTimestamp(entry.start);
const dayOffset = dayjs(entryDate).diff(prevWeekStart, 'day');
const newDate = weekDays.value[dayOffset];
if (!newDate) continue;
// Try the source's wall-clock time on the target day first
// (preserves "Monday 14:00 meeting" → "Monday 14:00 meeting"
// when the slot is free); fall back to 09:00, then to
// anywhere on the day.
const sourceTimeOfDay = dayjs.utc(entry.start).tz(tz).format('HH:mm:ss');
const sourceStartOnTarget = dayjs
.tz(`${newDate} ${sourceTimeOfDay}`, tz)
.utc()
.format();
const window =
findFreeWindowOnDay(
workingEntries,
newDate,
entry.duration,
tz,
sourceStartOnTarget
) ??
findFreeWindowOnDay(
workingEntries,
newDate,
entry.duration,
tz,
workDayStartOn(newDate, tz)
) ??
findFreeWindowOnDay(workingEntries, newDate, entry.duration, tz);
if (!window) {
attempted++;
otherFailures++;
continue;
}
const body: CreateTimeEntryBody = {
member_id: memberId,
project_id: entry.project_id,
task_id: entry.task_id,
start: window.start,
end: window.end,
billable: entry.billable,
description: entry.description ?? null,
tags: entry.tags ?? [],
};
attempted++;
try {
await api.createTimeEntry(body, { params: { organization: orgId } });
succeeded++;
workingEntries.push({
start: window.start,
end: window.end,
} as TimeEntry);
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.data?.key === 'overlapping_time_entry'
) {
overlapFailures++;
} else {
otherFailures++;
}
}
}
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
if (attempted === 0) return;
if (succeeded === attempted) {
addNotification(
'success',
`Copied ${succeeded} ${succeeded === 1 ? 'entry' : 'entries'} from last week`
);
} else if (succeeded > 0) {
const skipped = overlapFailures + otherFailures;
const detail =
overlapFailures > 0 && otherFailures === 0
? `${overlapFailures} overlapping`
: otherFailures > 0 && overlapFailures === 0
? `${otherFailures} failed`
: `${skipped} skipped`;
addNotification(
'error',
`Copied ${succeeded} of ${attempted} entries from last week`,
`${detail}.`
);
} else {
addNotification(
'error',
'Failed to copy entries from last week',
overlapFailures > 0 && otherFailures === 0
? 'All entries would overlap with existing time entries.'
: 'Please try again later.'
);
}
} finally {
isCopyingLastWeek.value = false;
}
}
return {
isCopyingLastWeek,
copyLastWeekRows,
copyLastWeekWithTime,
};
}

View File

@@ -0,0 +1,667 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { useTimesheetCellMutations, makeCellStatusKey } from './useTimesheetCellMutations';
import { api } from '@/packages/api/src';
import type { TimesheetRow, TimesheetCell } from '@/utils/useTimesheetGrid';
import type { TimeEntry } from '@/packages/api/src';
const addNotification = vi.fn();
vi.mock('@/utils/useUser', () => ({
getCurrentOrganizationId: vi.fn(() => 'org-1'),
getCurrentMembershipId: vi.fn(() => 'mem-1'),
}));
vi.mock('@tanstack/vue-query', () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
}));
vi.mock('@/utils/notification', () => ({
useNotificationsStore: () => ({
addNotification,
}),
}));
vi.mock('@/packages/api/src', () => ({
api: {
createTimeEntry: vi.fn(async () => ({ data: { id: 'new-id' } })),
updateTimeEntry: vi.fn(async () => undefined),
deleteTimeEntry: vi.fn(async () => undefined),
deleteTimeEntries: vi.fn(async () => undefined),
},
}));
// All scenarios use UTC so the local "day" matches the UTC day exactly
// (the test-setup mocks getTimezoneSetting to return 'UTC').
const DATE = '2026-04-10';
const HOUR = 3600;
function entry(start: string, end: string | null, overrides: Partial<TimeEntry> = {}): TimeEntry {
const startMs = new Date(start).valueOf();
const endMs = end ? new Date(end).valueOf() : startMs;
return {
id: overrides.id ?? `e-${start}-${end ?? 'running'}`,
start,
end,
duration: end ? Math.floor((endMs - startMs) / 1000) : null,
description: '',
member_id: 'm-1',
project_id: 'p-1',
task_id: null,
billable: false,
tags: [],
...overrides,
} as unknown as TimeEntry;
}
function buildCell(entries: TimeEntry[]): TimesheetCell {
return {
dayIndex: 0,
date: DATE,
entries,
totalSeconds: entries.reduce((sum, e) => sum + (e.duration ?? 0), 0),
};
}
function buildRow(
projectId: string | null,
entries: TimeEntry[],
key = `${projectId}:null`
): TimesheetRow {
const cell = buildCell(entries);
return {
key,
projectId,
taskId: null,
billable: false,
tags: [],
cells: new Map([[0, cell]]),
totalSeconds: cell.totalSeconds,
};
}
function buildEmptyRow(projectId: string | null, key = `${projectId}:null`): TimesheetRow {
return {
key,
projectId,
taskId: null,
billable: false,
tags: [],
cells: new Map(),
totalSeconds: 0,
};
}
/** Shape of the body the cell-mutation logic passes to api.createTimeEntry. */
interface CapturedEntry {
id?: string;
start: string;
end: string | null;
project_id?: string | null;
task_id?: string | null;
description?: string | null;
}
const apiMocks = vi.mocked(api);
function firstArg(
mock: typeof apiMocks.createTimeEntry | typeof apiMocks.updateTimeEntry
): CapturedEntry {
return mock.mock.calls[0]?.[0] as unknown as CapturedEntry;
}
function setup(
allEntries: TimeEntry[],
rowsValue: TimesheetRow[] = [],
removeSlot: (key: string) => void = () => undefined
) {
const cellMutations = useTimesheetCellMutations(
ref([
DATE,
'2026-04-11',
'2026-04-12',
'2026-04-13',
'2026-04-14',
'2026-04-15',
'2026-04-16',
]),
ref(allEntries),
ref(rowsValue),
removeSlot
);
return { cellMutations };
}
beforeEach(() => {
setActivePinia(createPinia());
apiMocks.createTimeEntry.mockClear();
apiMocks.updateTimeEntry.mockClear();
apiMocks.deleteTimeEntry.mockClear();
apiMocks.deleteTimeEntries.mockClear();
addNotification.mockClear();
// Lock the clock to mid-afternoon on the test day so running-entry
// tests have a deterministic "now". Past 12:00 to make spillover
// scenarios meaningful.
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-10T14:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('useTimesheetCellMutations.handleCellUpdate', () => {
// ── No-op ─────────────────────────────────────────────────────
it('does nothing when the diff is zero', async () => {
const { cellMutations } = setup([]);
const row = buildRow('p-1', [entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z')]);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled();
expect(apiMocks.deleteTimeEntry).not.toHaveBeenCalled();
expect(apiMocks.deleteTimeEntries).not.toHaveBeenCalled();
});
// ── Delete cell ───────────────────────────────────────────────
it('clearing a cell deletes all entries in it', async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z');
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
await cellMutations.handleCellUpdate(row, 0, 0);
expect(apiMocks.deleteTimeEntries).toHaveBeenCalledTimes(1);
const [, options] = apiMocks.deleteTimeEntries.mock.calls[0]!;
expect(options?.queries?.ids).toEqual([cellEntry.id]);
expect(options?.params?.organization).toBe('org-1');
});
// ── Create cell (Phase 1) ──────────────────────────────────────
describe('createCell', () => {
it('places a new entry at 09:00 on an empty day', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
const arg = firstArg(apiMocks.createTimeEntry);
expect(arg.start).toBe('2026-04-10T09:00:00Z');
expect(arg.end).toBe('2026-04-10T10:00:00Z');
expect(arg.project_id).toBe('p-1');
});
it('passes no-project rows to the API as null', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow(null);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(firstArg(apiMocks.createTimeEntry).project_id).toBeNull();
});
it('collapses an empty duplicate row after its first entry is created', async () => {
const removeSlot = vi.fn();
const existingRow = buildEmptyRow('p-1', 'existing-slot');
const duplicateRow = buildEmptyRow('p-1', 'duplicate-slot');
const { cellMutations } = setup([], [existingRow, duplicateRow], removeSlot);
await cellMutations.handleCellUpdate(duplicateRow, 0, HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(removeSlot).toHaveBeenCalledWith('duplicate-slot');
expect(addNotification).toHaveBeenCalledWith(
'success',
'Merged into matching row',
'Another row with the same project, task, billable status and tags already exists.'
);
});
it("falls back to the start of the day when 09:00 wouldn't fit", async () => {
// Block 09:00 → 23:30 with another row's entry. The only
// gap big enough for 1h is 00:00 → 09:00.
const blocker = entry('2026-04-10T09:00:00Z', '2026-04-10T23:30:00Z', {
id: 'blocker',
});
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
const arg = firstArg(apiMocks.createTimeEntry);
expect(arg.start).toBe('2026-04-10T00:00:00Z');
expect(arg.end).toBe('2026-04-10T01:00:00Z');
});
it('avoids overlapping with another row on the same day (Scenario #4)', async () => {
// Another row has an entry 09:00 → 10:00. The new entry must
// not overlap it.
const blocker = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'blocker',
});
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
await cellMutations.handleCellUpdate(row, 0, HOUR);
const arg = firstArg(apiMocks.createTimeEntry);
// 09:00 is blocked → must place after, at 10:00
expect(arg.start).toBe('2026-04-10T10:00:00Z');
expect(arg.end).toBe('2026-04-10T11:00:00Z');
});
it('treats a running entry as a blocker (Scenario #1)', async () => {
const running = entry('2026-04-10T08:00:00Z', null, { id: 'running' });
const { cellMutations } = setup([running]);
const row = buildEmptyRow('p-1');
await cellMutations.handleCellUpdate(row, 0, HOUR);
const arg = firstArg(apiMocks.createTimeEntry);
// The running entry blocks [08:00, now=14:00). The 09:00
// work-hours default lands inside that block, so the
// search advances the cursor to 14:00 and places the new
// entry at 14:00 → 15:00. Critically, this does NOT
// overlap the running timer.
expect(arg.start).toBe('2026-04-10T14:00:00Z');
expect(arg.end).toBe('2026-04-10T15:00:00Z');
});
it('avoids spillover from the previous day (Scenario #2)', async () => {
// An entry from yesterday spills into today's first 4h.
const spillover = entry('2026-04-09T22:00:00Z', '2026-04-10T04:00:00Z', {
id: 'spillover',
});
const { cellMutations } = setup([spillover]);
const row = buildEmptyRow('p-1');
await cellMutations.handleCellUpdate(row, 0, HOUR);
const arg = firstArg(apiMocks.createTimeEntry);
// 09:00 is free (the spillover ends at 04:00).
expect(arg.start).toBe('2026-04-10T09:00:00Z');
});
it('refuses to cross midnight (Scenario #3)', async () => {
// Block all of the day except the last 2h. Asking for 3h
// → no single-day window fits → notification, no API call.
const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', {
id: 'blocker',
});
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
await cellMutations.handleCellUpdate(row, 0, 3 * HOUR);
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
});
});
// ── Extend cell (Phase 2) ──────────────────────────────────────
describe('extendCell', () => {
it("extends the cell's latest-ended entry forward when there is room", async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'extend-me',
});
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
// Cell is 1h, request 2h total → +1h
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
const updated = firstArg(apiMocks.updateTimeEntry);
expect(updated.id).toBe('extend-me');
expect(updated.end).toBe('2026-04-10T11:00:00Z');
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
});
it('picks the latest-END entry when nested entries exist (Scenario #6)', async () => {
// Outer entry 09:00 → 12:00, inner entry 10:00 → 11:00.
// The latest START is "inner" but the latest END is "outer".
// Extending should grow the OUTER entry, not the inner.
const outer = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', { id: 'outer' });
const inner = entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', { id: 'inner' });
const { cellMutations } = setup([outer, inner]);
const row = buildRow('p-1', [outer, inner]);
// Cell total = 3h + 1h = 4h. Bump to 5h → +1h.
await cellMutations.handleCellUpdate(row, 0, 5 * HOUR);
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
const updated = firstArg(apiMocks.updateTimeEntry);
expect(updated.id).toBe('outer');
expect(updated.end).toBe('2026-04-10T13:00:00Z');
});
it('splits the extension when another row blocks the path (Scenario #5)', async () => {
// Cell entry: 09:00 → 10:00 (1h). Blocker on another row:
// 10:30 → 11:30. Bump cell to 3h (+2h):
// - 30 minutes fit in the gap [10:00, 10:30) → extend to 10:30
// - 90 minutes remain → place a NEW entry in the next free
// window (11:30 → 13:00)
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'cell-entry',
});
const blocker = entry('2026-04-10T10:30:00Z', '2026-04-10T11:30:00Z', {
id: 'blocker',
project_id: 'other-project',
});
const { cellMutations } = setup([cellEntry, blocker]);
const row = buildRow('p-1', [cellEntry]);
await cellMutations.handleCellUpdate(row, 0, 3 * HOUR);
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
const updated = firstArg(apiMocks.updateTimeEntry);
expect(updated.id).toBe('cell-entry');
expect(updated.end).toBe('2026-04-10T10:30:00Z');
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
const created = firstArg(apiMocks.createTimeEntry);
// Remainder = 2h - 30min = 90min, placed in next free window
// (11:30 → 13:00)
expect(created.start).toBe('2026-04-10T11:30:00Z');
expect(created.end).toBe('2026-04-10T13:00:00Z');
});
it("places everything as a new entry when the cell's tail abuts another row immediately (Scenario #5 zero-gap)", async () => {
// Cell entry: 09:00 → 10:00. Another row starts EXACTLY at 10:00.
// Gap after cell entry = 0. The whole add becomes a new entry
// somewhere else.
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'cell-entry',
});
const blocker = entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', {
id: 'blocker',
project_id: 'other-project',
});
const { cellMutations } = setup([cellEntry, blocker]);
const row = buildRow('p-1', [cellEntry]);
// Bump cell from 1h to 2h → +1h
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
// No update to cell entry (gap is zero)
expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled();
// New entry placed in next free window: 11:00 → 12:00
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
const created = firstArg(apiMocks.createTimeEntry);
expect(created.start).toBe('2026-04-10T11:00:00Z');
expect(created.end).toBe('2026-04-10T12:00:00Z');
});
it('falls back to creating a new entry when the latest cell entry is running (Scenario #8)', async () => {
// Cell contains only a running timer.
const running = entry('2026-04-10T08:00:00Z', null, {
id: 'running',
duration: null,
});
// The grid would treat the cell total as 0 because duration is
// null, so we fake a non-zero totalSeconds via a manual cell.
const cell: TimesheetCell = {
dayIndex: 0,
date: DATE,
entries: [running],
// Pretend we computed 1h so that handleCellUpdate sees a
// diff > 0 and routes through extendCell rather than
// createCell.
totalSeconds: HOUR,
};
const row: TimesheetRow = {
key: 'p-1:null',
projectId: 'p-1',
taskId: null,
billable: false,
tags: [],
cells: new Map([[0, cell]]),
totalSeconds: HOUR,
};
const { cellMutations } = setup([running]);
// Bump from 1h to 2h → +1h. extendCell sees the running entry
// as the latest-end and falls through to createCell.
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled();
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
// Running entry blocks [08:00, now=14:00). 09:00 lands inside
// → cursor advances to 14:00. Crucially the running entry is
// never modified.
const created = firstArg(apiMocks.createTimeEntry);
expect(created.start).toBe('2026-04-10T14:00:00Z');
expect(created.end).toBe('2026-04-10T15:00:00Z');
});
it('places the remainder strictly after the just-extended end (no stale-overlap)', async () => {
// Regression: timeEntries.value is stale right after the
// updateEntry call (still shows candidate.end at the old
// value). Without an explicit floor, findFreeWindowOnDay
// would propose a window inside the gap we just filled —
// overlapping the now-extended candidate.
//
// Cell entry: 09:00 → 10:00 (1h). Blocker on another row at
// 11:00 → 12:00. Gap is 1h. Bump cell to 2.5h (+1.5h):
// - extend by 1h → newEnd = 11:00
// - remainder = 30 min must land AFTER 11:00, not in the
// stale-looking [10:00, 11:00] window.
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'cell-entry',
});
const blocker = entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z', {
id: 'blocker',
project_id: 'other-project',
});
const { cellMutations } = setup([cellEntry, blocker]);
const row = buildRow('p-1', [cellEntry]);
await cellMutations.handleCellUpdate(row, 0, Math.round(2.5 * HOUR));
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
const updated = firstArg(apiMocks.updateTimeEntry);
expect(updated.id).toBe('cell-entry');
expect(updated.end).toBe('2026-04-10T11:00:00Z');
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
const created = firstArg(apiMocks.createTimeEntry);
// Next free window after blocker, at 12:00.
expect(created.start).toBe('2026-04-10T12:00:00Z');
expect(created.end).toBe('2026-04-10T12:30:00Z');
});
it('refuses extension that would cross midnight before patching the server (Scenario #7)', async () => {
// Cell entry 22:00 → 23:00. Bump to 4h (+3h). Only 1h is
// available before midnight. Because the remaining 2h don't
// fit anywhere else on the day, the mutation now aborts
// before issuing the PATCH, leaving the entry untouched.
const cellEntry = entry('2026-04-10T22:00:00Z', '2026-04-10T23:00:00Z', {
id: 'cell-entry',
});
const fillEarly = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', {
id: 'fill',
project_id: 'other-project',
});
const { cellMutations } = setup([cellEntry, fillEarly]);
const row = buildRow('p-1', [cellEntry]);
// Bump from 1h to 4h → +3h
await cellMutations.handleCellUpdate(row, 0, 4 * HOUR);
// Nothing should be patched or created because the preflight
// fit-check rejects the whole edit.
expect(apiMocks.updateTimeEntry).not.toHaveBeenCalled();
expect(apiMocks.createTimeEntry).not.toHaveBeenCalled();
});
});
// ── Shrink (unchanged behavior, still correct) ─────────────────
describe('shrinkFromEnd', () => {
it('shortens the latest entry by the requested amount', async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T12:00:00Z', {
id: 'shrink-me',
});
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
// 3h → 2h: shrink by 1h
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
const updated = firstArg(apiMocks.updateTimeEntry);
expect(updated.end).toBe('2026-04-10T11:00:00Z');
});
it('deletes entries that are entirely consumed', async () => {
const a = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'a',
});
const b = entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z', {
id: 'b',
});
const { cellMutations } = setup([a, b]);
const row = buildRow('p-1', [a, b]);
// Cell = 2h, shrink to 0.5h: must delete b entirely (1h) and
// shorten a from 1h to 30min.
await cellMutations.handleCellUpdate(row, 0, 1800);
expect(apiMocks.deleteTimeEntry).toHaveBeenCalledTimes(1);
const [, deleteOptions] = apiMocks.deleteTimeEntry.mock.calls[0]!;
expect(deleteOptions?.params?.timeEntry).toBe('b');
expect(apiMocks.updateTimeEntry).toHaveBeenCalledTimes(1);
const updated = firstArg(apiMocks.updateTimeEntry);
expect(updated.id).toBe('a');
expect(updated.end).toBe('2026-04-10T09:30:00Z');
});
});
});
describe('useTimesheetCellMutations save status', () => {
// Timer handles keep old fade-outs from clearing newer status, and
// the same-cell saving guard prevents concurrent writes from stale rows.
it('does not let a stale fade-out timer clear a newer edit on the same cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
// Re-edit the same cell partway through the first "saved" window.
vi.advanceTimersByTime(1000);
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
// Advance past the FIRST timer's deadline: it must not wipe the newer state.
vi.advanceTimersByTime(2000);
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(2 * HOUR);
});
it('ignores another commit while the same cell is saving', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
let release!: () => void;
const gateA = new Promise<void>((res) => {
release = () => res();
});
apiMocks.createTimeEntry.mockImplementationOnce(async () => {
await gateA;
return { data: { id: 'a' } } as never;
});
const save = cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('saving');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
// The second commit would be planned from the same stale row, so it is ignored.
await cellMutations.handleCellUpdate(row, 0, 2 * HOUR);
expect(apiMocks.createTimeEntry).toHaveBeenCalledTimes(1);
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
release();
await save;
expect(cellMutations.cellStatus.value[key]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[key]).toBe(HOUR);
});
it('marks error and drops the optimistic value when the save fails', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
apiMocks.createTimeEntry.mockRejectedValueOnce(new Error('boom'));
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
'Failed to update timesheet',
expect.any(String)
);
});
it('marks error and drops the optimistic value when the day is full', async () => {
// Block all but the last 2h, then ask for 3h → NoFreeWindowError.
const blocker = entry('2026-04-10T00:00:00Z', '2026-04-10T22:00:00Z', { id: 'blocker' });
const { cellMutations } = setup([blocker]);
const row = buildEmptyRow('p-1');
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, 3 * HOUR);
expect(cellMutations.cellStatus.value[key]).toBe('error');
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
expect(addNotification).toHaveBeenCalledWith(
'error',
"This day can't fit any more work",
expect.any(String)
);
});
it('creates no status when the committed value is unchanged', async () => {
const cellEntry = entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z');
const { cellMutations } = setup([cellEntry]);
const row = buildRow('p-1', [cellEntry]);
const key = makeCellStatusKey(row.key, 0);
await cellMutations.handleCellUpdate(row, 0, HOUR);
expect(cellMutations.cellStatus.value[key]).toBeUndefined();
expect(cellMutations.cellPendingSeconds.value[key]).toBeUndefined();
});
it('tracks save status independently for each cell', async () => {
const { cellMutations } = setup([]);
const row = buildEmptyRow('p-1');
const mondayKey = makeCellStatusKey(row.key, 0);
const tuesdayKey = makeCellStatusKey(row.key, 1);
await cellMutations.handleCellUpdate(row, 0, HOUR);
await cellMutations.handleCellUpdate(row, 1, 2 * HOUR);
expect(cellMutations.cellStatus.value[mondayKey]).toBe('saved');
expect(cellMutations.cellStatus.value[tuesdayKey]).toBe('saved');
expect(cellMutations.cellPendingSeconds.value[mondayKey]).toBe(HOUR);
expect(cellMutations.cellPendingSeconds.value[tuesdayKey]).toBe(2 * HOUR);
});
});

View File

@@ -0,0 +1,375 @@
import { ref, type Ref } from 'vue';
import { useQueryClient } from '@tanstack/vue-query';
import { api, type CreateTimeEntryBody, type TimeEntry } from '@/packages/api/src';
import { formatHumanReadableDuration, getDayJsInstance } from '@/packages/ui/src/utils/time';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import {
makeRowKey,
type TimesheetCell,
type TimesheetRow,
type TimesheetRowKey,
} from '@/utils/useTimesheetGrid';
import { useNotificationsStore } from '@/utils/notification';
import {
findFreeWindowOnDay,
freeGapSecondsAfter,
NoFreeWindowError,
workDayStartOn,
type FreeWindow,
} from './cellMath';
export type CellSaveStatus = 'saving' | 'saved' | 'error';
/** Map key for a cell's save state (row + day). */
export function makeCellStatusKey(rowKey: TimesheetRowKey, dayIndex: number): string {
return `${rowKey}:${dayIndex}`;
}
/** How long the saved/error state stays visible before fading. */
const SAVED_VISIBLE_MS = 2800;
const ERROR_VISIBLE_MS = 2500;
/**
* Cell-level edit dispatcher. Picks one of four strategies based on
* the diff between current and requested totals:
*
* - deleteCell — new total is 0
* - createCell — empty cell, place in first free window
* - extendCell — diff > 0, push the latest-ending entry forward,
* splitting the remainder into a new entry if a
* collision blocks the path
* - shrinkFromEnd — diff < 0, shorten / delete entries from most-
* recent backwards
*
* Running entries (end === null) are treated as immutable. Both create
* and extend can throw NoFreeWindowError when the day is too full.
*
* Calls the API directly (not via useTimeEntriesMutations) so a single
* cell edit fanning into multiple mutations produces exactly one toast
* and one cache invalidation.
*/
export function useTimesheetCellMutations(
weekDays: Ref<string[]>,
timeEntries: Ref<TimeEntry[]>,
rows: Ref<TimesheetRow[]>,
removeSlot: (key: TimesheetRowKey) => void
) {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const notifications = useNotificationsStore();
// Save status + the optimistic value shown while saving, so a saved cell
// doesn't flicker back to its old total before the refetch lands.
const cellStatus = ref<Record<string, CellSaveStatus>>({});
const cellPendingSeconds = ref<Record<string, number>>({});
const statusClearTimers: Record<string, ReturnType<typeof setTimeout>> = {};
function clearStatusTimer(key: string): void {
clearTimeout(statusClearTimers[key]);
delete statusClearTimers[key];
}
function beginSaving(key: string, seconds: number): void {
clearStatusTimer(key);
cellPendingSeconds.value[key] = seconds;
cellStatus.value[key] = 'saving';
}
function markSaved(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'saved';
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete cellPendingSeconds.value[key];
delete statusClearTimers[key];
}, SAVED_VISIBLE_MS);
}
function markError(key: string): void {
clearStatusTimer(key);
cellStatus.value[key] = 'error';
// Drop the optimistic value so the cell shows server truth after refetch.
delete cellPendingSeconds.value[key];
statusClearTimers[key] = setTimeout(() => {
delete cellStatus.value[key];
delete statusClearTimers[key];
}, ERROR_VISIBLE_MS);
}
async function handleCellUpdate(
row: TimesheetRow,
dayIndex: number,
newTotalSeconds: number
): Promise<void> {
const statusKey = makeCellStatusKey(row.key, dayIndex);
if (cellStatus.value[statusKey] === 'saving') return;
const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0;
if (newTotalSeconds === existingSeconds) return;
beginSaving(statusKey, newTotalSeconds);
// Capture row state before the mutation: a row that was empty
// and shares identity with another slot collapses after the
// first entry lands, so the entry naturally identity-routes to
// the surviving slot.
const wasEmpty = row.totalSeconds === 0;
try {
await dispatchCellUpdate(row, dayIndex, newTotalSeconds);
if (wasEmpty && newTotalSeconds > 0 && hasDuplicateIdentitySlot(row)) {
removeSlot(row.key);
notifications.addNotification(
'success',
'Merged into matching row',
'Another row with the same project, task, billable status and tags already exists.'
);
}
markSaved(statusKey);
} catch (err) {
markError(statusKey);
if (err instanceof NoFreeWindowError) {
const friendlyDuration = formatHumanReadableDuration(
err.requiredSeconds,
'hours-minutes',
'point'
);
notifications.addNotification(
'error',
"This day can't fit any more work",
`Couldn't fit ${friendlyDuration} on ${err.date} without overlapping existing entries.`
);
return;
}
notifications.addNotification(
'error',
'Failed to update timesheet',
'Please try again later.'
);
} finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
}
function hasDuplicateIdentitySlot(row: TimesheetRow): boolean {
const target = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
return rows.value.some(
(r) =>
r.key !== row.key &&
makeRowKey(r.projectId, r.taskId, r.billable, r.tags) === target
);
}
async function dispatchCellUpdate(
row: TimesheetRow,
dayIndex: number,
newTotalSeconds: number
): Promise<void> {
const cell = row.cells.get(dayIndex);
const existingSeconds = cell?.totalSeconds ?? 0;
const diff = newTotalSeconds - existingSeconds;
if (newTotalSeconds === 0 && cell) {
await deleteCell(cell);
return;
}
if (!cell || existingSeconds === 0) {
await createCell(row, dayIndex, newTotalSeconds);
return;
}
if (diff > 0) {
await extendCell(row, dayIndex, cell, diff);
return;
}
await shrinkFromEnd(cell, -diff);
}
async function deleteCell(cell: TimesheetCell): Promise<void> {
const orgId = requireOrgId();
await api.deleteTimeEntries(undefined, {
queries: { ids: cell.entries.map((e) => e.id) },
params: { organization: orgId },
});
}
/**
* Place a new entry on the cell's day. Without `afterCursor`, prefers
* 09:00 local with a fall-back to start-of-day. With `afterCursor`,
* places strictly at-or-after that timestamp (used by extendCell to
* skip past a just-written extension that timeEntries.value doesn't
* yet reflect). Throws NoFreeWindowError if nothing fits.
*/
async function createCell(
row: TimesheetRow,
dayIndex: number,
totalSeconds: number,
afterCursor?: string
): Promise<void> {
const date = weekDays.value[dayIndex]!;
const tz = getUserTimezone();
let window: FreeWindow | null;
if (afterCursor) {
window = findFreeWindowOnDay(timeEntries.value, date, totalSeconds, tz, afterCursor);
} else {
window =
findFreeWindowOnDay(
timeEntries.value,
date,
totalSeconds,
tz,
workDayStartOn(date, tz)
) ?? findFreeWindowOnDay(timeEntries.value, date, totalSeconds, tz);
}
if (!window) throw new NoFreeWindowError(date, totalSeconds);
const orgId = requireOrgId();
const memberId = getCurrentMembershipId();
if (!memberId) throw new Error('No member context');
const body: CreateTimeEntryBody = {
member_id: memberId,
project_id: row.projectId,
task_id: row.taskId,
start: window.start,
end: window.end,
billable: row.billable,
description: null,
tags: row.tags,
};
await api.createTimeEntry(body, { params: { organization: orgId } });
}
/**
* Push the latest-ending entry's end forward by `addSeconds`, and if
* a collision blocks the path before that's exhausted, place the
* remainder as a fresh entry in the next free window on the day.
*/
async function extendCell(
row: TimesheetRow,
dayIndex: number,
cell: TimesheetCell,
addSeconds: number
): Promise<void> {
const date = weekDays.value[dayIndex]!;
const tz = getUserTimezone();
// Latest END (not latest start) — extending a nested inner entry
// would leave the outer one as the true tail.
const candidate = pickLatestEndedEntry(cell);
// Running timer (or no ended entry): can't extend, place it all
// as a new entry instead.
if (!candidate || !candidate.end) {
await createCell(row, dayIndex, addSeconds);
return;
}
const gap = freeGapSecondsAfter(timeEntries.value, date, tz, candidate.end);
const extendBy = Math.min(addSeconds, gap);
const remainder = addSeconds - extendBy;
const projectedNewEnd = dayjs.utc(candidate.end).add(extendBy, 'second').format();
// Pre-flight: if there's a remainder, make sure it'll fit in a
// window after `projectedNewEnd` BEFORE we issue the extend PATCH.
// Otherwise a successful extend followed by a no-fit createCell
// would leave the entry persistently lengthened on the server
// while the user sees a "can't fit" error.
if (remainder > 0) {
const fit = findFreeWindowOnDay(
timeEntries.value,
date,
remainder,
tz,
projectedNewEnd
);
if (!fit) throw new NoFreeWindowError(date, addSeconds);
}
if (extendBy > 0) {
await updateEntry({ ...candidate, end: projectedNewEnd });
}
if (remainder <= 0) return;
// timeEntries.value is stale here (still shows candidate's old
// end). Force the placement search past projectedNewEnd so it
// can't propose a window that overlaps the just-extended candidate.
await createCell(row, dayIndex, remainder, projectedNewEnd);
}
async function shrinkFromEnd(cell: TimesheetCell, removeSeconds: number): Promise<void> {
let toRemove = removeSeconds;
// Shrink doesn't introduce overlaps, so latest-START is fine here.
const sortedEntries = [...cell.entries].sort((a, b) => b.start.localeCompare(a.start));
for (const entry of sortedEntries) {
if (toRemove <= 0) break;
if (!entry.end) continue; // running entries are immutable
const entryDuration = entry.duration ?? 0;
if (entryDuration <= toRemove) {
await deleteEntry(entry.id);
toRemove -= entryDuration;
} else {
const newEnd = dayjs
.utc(entry.start)
.add(entryDuration - toRemove, 'second')
.format();
await updateEntry({ ...entry, end: newEnd });
toRemove = 0;
}
}
}
// ── api helpers ───────────────────────────────────────────────
function requireOrgId(): string {
const id = getCurrentOrganizationId();
if (!id) throw new Error('No organization context');
return id;
}
async function updateEntry(entry: TimeEntry) {
const orgId = requireOrgId();
await api.updateTimeEntry(entry, {
params: { organization: orgId, timeEntry: entry.id },
});
}
async function deleteEntry(id: string) {
const orgId = requireOrgId();
await api.deleteTimeEntry(undefined, {
params: { organization: orgId, timeEntry: id },
});
}
function pickLatestEndedEntry(cell: TimesheetCell): TimeEntry | null {
let best: TimeEntry | null = null;
for (const entry of cell.entries) {
if (!best) {
best = entry;
continue;
}
// Running entries are treated as "infinite" — they win.
if (!entry.end) {
best = entry;
continue;
}
if (best.end && entry.end > best.end) {
best = entry;
}
}
return best;
}
return { handleCellUpdate, cellStatus, cellPendingSeconds };
}

View File

@@ -0,0 +1,70 @@
import { computed, ref, type Ref } from 'vue';
import type { Project, TimeEntry } from '@/packages/api/src';
import type { TimesheetRow, TimesheetRowKey } from '@/utils/useTimesheetGrid';
import type { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
type Mutations = ReturnType<typeof useTimeEntriesMutations>;
/**
* Holds the state and handlers for the "remove row" confirmation flow.
*
* Empty rows (no entries) are removed immediately without confirmation;
* rows with entries open a confirmation dialog, and on confirm we bulk
* delete every entry in the row before dropping the row from the grid.
*/
export function useTimesheetRowDeletion(
projects: Ref<Project[]>,
mutations: Pick<Mutations, 'deleteTimeEntries'>,
removeSlot: (key: TimesheetRowKey) => void
) {
const showDeleteDialog = ref(false);
const rowToDelete = ref<TimesheetRow | null>(null);
const deleteRowEntryCount = computed(() => {
if (!rowToDelete.value) return 0;
let count = 0;
for (const cell of rowToDelete.value.cells.values()) {
count += cell.entries.length;
}
return count;
});
const deleteRowProjectName = computed(() => {
if (!rowToDelete.value?.projectId) return 'No Project';
return projects.value.find((p) => p.id === rowToDelete.value?.projectId)?.name ?? 'Unknown';
});
function requestRemoveRow(row: TimesheetRow): void {
if (row.totalSeconds === 0) {
removeSlot(row.key);
return;
}
rowToDelete.value = row;
showDeleteDialog.value = true;
}
async function confirmDeleteRow(): Promise<void> {
if (!rowToDelete.value) return;
const allEntries: TimeEntry[] = [];
for (const cell of rowToDelete.value.cells.values()) {
allEntries.push(...cell.entries);
}
if (allEntries.length > 0) {
await mutations.deleteTimeEntries(allEntries);
}
removeSlot(rowToDelete.value.key);
showDeleteDialog.value = false;
rowToDelete.value = null;
}
return {
showDeleteDialog,
rowToDelete,
deleteRowEntryCount,
deleteRowProjectName,
requestRemoveRow,
confirmDeleteRow,
};
}

View File

@@ -0,0 +1,272 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import type { Project, TimeEntry } from '@/packages/api/src';
import type { TimesheetCell, TimesheetRow } from '@/utils/useTimesheetGrid';
import { useTimesheetRowMutations } from './useTimesheetRowMutations';
const addNotification = vi.fn();
vi.mock('@/utils/notification', () => ({
useNotificationsStore: () => ({
addNotification,
}),
}));
function project(id: string, isBillable: boolean): Project {
return {
id,
name: id,
is_billable: isBillable,
} as unknown as Project;
}
function entry(id: string): TimeEntry {
return {
id,
start: '2026-04-10T09:00:00Z',
end: '2026-04-10T10:00:00Z',
duration: 3600,
description: '',
member_id: 'm-1',
project_id: 'p-1',
task_id: null,
billable: false,
tags: [],
} as unknown as TimeEntry;
}
function buildCell(entries: TimeEntry[]): TimesheetCell {
return {
dayIndex: 0,
date: '2026-04-10',
entries,
totalSeconds: entries.reduce((sum, item) => sum + (item.duration ?? 0), 0),
};
}
function buildRow(key: string, projectId: string | null, entries: TimeEntry[]): TimesheetRow {
const cells = entries.length > 0 ? new Map([[0, buildCell(entries)]]) : new Map();
const totalSeconds = entries.reduce((sum, item) => sum + (item.duration ?? 0), 0);
return {
key,
projectId,
taskId: null,
billable: false,
tags: [],
cells,
totalSeconds,
};
}
describe('useTimesheetRowMutations', () => {
beforeEach(() => {
addNotification.mockClear();
});
it('collapses a populated row into an existing matching row after identity change', async () => {
const existingRow = buildRow('row-a', 'p-1', [entry('e-a')]);
const editedRow = buildRow('row-b', 'p-2', [entry('e-b')]);
const rows = ref([existingRow, editedRow]);
const updateTimeEntries = vi.fn().mockResolvedValue(undefined);
const addSlot = vi.fn();
const updateSlot = vi.fn();
const removeSlot = vi.fn();
const { handleRowIdentityChange } = useTimesheetRowMutations(
{ updateTimeEntries },
ref<Project[]>([]),
rows,
addSlot,
updateSlot,
removeSlot
);
await handleRowIdentityChange(editedRow, { projectId: 'p-1' });
expect(updateTimeEntries).toHaveBeenCalledWith({
ids: ['e-b'],
changes: { project_id: 'p-1' },
});
expect(removeSlot).toHaveBeenCalledWith('row-b');
expect(updateSlot).not.toHaveBeenCalled();
expect(addNotification).toHaveBeenCalledWith(
'success',
'Merged into matching row',
'Another row with the same project, task, billable status and tags already exists.'
);
});
it('translates identity fields to snake_case API field names', async () => {
const row = buildRow('row-a', 'p-1', [entry('e-a')]);
const rows = ref([row]);
const updateTimeEntries = vi.fn().mockResolvedValue(undefined);
const { handleRowIdentityChange } = useTimesheetRowMutations(
{ updateTimeEntries },
ref<Project[]>([]),
rows,
vi.fn(),
vi.fn(),
vi.fn()
);
await handleRowIdentityChange(row, {
projectId: 'p-2',
taskId: 't-1',
billable: true,
tags: ['tag-1'],
});
expect(updateTimeEntries).toHaveBeenCalledWith({
ids: ['e-a'],
changes: {
project_id: 'p-2',
task_id: 't-1',
billable: true,
tags: ['tag-1'],
},
});
});
it('only includes touched fields in the API changeset', async () => {
const row = buildRow('row-a', 'p-1', [entry('e-a')]);
const rows = ref([row]);
const updateTimeEntries = vi.fn().mockResolvedValue(undefined);
const { handleRowIdentityChange } = useTimesheetRowMutations(
{ updateTimeEntries },
ref<Project[]>([]),
rows,
vi.fn(),
vi.fn(),
vi.fn()
);
await handleRowIdentityChange(row, { tags: ['tag-1'] });
expect(updateTimeEntries).toHaveBeenCalledWith({
ids: ['e-a'],
changes: { tags: ['tag-1'] },
});
});
it('keeps an empty duplicate row until it receives time', async () => {
const existingRow = buildRow('row-a', 'p-1', [entry('e-a')]);
const emptyRow = buildRow('row-b', 'p-2', []);
const rows = ref([existingRow, emptyRow]);
const updateTimeEntries = vi.fn().mockResolvedValue(undefined);
const addSlot = vi.fn();
const updateSlot = vi.fn();
const removeSlot = vi.fn();
const { handleRowIdentityChange } = useTimesheetRowMutations(
{ updateTimeEntries },
ref<Project[]>([]),
rows,
addSlot,
updateSlot,
removeSlot
);
await handleRowIdentityChange(emptyRow, { projectId: 'p-1' });
expect(updateTimeEntries).not.toHaveBeenCalled();
expect(updateSlot).toHaveBeenCalledWith('row-b', {
projectId: 'p-1',
taskId: null,
billable: false,
tags: [],
});
expect(removeSlot).not.toHaveBeenCalled();
});
it('sends null project updates when an existing row changes to no project', async () => {
const row = buildRow('row-a', 'p-1', [entry('e-a')]);
const rows = ref([row]);
const updateTimeEntries = vi.fn().mockResolvedValue(undefined);
const updateSlot = vi.fn();
const { handleRowIdentityChange } = useTimesheetRowMutations(
{ updateTimeEntries },
ref<Project[]>([]),
rows,
vi.fn(),
updateSlot,
vi.fn()
);
await handleRowIdentityChange(row, { projectId: null });
expect(updateTimeEntries).toHaveBeenCalledWith({
ids: ['e-a'],
changes: { project_id: null },
});
expect(updateSlot).toHaveBeenCalledWith('row-a', {
projectId: null,
taskId: null,
billable: false,
tags: [],
});
});
it('defaults billable from the selected project when an empty row picks its first project', async () => {
const emptyRow = buildRow('row-a', null, []);
const rows = ref([emptyRow]);
const updateTimeEntries = vi.fn().mockResolvedValue(undefined);
const updateSlot = vi.fn();
const { handleRowIdentityChange } = useTimesheetRowMutations(
{ updateTimeEntries },
ref([project('p-billable', true)]),
rows,
vi.fn(),
updateSlot,
vi.fn()
);
await handleRowIdentityChange(emptyRow, { projectId: 'p-billable' });
expect(updateTimeEntries).not.toHaveBeenCalled();
expect(updateSlot).toHaveBeenCalledWith('row-a', {
projectId: 'p-billable',
taskId: null,
billable: true,
tags: [],
});
});
it('handleAddRow defaults billable from the selected project metadata', () => {
const addSlot = vi.fn();
const { handleAddRow } = useTimesheetRowMutations(
{ updateTimeEntries: vi.fn() },
ref([project('p-billable', true)]),
ref<TimesheetRow[]>([]),
addSlot,
vi.fn(),
vi.fn()
);
handleAddRow('p-billable', 't-1');
expect(addSlot).toHaveBeenCalledWith('p-billable', 't-1', true, []);
});
it('can add a no-project row', () => {
const addSlot = vi.fn();
const { handleAddRow } = useTimesheetRowMutations(
{ updateTimeEntries: vi.fn() },
ref([project('p-billable', true)]),
ref<TimesheetRow[]>([]),
addSlot,
vi.fn(),
vi.fn()
);
handleAddRow(null, null);
expect(addSlot).toHaveBeenCalledWith(null, null, false, []);
});
});

View File

@@ -0,0 +1,150 @@
import type { Ref } from 'vue';
import type { Project, UpdateMultipleTimeEntriesChangeset } from '@/packages/api/src';
import {
makeRowKey,
type TimesheetRow,
type TimesheetRowIdentity,
type TimesheetRowKey,
} from '@/utils/useTimesheetGrid';
import type { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useNotificationsStore } from '@/utils/notification';
function identityPartialToApiChanges(
partial: Partial<TimesheetRowIdentity>
): UpdateMultipleTimeEntriesChangeset {
const changes: UpdateMultipleTimeEntriesChangeset = {};
if ('projectId' in partial) changes.project_id = partial.projectId;
if ('taskId' in partial) changes.task_id = partial.taskId;
if ('billable' in partial) changes.billable = partial.billable;
if ('tags' in partial) changes.tags = partial.tags;
return changes;
}
type Mutations = ReturnType<typeof useTimeEntriesMutations>;
/**
* Row-level mutations that don't involve confirmation.
*
* Rows are keyed by slot id (not identity), so any partial change to
* a row's identity is handled the same way: push the change to the
* server for any entries in the row, then migrate the slot's identity
* in place so the row stays at its existing position.
*/
export function useTimesheetRowMutations(
mutations: Pick<Mutations, 'updateTimeEntries'>,
projects: Ref<Project[]>,
rows: Ref<TimesheetRow[]>,
addSlot: (
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
) => TimesheetRowKey,
updateSlot: (key: TimesheetRowKey, identity: TimesheetRowIdentity) => void,
removeSlot: (key: TimesheetRowKey) => void
) {
const notifications = useNotificationsStore();
function collectEntryIds(row: TimesheetRow): string[] {
const ids: string[] = [];
for (const cell of row.cells.values()) {
for (const entry of cell.entries) ids.push(entry.id);
}
return ids;
}
function hasDuplicateIdentityRow(
rowKey: TimesheetRowKey,
identity: TimesheetRowIdentity
): boolean {
const target = makeRowKey(
identity.projectId,
identity.taskId,
identity.billable,
identity.tags
);
return rows.value.some(
(candidate) =>
candidate.key !== rowKey &&
makeRowKey(
candidate.projectId,
candidate.taskId,
candidate.billable,
candidate.tags
) === target
);
}
async function handleRowIdentityChange(
row: TimesheetRow,
partial: Partial<TimesheetRowIdentity>
): Promise<void> {
const entryIds = collectEntryIds(row);
const currentIdentity = makeRowKey(row.projectId, row.taskId, row.billable, row.tags);
let merged: TimesheetRowIdentity = {
projectId: row.projectId,
taskId: row.taskId,
billable: row.billable,
tags: row.tags,
...partial,
};
// Auto-default billable on the first project pick for an empty
// row (project provides the default; user can override after).
if (
entryIds.length === 0 &&
partial.projectId !== undefined &&
partial.projectId !== row.projectId &&
partial.projectId &&
partial.billable === undefined
) {
const projectBillable = projects.value.find(
(p) => p.id === partial.projectId
)?.is_billable;
if (projectBillable !== undefined) {
merged = { ...merged, billable: projectBillable };
}
}
const mergedIdentity = makeRowKey(
merged.projectId,
merged.taskId,
merged.billable,
merged.tags
);
const shouldMergeIntoExistingRow =
entryIds.length > 0 &&
currentIdentity !== mergedIdentity &&
hasDuplicateIdentityRow(row.key, merged);
if (entryIds.length > 0) {
await mutations.updateTimeEntries({
ids: entryIds,
changes: identityPartialToApiChanges(partial),
});
}
if (shouldMergeIntoExistingRow) {
removeSlot(row.key);
notifications.addNotification(
'success',
'Merged into matching row',
'Another row with the same project, task, billable status and tags already exists.'
);
return;
}
updateSlot(row.key, merged);
}
function handleAddRow(projectId: string | null = null, taskId: string | null = null): void {
const project = projectId ? projects.value.find((p) => p.id === projectId) : null;
addSlot(projectId, taskId, project?.is_billable ?? false, []);
}
return {
handleRowIdentityChange,
handleAddRow,
};
}

View File

@@ -0,0 +1,82 @@
import { computed, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import { useQueryClient } from '@tanstack/vue-query';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { prefetchTimesheetWeek } from '@/utils/useTimesheetQuery';
import { getInitialWeekRange } from '@/utils/useTimeEntriesCalendarQuery';
/**
* Owns week-navigation state for the timesheet page.
*
* Exposes the current week start/end, the list of day strings, derived
* display helpers (week number, today's date, whether this is the
* current week), and navigation functions.
*
* Also prefetches the adjacent weeks whenever `weekStart` changes so
* that clicking prev/next feels instant.
*/
export function useTimesheetWeek() {
const dayjs = getDayJsInstance();
const queryClient = useQueryClient();
const weekStart = ref<Dayjs>(getInitialWeekRange().start);
const weekEnd = computed(() => weekStart.value.add(7, 'day'));
const weekDays = computed(() => {
const days: string[] = [];
for (let i = 0; i < 7; i++) {
days.push(weekStart.value.add(i, 'day').format('YYYY-MM-DD'));
}
return days;
});
const weekNumber = computed(() => weekStart.value.week());
const isCurrentWeek = computed(() =>
weekStart.value.isSame(getInitialWeekRange().start, 'day')
);
const todayDate = computed(() => {
const tz = getUserTimezone();
return dayjs().tz(tz).format('YYYY-MM-DD');
});
// Prefetch adjacent weeks so prev/next feels instant.
watch(
weekStart,
() => {
const prevStart = weekStart.value.subtract(7, 'day');
const prevEnd = weekStart.value;
const nextStart = weekStart.value.add(7, 'day');
const nextEnd = weekStart.value.add(14, 'day');
prefetchTimesheetWeek(queryClient, prevStart, prevEnd);
prefetchTimesheetWeek(queryClient, nextStart, nextEnd);
},
{ immediate: true }
);
function goToPreviousWeek() {
weekStart.value = weekStart.value.subtract(7, 'day');
}
function goToNextWeek() {
weekStart.value = weekStart.value.add(7, 'day');
}
function goToCurrentWeek() {
weekStart.value = getInitialWeekRange().start;
}
return {
weekStart,
weekEnd,
weekDays,
weekNumber,
isCurrentWeek,
todayDate,
goToPreviousWeek,
goToNextWeek,
goToCurrentWeek,
};
}

View File

@@ -2,43 +2,28 @@ import { useQuery } from '@tanstack/vue-query';
import { api, type TimeEntryResponse, type TimeEntry } from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref } from 'vue';
import { getDayJsInstance } from '@/packages/ui/src/utils/time';
import { getUserTimezone, getWeekStart } from '@/packages/ui/src/utils/settings';
const weekStartMap: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
import type { Dayjs } from 'dayjs';
import { getDayJsInstance, localDateToUtc } from '@/packages/ui/src/utils/time';
import { getWeekStartDayNumber } from '@/packages/ui/src/utils/settings';
/**
* Calculate expanded date range to include previous and next periods with timezone transformations.
* This allows smooth navigation between calendar views without loading delays.
*/
export function getExpandedCalendarDateRange(
calendarStart: Date,
calendarEnd: Date
calendarStart: Dayjs,
calendarEnd: Dayjs
): { start: string; end: string } {
const dayjs = getDayJsInstance();
const duration = dayjs(calendarEnd).diff(dayjs(calendarStart), 'milliseconds');
const duration = calendarEnd.diff(calendarStart, 'milliseconds');
// Calculate previous period
const previousStart = dayjs(calendarStart).subtract(duration, 'milliseconds');
const previousStart = calendarStart.subtract(duration, 'milliseconds');
// Calculate next period
const nextEnd = dayjs(calendarEnd).add(duration, 'milliseconds');
// Apply timezone transformations
const timezone = getUserTimezone();
const formattedStart = previousStart.utc().tz(timezone, true).utc().format();
const formattedEnd = nextEnd.utc().tz(timezone, true).utc().format();
const nextEnd = calendarEnd.add(duration, 'milliseconds');
return {
start: formattedStart,
end: formattedEnd,
start: localDateToUtc(previousStart),
end: localDateToUtc(nextEnd),
};
}
@@ -46,21 +31,17 @@ export function getExpandedCalendarDateRange(
* Get the initial week view date range based on user's week start preference.
* Matches FullCalendar's timeGridWeek initial view.
*/
export function getInitialWeekRange(): { start: Date; end: Date } {
export function getInitialWeekRange(): { start: Dayjs; end: Dayjs } {
const dayjs = getDayJsInstance();
const weekStart = getWeekStart();
const firstDay = weekStartMap[weekStart] ?? 1;
const firstDay = getWeekStartDayNumber();
const now = dayjs();
const currentDayOfWeek = now.day();
const daysFromWeekStart = (currentDayOfWeek - firstDay + 7) % 7;
const calendarStart = now.subtract(daysFromWeekStart, 'day').startOf('day');
const calendarEnd = calendarStart.add(7, 'day');
const start = now.subtract(daysFromWeekStart, 'day').startOf('day');
const end = start.add(7, 'day');
return {
start: calendarStart.toDate(),
end: calendarEnd.toDate(),
};
return { start, end };
}
/**
@@ -115,8 +96,8 @@ export async function fetchAllCalendarEntries(
}
export function useTimeEntriesCalendarQuery(
calendarStart: Ref<Date | undefined>,
calendarEnd: Ref<Date | undefined>
calendarStart: Ref<Dayjs | undefined>,
calendarEnd: Ref<Dayjs | undefined>
) {
const enableCalendarQuery = computed(() => {
return !!getCurrentOrganizationId() && !!calendarStart.value && !!calendarEnd.value;

View File

@@ -0,0 +1,277 @@
import { describe, expect, it } from 'vitest';
import { nextTick, ref } from 'vue';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import type { Dayjs } from 'dayjs';
import type { Project, Task, TimeEntry } from '@/packages/api/src';
import { useTimesheetGrid } from './useTimesheetGrid';
dayjs.extend(utc);
const WEEK_DAYS = [
'2026-04-06',
'2026-04-07',
'2026-04-08',
'2026-04-09',
'2026-04-10',
'2026-04-11',
'2026-04-12',
];
function entry(start: string, end: string | null, overrides: Partial<TimeEntry> = {}): TimeEntry {
const startMs = new Date(start).valueOf();
const endMs = end ? new Date(end).valueOf() : startMs;
return {
id: overrides.id ?? `e-${start}-${end ?? 'running'}`,
start,
end,
duration: end ? Math.floor((endMs - startMs) / 1000) : null,
description: '',
member_id: 'm-1',
project_id: 'p-1',
task_id: null,
billable: false,
tags: [],
...overrides,
} as unknown as TimeEntry;
}
function project(id: string, name: string, isBillable = false): Project {
return {
id,
name,
is_billable: isBillable,
} as unknown as Project;
}
function task(id: string, name: string, projectId: string): Task {
return {
id,
name,
project_id: projectId,
} as unknown as Task;
}
describe('useTimesheetGrid', () => {
it('seeds unseen identities and re-sorts seeded rows when project metadata changes', async () => {
const timeEntries = ref([
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'seed-b',
project_id: 'p-b',
task_id: null,
}),
entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', {
id: 'seed-a-z',
project_id: 'p-a',
task_id: 't-z',
}),
entry('2026-04-10T11:00:00Z', '2026-04-10T12:00:00Z', {
id: 'seed-a-a',
project_id: 'p-a',
task_id: 't-a',
}),
]);
const projects = ref([project('p-a', 'Bravo'), project('p-b', 'Alpha')]);
const tasks = ref([task('t-z', 'Zulu Task', 'p-a'), task('t-a', 'Alpha Task', 'p-a')]);
const { rows } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
projects,
tasks,
ref<Dayjs | null>(null)
);
expect(rows.value.map((row) => `${row.projectId}:${row.taskId}`)).toEqual([
'p-b:null',
'p-a:t-a',
'p-a:t-z',
]);
projects.value = [project('p-a', 'Aardvark'), project('p-b', 'Zulu')];
await nextTick();
expect(rows.value.map((row) => `${row.projectId}:${row.taskId}`)).toEqual([
'p-a:t-a',
'p-a:t-z',
'p-b:null',
]);
});
it('keeps user-added slots below seeded rows in insertion order', async () => {
const timeEntries = ref([
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'seed-b',
project_id: 'p-seed-b',
}),
entry('2026-04-10T10:00:00Z', '2026-04-10T11:00:00Z', {
id: 'seed-a',
project_id: 'p-seed-a',
}),
]);
const projects = ref([
project('p-seed-a', 'Bravo Seed'),
project('p-seed-b', 'Alpha Seed'),
project('p-user-1', 'Alpha User'),
project('p-user-2', 'Zulu User'),
]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
projects,
ref<Task[]>([]),
ref<Dayjs | null>(null)
);
addSlot('p-user-2', null, false, []);
addSlot('p-user-1', null, false, []);
await nextTick();
expect(rows.value.map((row) => row.projectId)).toEqual([
'p-seed-b',
'p-seed-a',
'p-user-2',
'p-user-1',
]);
projects.value = [
project('p-seed-a', 'Zulu Seed'),
project('p-seed-b', 'Alpha Seed'),
project('p-user-1', 'Aardvark User'),
project('p-user-2', 'Bravo User'),
];
await nextTick();
expect(rows.value.map((row) => row.projectId)).toEqual([
'p-seed-b',
'p-seed-a',
'p-user-2',
'p-user-1',
]);
});
it('assigns entries to the first duplicate-identity slot and leaves later duplicates empty', async () => {
const timeEntries = ref<TimeEntry[]>([]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
ref([project('p-1', 'Project One')]),
ref<Task[]>([]),
ref<Dayjs | null>(null)
);
const firstKey = addSlot('p-1', null, false, []);
const secondKey = addSlot('p-1', null, false, []);
await nextTick();
timeEntries.value = [
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', { project_id: 'p-1' }),
];
await nextTick();
expect(rows.value).toHaveLength(2);
expect(rows.value[0]?.key).toBe(firstKey);
expect(rows.value[0]?.totalSeconds).toBe(3600);
expect(rows.value[1]?.key).toBe(secondKey);
expect(rows.value[1]?.totalSeconds).toBe(0);
expect(rows.value[1]?.cells.size).toBe(0);
});
it('keeps no-project user slots aligned with refetched no-project entries', async () => {
const timeEntries = ref<TimeEntry[]>([]);
const { rows, addSlot } = useTimesheetGrid(
timeEntries,
ref(WEEK_DAYS),
ref<Project[]>([]),
ref<Task[]>([]),
ref<Dayjs | null>(null)
);
const rowKey = addSlot(null, null, false, []);
await nextTick();
expect(rows.value).toHaveLength(1);
expect(rows.value[0]).toMatchObject({ key: rowKey, projectId: null });
timeEntries.value = [
entry('2026-04-10T09:00:00Z', '2026-04-10T10:00:00Z', {
id: 'no-project',
project_id: null,
}),
];
await nextTick();
expect(rows.value).toHaveLength(1);
expect(rows.value[0]).toMatchObject({
key: rowKey,
projectId: null,
totalSeconds: 3600,
});
});
it('updates a slot identity in place and clearSlots removes all rows', async () => {
const { rows, slots, addSlot, updateSlot, clearSlots } = useTimesheetGrid(
ref<TimeEntry[]>([]),
ref(WEEK_DAYS),
ref([project('p-next', 'Next Project')]),
ref([task('t-1', 'Task One', 'p-next')]),
ref<Dayjs | null>(null)
);
const key = addSlot(null, null, false, []);
await nextTick();
updateSlot(key, {
projectId: 'p-next',
taskId: 't-1',
billable: true,
tags: ['b-tag', 'a-tag'],
});
await nextTick();
expect(rows.value).toHaveLength(1);
expect(rows.value[0]).toMatchObject({
key,
projectId: 'p-next',
taskId: 't-1',
billable: true,
tags: ['a-tag', 'b-tag'],
});
clearSlots();
await nextTick();
expect(rows.value).toHaveLength(0);
expect(slots.value).toHaveLength(0);
});
it('includes running entries in row and week totals using the live timer clock', () => {
const currentTime = ref(dayjs.utc('2026-04-10T10:00:00Z'));
const runningEntry = entry('2026-04-10T09:00:00Z', null, { id: 'running' });
const { rows, dayTotals, grandTotal } = useTimesheetGrid(
ref([runningEntry]),
ref(WEEK_DAYS),
ref<Project[]>([]),
ref<Task[]>([]),
currentTime
);
expect(rows.value).toHaveLength(1);
expect(rows.value[0]?.cells.get(4)?.totalSeconds).toBe(3600);
expect(rows.value[0]?.totalSeconds).toBe(3600);
expect(dayTotals.value[4]).toBe(3600);
expect(grandTotal.value).toBe(3600);
currentTime.value = dayjs.utc('2026-04-10T11:30:00Z');
expect(rows.value[0]?.cells.get(4)?.totalSeconds).toBe(9000);
expect(rows.value[0]?.totalSeconds).toBe(9000);
expect(dayTotals.value[4]).toBe(9000);
expect(grandTotal.value).toBe(9000);
});
});

View File

@@ -0,0 +1,284 @@
import type { TimeEntry, Project, Task } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDateFromTimestamp } from '@/packages/ui/src/utils/time';
import type { Dayjs } from 'dayjs';
import { computed, ref, watch, type Ref } from 'vue';
export type TimesheetRowKey = string;
export interface TimesheetCell {
dayIndex: number;
date: string;
entries: TimeEntry[];
totalSeconds: number;
}
export interface TimesheetRow {
key: TimesheetRowKey;
projectId: string | null;
taskId: string | null;
billable: boolean;
tags: string[];
cells: Map<number, TimesheetCell>;
totalSeconds: number;
}
export interface TimesheetRowIdentity {
projectId: string | null;
taskId: string | null;
billable: boolean;
tags: string[];
}
interface Slot extends TimesheetRowIdentity {
id: string;
// 'seeded' slots are derived from the entries query and re-sort
// alphabetically whenever project/task lists change. 'user' slots
// were created via Add Row / project-change interactions and keep
// their insertion order (always below the seeded block).
origin: 'seeded' | 'user';
}
function sortTags(tags: string[] | null | undefined): string[] {
return [...(tags ?? [])].sort();
}
export function makeRowKey(
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
): TimesheetRowKey {
return JSON.stringify([projectId, taskId, billable, sortTags(tags)]);
}
function slotIdentityKey(slot: Slot): TimesheetRowKey {
return makeRowKey(slot.projectId, slot.taskId, slot.billable, slot.tags);
}
let slotCounter = 0;
function newSlotId(): string {
return `s${++slotCounter}`;
}
/**
* Slot-first row model.
*
* The timesheet renders one row per slot, in insertion order. Slots
* carry a stable id — the row's Vue key never changes across mutations,
* so rows don't jump positions as entries load or get edited.
*
* Entries hydrate slots: `rows` is computed by grouping entries by
* identity (projectId, taskId, billable, tags) and attaching the
* matching group to the first slot with that identity. Duplicate
* slots with the same identity render empty (the first one claims
* the entries) — callers are expected to collapse duplicates after a
* cell-create rather than letting them linger.
*
* Seeding: a watcher scans `timeEntries` and appends a slot for every
* identity that doesn't already have one. Initial loads come in as a
* batch and are sorted by project name so the first render is stable;
* slots added later (via `addSlot` or post-mutation refetches) append
* at the end.
*
* Mutations:
* - `addSlot` push a blank or pre-populated slot at the end
* - `removeSlot` drop a slot by id (the row's `key`)
* - `updateSlot` migrate a slot's identity in place — used by
* project/billable/tags changes so the row
* stays put while the server roundtrips
* - `clearSlots` wipe everything (used on week navigation)
*/
export function useTimesheetGrid(
timeEntries: Ref<TimeEntry[]>,
weekDays: Ref<string[]>,
projects: Ref<Project[]>,
tasks: Ref<Task[]>,
currentTime: Ref<Dayjs | null>
) {
const dayjs = getDayJsInstance();
const slots = ref<Slot[]>([]);
// Seed / re-sort the seeded portion of slots whenever entries,
// projects or tasks change. Seeded slots sort alphabetically by
// project name → task name → billable → tags so reloads are
// deterministic. User-added slots keep their insertion order and
// stay after the seeded block.
watch(
[() => timeEntries.value, () => projects.value, () => tasks.value],
([entries, projectList, taskList]) => {
const present = new Set(slots.value.map(slotIdentityKey));
for (const entry of entries) {
const key = makeRowKey(
entry.project_id,
entry.task_id,
entry.billable,
sortTags(entry.tags)
);
if (present.has(key)) continue;
present.add(key);
slots.value.push({
id: newSlotId(),
origin: 'seeded',
projectId: entry.project_id,
taskId: entry.task_id,
billable: entry.billable,
tags: sortTags(entry.tags),
});
}
const projectNameMap = new Map<string, string>();
for (const p of projectList) projectNameMap.set(p.id, p.name);
const taskNameMap = new Map<string, string>();
for (const t of taskList) taskNameMap.set(t.id, t.name);
const sortKey = (s: Slot): string => {
const projectName = s.projectId ? (projectNameMap.get(s.projectId) ?? '') : '';
const taskName = s.taskId ? (taskNameMap.get(s.taskId) ?? '') : '';
return `${projectName}\x00${taskName}\x00${s.billable ? '1' : '0'}\x00${s.tags.join(',')}`;
};
const seeded = slots.value.filter((s) => s.origin === 'seeded');
const userAdded = slots.value.filter((s) => s.origin === 'user');
seeded.sort((a, b) => sortKey(a).localeCompare(sortKey(b)));
slots.value = [...seeded, ...userAdded];
},
{ immediate: true }
);
const rows = computed<TimesheetRow[]>(() => {
const dayIndexMap = new Map<string, number>();
weekDays.value.forEach((date, index) => dayIndexMap.set(date, index));
// Group entries by identity. The first slot (in render order) with
// a given identity claims that group; later duplicate-identity
// slots render empty.
const entriesByIdentity = new Map<TimesheetRowKey, TimeEntry[]>();
for (const entry of timeEntries.value) {
const identityKey = makeRowKey(
entry.project_id,
entry.task_id,
entry.billable,
sortTags(entry.tags)
);
if (!entriesByIdentity.has(identityKey)) entriesByIdentity.set(identityKey, []);
entriesByIdentity.get(identityKey)!.push(entry);
}
const claimed = new Set<TimesheetRowKey>();
function buildCellsFromEntries(entries: TimeEntry[]) {
const cells = new Map<number, TimesheetCell>();
let totalSeconds = 0;
function getEntryDurationSeconds(entry: TimeEntry): number {
if (entry.end !== null) {
return entry.duration ?? 0;
}
const liveNow = currentTime.value ?? dayjs.utc();
return Math.max(0, liveNow.diff(dayjs.utc(entry.start), 'second'));
}
for (const entry of entries) {
const entryDate = getLocalizedDateFromTimestamp(entry.start);
const dayIndex = dayIndexMap.get(entryDate);
if (dayIndex === undefined) continue;
const existing = cells.get(dayIndex);
const duration = getEntryDurationSeconds(entry);
if (existing) {
existing.entries.push(entry);
existing.totalSeconds += duration;
} else {
cells.set(dayIndex, {
dayIndex,
date: weekDays.value[dayIndex]!,
entries: [entry],
totalSeconds: duration,
});
}
totalSeconds += duration;
}
return { cells, totalSeconds };
}
return slots.value.map((slot) => {
const identityKey = slotIdentityKey(slot);
let collected: TimeEntry[] = [];
if (!claimed.has(identityKey)) {
const byIdentity = entriesByIdentity.get(identityKey);
if (byIdentity) {
claimed.add(identityKey);
collected = byIdentity;
}
}
const { cells, totalSeconds } = buildCellsFromEntries(collected);
return {
key: slot.id,
projectId: slot.projectId,
taskId: slot.taskId,
billable: slot.billable,
tags: slot.tags,
cells,
totalSeconds,
};
});
});
const dayTotals = computed<number[]>(() =>
weekDays.value.map((_, dayIndex) =>
rows.value.reduce((sum, row) => sum + (row.cells.get(dayIndex)?.totalSeconds ?? 0), 0)
)
);
const grandTotal = computed(() => dayTotals.value.reduce((a, b) => a + b, 0));
function addSlot(
projectId: string | null,
taskId: string | null,
billable: boolean,
tags: string[]
): TimesheetRowKey {
const id = newSlotId();
slots.value.push({
id,
origin: 'user',
projectId,
taskId,
billable,
tags: sortTags(tags),
});
return id;
}
function removeSlot(key: TimesheetRowKey) {
slots.value = slots.value.filter((s) => s.id !== key);
}
function updateSlot(key: TimesheetRowKey, identity: TimesheetRowIdentity) {
const slot = slots.value.find((s) => s.id === key);
if (!slot) return;
slot.projectId = identity.projectId;
slot.taskId = identity.taskId;
slot.billable = identity.billable;
slot.tags = sortTags(identity.tags);
}
function clearSlots() {
slots.value = [];
}
return {
rows,
dayTotals,
grandTotal,
slots,
addSlot,
removeSlot,
updateSlot,
clearSlots,
};
}

View File

@@ -0,0 +1,102 @@
import { useQuery, type QueryClient } from '@tanstack/vue-query';
import { api, type TimeEntry, type TimeEntryResponse } from '@/packages/api/src';
import { getCurrentMembershipId, getCurrentOrganizationId } from '@/utils/useUser';
import { computed, type Ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { localDateToUtc } from '@/packages/ui/src/utils/time';
function createTimesheetQueryKey(
start: string | null,
end: string | null,
organizationId: string | null
) {
return ['timeEntries', 'timesheet', { start, end, organization: organizationId }] as const;
}
async function fetchTimesheetEntries(
organizationId: string,
memberId: string | undefined,
start: string,
end: string
): Promise<TimeEntryResponse> {
const allEntries: TimeEntry[] = [];
while (true) {
const response = await api.getTimeEntries({
params: { organization: organizationId },
queries: {
start,
end,
member_id: memberId,
offset: allEntries.length || undefined,
},
});
if (response.data.length === 0) {
return { data: allEntries, meta: response.meta };
}
allEntries.push(...response.data);
if (allEntries.length >= response.meta.total) {
return { data: allEntries, meta: response.meta };
}
}
}
export function useTimesheetQuery(
weekStart: Ref<Dayjs | undefined>,
weekEnd: Ref<Dayjs | undefined>
) {
const enabled = computed(() => {
return !!getCurrentOrganizationId() && !!weekStart.value && !!weekEnd.value;
});
const dateRange = computed(() => {
if (!weekStart.value || !weekEnd.value) return { start: null, end: null };
return {
start: localDateToUtc(weekStart.value),
end: localDateToUtc(weekEnd.value),
};
});
return useQuery<TimeEntryResponse>({
queryKey: computed(() =>
createTimesheetQueryKey(
dateRange.value.start,
dateRange.value.end,
getCurrentOrganizationId()
)
),
enabled,
queryFn: async () => {
return fetchTimesheetEntries(
getCurrentOrganizationId() || '',
getCurrentMembershipId(),
dateRange.value.start!,
dateRange.value.end!
);
},
staleTime: 1000 * 30,
placeholderData: (previousData) => previousData,
});
}
export function prefetchTimesheetWeek(queryClient: QueryClient, weekStart: Dayjs, weekEnd: Dayjs) {
const start = localDateToUtc(weekStart);
const end = localDateToUtc(weekEnd);
const organizationId = getCurrentOrganizationId();
const memberId = getCurrentMembershipId();
if (!organizationId) return;
const queryKey = createTimesheetQueryKey(start, end, organizationId);
queryClient.prefetchQuery({
queryKey,
queryFn: () => fetchTimesheetEntries(organizationId, memberId, start, end),
staleTime: 1000 * 30,
});
}
export { fetchTimesheetEntries };

View File

@@ -308,6 +308,22 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_show_endpoint_fails_if_employee_tries_to_access_public_project(): void
{
// Arrange
// Employees do not have the projects:view:all permission that the show endpoint requires,
// so they are forbidden even from public projects (they list them via the index endpoint instead).
$data = $this->createUserWithRole(Role::Employee);
$publicProject = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $publicProject->getKey()]));
// Assert
$response->assertForbidden();
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
{
// Arrange
@@ -327,6 +343,29 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_store_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Project::class, [
'name' => $projectFake->name,
'organization_id' => $data->organization->getKey(),
]);
}
public function test_store_endpoint_highest_possible_billable_rate_can_be_stored_in_database(): void
{
// Arrange
@@ -668,6 +707,124 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_store_endpoint_creates_public_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => true,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => true,
]);
}
public function test_store_endpoint_creates_private_project_if_is_public_is_false(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => false,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => false,
]);
}
public function test_store_endpoint_creates_private_project_by_default_if_is_public_is_not_given(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
]);
// Assert
$response->assertStatus(201);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'organization_id' => $projectFake->organization_id,
'is_public' => false,
]);
}
public function test_store_endpoint_fails_if_is_public_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:create',
]);
$projectFake = Project::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => null,
'is_billable' => $projectFake->is_billable,
'is_public' => 'public',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['is_public']);
}
public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void
{
// Arrange
@@ -713,6 +870,30 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_update_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => 'Employee Updated Name',
'color' => $project->color,
'client_id' => null,
'is_billable' => $project->is_billable,
]);
// Assert
$response->assertForbidden();
$this->assertDatabaseMissing(Project::class, [
'id' => $project->getKey(),
'name' => 'Employee Updated Name',
]);
}
public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
{
// Arrange
@@ -957,6 +1138,120 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$this->assertFalse($project->is_archived);
}
public function test_update_endpoint_can_make_a_private_project_public(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => true,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_can_make_a_public_project_private(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => false,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', false)
->etc()
);
$project->refresh();
$this->assertFalse($project->is_public);
}
public function test_update_endpoint_keeps_project_visibility_if_is_public_is_not_given(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
$this->assertBillableRateServiceIsUnused();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_public', true)
->etc()
);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_fails_if_is_public_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
'name' => $project->name,
'color' => $project->color,
'is_billable' => $project->is_billable,
'client_id' => null,
'is_public' => 'public',
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['is_public']);
$project->refresh();
$this->assertTrue($project->is_public);
}
public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void
{
// Arrange
@@ -1175,6 +1470,23 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden();
}
public function test_destroy_endpoint_fails_if_user_is_employee(): void
{
// Arrange
$data = $this->createUserWithRole(Role::Employee);
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
// Assert
$response->assertForbidden();
$this->assertDatabaseHas(Project::class, [
'id' => $project->getKey(),
]);
}
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void
{
// Arrange

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Models\OrganizationInvitation;
use App\Providers\JetstreamServiceProvider;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Jetstream\Jetstream;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(JetstreamServiceProvider::class)]
class TeamShowEndpointTest extends EndpointTestAbstract
{
protected function setUp(): void
{
Jetstream::$inertiaManager = null;
parent::setUp();
}
public function test_team_show_does_not_expose_member_roster_invitations_or_owner_email(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'email' => 'pending@example.com',
]);
$this->actingAs($data->user);
// Act
$response = $this->get('/teams/'.$data->organization->getKey());
// Assert
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->missing('team.users')
->missing('team.team_invitations')
->missing('team.owner.email')
->has('team.owner.id')
->has('team.owner.name')
->has('team.owner.profile_photo_url')
);
}
}

View File

@@ -10,12 +10,57 @@ use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(TimeEntryFilter::class)]
class TimeEntryFilterTest extends TestCaseWithDatabase
{
public function test_add_start_is_inclusive_of_boundary(): void
{
// Arrange
$boundary = Carbon::parse('2024-01-01 12:00:00', 'UTC');
$entryAtBoundary = TimeEntry::factory()->start($boundary)->create();
$entryAfterBoundary = TimeEntry::factory()->start($boundary->copy()->addSecond())->create();
$entryBeforeBoundary = TimeEntry::factory()->start($boundary->copy()->subSecond())->create();
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addStart($boundary);
// Assert
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertTrue($timeEntries->contains($entryAtBoundary));
$this->assertTrue($timeEntries->contains($entryAfterBoundary));
$this->assertFalse($timeEntries->contains($entryBeforeBoundary));
}
public function test_add_end_is_exclusive_of_boundary(): void
{
// Arrange
$boundary = Carbon::parse('2024-01-01 12:00:00', 'UTC');
$entryAtBoundary = TimeEntry::factory()->start($boundary)->create();
$entryAfterBoundary = TimeEntry::factory()->start($boundary->copy()->addSecond())->create();
$entryBeforeBoundary = TimeEntry::factory()->start($boundary->copy()->subSecond())->create();
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addEnd($boundary);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($entryBeforeBoundary));
$this->assertFalse($timeEntries->contains($entryAtBoundary));
$this->assertFalse($timeEntries->contains($entryAfterBoundary));
}
public function test_add_tag_ids_filter_is_or(): void
{
// Arrange

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath } from 'node:url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),
},
},
test: {
environment: 'happy-dom',
globals: false,
setupFiles: ['./resources/js/test-setup.ts'],
include: ['resources/js/**/*.{test,spec}.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/e2e/**', '**/dist/**'],
},
});