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
64 changed files with 5944 additions and 1430 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'

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

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

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

@@ -1,5 +1,8 @@
<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,
@@ -7,18 +10,40 @@ import {
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
import type { CellSaveStatus } from '@/utils/timesheet/useTimesheetCellMutations';
defineProps<{
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>
@@ -46,18 +71,26 @@ const emit = defineEmits<{
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip>
</TooltipProvider>
<DurationSecondsInput
v-else
:model-value="cell?.totalSeconds ?? 0"
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
hover:bg-card-background
focus-visible:bg-tertiary focus-visible:border-transparent
focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@commit="(seconds) => emit('update', seconds ?? 0)" />
<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

@@ -15,6 +15,7 @@ import type {
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();
@@ -36,6 +37,8 @@ defineProps<{
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<{
@@ -60,7 +63,7 @@ const emit = defineEmits<{
class="grid min-w-full w-max border-y border-default-background-separator"
style="
grid-template-columns:
minmax(420px, 1fr) repeat(7, minmax(96px, 120px)) minmax(100px, auto)
minmax(420px, 1fr) repeat(7, minmax(116px, 120px)) minmax(100px, auto)
40px;
">
<!-- Header row -->
@@ -100,6 +103,8 @@ const emit = defineEmits<{
: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)
@@ -124,6 +129,7 @@ const emit = defineEmits<{
: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">
@@ -144,7 +150,7 @@ const emit = defineEmits<{
:key="dayIndex"
data-testid="timesheet_day_total"
:class="[
'flex items-center justify-center border-t border-default-background-separator bg-secondary px-2 py-1 text-xs font-medium',
'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',
@@ -154,10 +160,11 @@ const emit = defineEmits<{
</span>
</div>
<div
class="flex items-center justify-end border-t border-default-background-separator bg-secondary pl-3 pr-3 py-1 text-xs font-semibold text-text-primary">
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-secondary"></div>
<div
class="border-t border-default-background-separator bg-background dark:bg-secondary"></div>
</div>
</div>
</div>

View File

@@ -15,6 +15,10 @@ import type {
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');
@@ -34,6 +38,8 @@ const props = defineProps<{
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<{
@@ -109,6 +115,8 @@ function hasRunningEntry(dayIndex: number): boolean {
: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 -->
@@ -124,6 +132,7 @@ function hasRunningEntry(dayIndex: number): boolean {
<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" />

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

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

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

@@ -90,7 +90,12 @@ const weekRangeDisplay = computed(() => {
});
// ── Cell / row mutation handlers ──────────────────────────────────
const { handleCellUpdate } = useTimesheetCellMutations(weekDays, timeEntries, rows, removeSlot);
const { handleCellUpdate, cellStatus, cellPendingSeconds } = useTimesheetCellMutations(
weekDays,
timeEntries,
rows,
removeSlot
);
const { handleRowIdentityChange, handleAddRow } = useTimesheetRowMutations(
mutations,
@@ -167,6 +172,8 @@ async function createTag(name: string): Promise<Tag | undefined> {
: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="

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

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

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

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

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

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

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

@@ -28,10 +28,14 @@ interface Interval {
function localDayBounds(date: string, tz: string): { dayStart: Dayjs; dayEnd: Dayjs } {
const dayjs = getDayJsInstance();
const localDayStart = dayjs.tz(`${date} 00:00:00`, tz);
// `.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: localDayStart.utc(),
dayEnd: localDayStart.add(1, 'day').utc(),
dayStart: dayjs.tz(`${date} 00:00:00`, tz).utc(),
dayEnd: dayjs.tz(`${nextDate} 00:00:00`, tz).utc(),
};
}

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

@@ -1,4 +1,4 @@
import type { Ref } from 'vue';
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';
@@ -19,6 +19,17 @@ import {
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:
@@ -48,15 +59,58 @@ export function useTimesheetCellMutations(
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
@@ -74,7 +128,9 @@ export function useTimesheetCellMutations(
'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,
@@ -93,7 +149,6 @@ export function useTimesheetCellMutations(
'Failed to update timesheet',
'Please try again later.'
);
throw err;
} finally {
queryClient.invalidateQueries({ queryKey: ['timeEntries'] });
}
@@ -316,5 +371,5 @@ export function useTimesheetCellMutations(
return best;
}
return { handleCellUpdate };
return { handleCellUpdate, cellStatus, cellPendingSeconds };
}

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

@@ -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')
);
}
}

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/**'],
},
});