Compare commits

..

35 Commits

Author SHA1 Message Date
Gregor Vostrak
e56386d2c3 remove special “Add new project” state in TimeTrackerProjectTaskDropdown 2026-05-19 14:39:21 +02:00
Gregor Vostrak
7f75e5f1b5 set min release age for npm packages to 7 days to prevent supply chain attacks 2026-05-12 19:21:21 +02:00
Gregor Vostrak
8941aec72b remove timetrackerprojecttaskdropdown test without setup 2026-05-12 17:33:26 +02:00
Gregor Vostrak
bc1f4deaa0 fix DST boundary issue in timesheets 2026-05-12 17:25:46 +02:00
Gregor Vostrak
d292396dd4 fix "No project" duplicating rows, unify no project senitel to null 2026-05-12 17:02:26 +02:00
Gregor Vostrak
f926e8fde3 change TimeEntryFilter start filter to be inclusive 2026-05-12 16:55:01 +02:00
Gregor Vostrak
b660486eb7 fix e2e tests 2026-05-11 19:00:37 +02:00
Gregor Vostrak
61db23fc01 fix formatting 2026-05-11 17:28:29 +02:00
Gregor Vostrak
ab761d97f9 clamp running time entry duration to min 0 for FullCalendarHeaderDuration calc 2026-05-11 17:20:28 +02:00
Gregor Vostrak
50d60393bc fix formatting 2026-05-11 17:18:57 +02:00
Gregor Vostrak
b5639e4bb2 add missing dayjs plugins for isSameOrBefore and isSameOrAfter 2026-05-11 17:08:15 +02:00
Gregor Vostrak
db4af2bcac add timesheets page 2026-04-30 02:50:02 +02:00
Gregor Vostrak
b73aa543fd Merge commit from fork 2026-04-21 21:12:30 +02:00
Gregor Vostrak
2d6f9e514f add groupSimilarTimeEntries to TimeEntryGroupedTable 2026-04-21 20:44:33 +02:00
Gregor Vostrak
f8e668790b Fix typo in project name in README.md 2026-04-18 04:27:50 +02:00
utlark
77a5e979c6 Added the ability to disable group similar time entries (#1054)
* Added the ability to disable group similar time entries

* Fix E2E test for Group similar time entries

* Simplify `TimeEntryGroupedTable` by replacing ternary with early return logic

* Refactor time entry grouping settings: rename storage key, move logic into a dedicated module

* Replace fixed `waitForTimeout` calls in E2E tests with element-based waits and assertions

* Run frontend linting and formatting for changes
2026-04-17 16:44:59 +02:00
Gregor Vostrak
353a579850 chore: bump ui package version 2026-04-17 14:46:36 +02:00
Gregor Vostrak
bd44a2b376 fix e2e tests for new duration reporting format logic 2026-04-17 14:36:56 +02:00
Gregor Vostrak
277dbaf6eb promote duration formats that omit seconds to HH:mm:ss in reporting
views and exports
2026-04-17 12:15:26 +02:00
Gregor Vostrak
1cf33ddb3f improve dark mode color palette; rework font weights throughout the
interface
2026-04-15 15:35:20 +02:00
Gregor Vostrak
84cd0d572d bump ui package version 2026-04-08 23:18:29 +02:00
Gregor Vostrak
f37b86f377 chore: remove unused formatActivityDuration function 2026-04-08 14:49:37 +02:00
Gregor Vostrak
1e7364fc4b show calendar activities more prominently when no time entry exists 2026-04-08 14:43:09 +02:00
Gregor Vostrak
8cbc9838c9 fix minimal layout shift on time entry select and migrate to ui button 2026-04-07 21:42:34 +02:00
Gregor Vostrak
71c8992e31 Fix getLocalizedDayJsFromMinutes handling negative minute values 2026-03-31 13:56:30 +02:00
Gregor Vostrak
53d91b65d6 fix: use timezoned dates in public report endpoint tests
Replace travelTo + now() with Carbon::now($timezone)->startOfDay() to eliminate flakiness when tests run near midnight UTC, where the UTC and Vienna dates can differ.
2026-03-31 13:21:54 +02:00
Gregor Vostrak
0c88a10eb5 improve calendar current day styling 2026-03-30 00:58:40 +02:00
Gregor Vostrak
dd7b23958a fix gotenberg url in CI 2026-03-30 00:07:57 +02:00
Gregor Vostrak
1eb066f5aa Add E2E test for project name prefill 2026-03-29 23:55:10 +02:00
ShrootBuck
b1287c6a0a Prefill project name in create modal
Add optional initialProjectName prop to ProjectCreateModal and use it
to initialize the project's name. Pass the TimeTracker dropdown's
searchValue as initial-project-name so the create form is prefilled.
2026-03-29 23:55:10 +02:00
Gregor Vostrak
815abb5980 improve drag handle hit area and activity tooltip placement 2026-03-29 23:14:01 +02:00
Gregor Vostrak
e2f859be27 fix calendar scroll down on load; bump ui package version 2026-03-29 23:02:22 +02:00
Gregor Vostrak
3d26fcaefe Fix DST-related timezone offset when creating/resizing/dragging calendar
events
2026-03-29 22:55:50 +02:00
Gregor Vostrak
1e73a90f9d chore: bump ui version 2026-03-29 22:09:01 +02:00
Gregor Vostrak
0f8f906e5c clarify naming on activity type 2026-03-27 00:37:29 +01:00
94 changed files with 3236 additions and 412 deletions

View File

@@ -60,7 +60,7 @@ AUDITING_ENABLED=true
TELESCOPE_ENABLED=false TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://0.0.0.0:3000 GOTENBERG_URL=http://localhost:3000
# Octane # Octane
OCTANE_SERVER=frankenphp OCTANE_SERVER=frankenphp

View File

@@ -77,6 +77,9 @@ TELESCOPE_ENABLED=false
# Services # Services
GOTENBERG_URL=http://gotenberg:3000 GOTENBERG_URL=http://gotenberg:3000
# Octane
OCTANE_SERVER=frankenphp
# Local setup # Local setup
NGINX_HOST_NAME=solidtime.test NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing NETWORK_NAME=reverse-proxy-docker-traefik_routing

1
.npmrc Normal file
View File

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

View File

@@ -1,4 +1,4 @@
# solidtime - The modern Open-Source Time Tracker # solidtime - The modern Open-Source TimeTracker
[![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md) [![GitHub License](https://img.shields.io/github/license/solidtime-io/solidtime?style=flat-square)](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
[![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime) [![Codecov](https://img.shields.io/codecov/c/github/solidtime-io/solidtime?style=flat-square&logo=codecov)](https://codecov.io/gh/solidtime-io/solidtime)

View File

@@ -629,9 +629,9 @@ class TimeEntryController extends Controller
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
$this->checkPermission($organization, 'time-entries:update:own'); $this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all'); $this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
} }
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {

View File

@@ -96,6 +96,30 @@ class LocalizationService
} }
} }
/**
* Format a duration for reporting contexts (PDF reports, places that display duration
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
* so totals stay narrow and reconcile with cost, which is always computed to the second.
*/
public function formatIntervalForReporting(CarbonInterval $interval): string
{
$promoted = [
IntervalFormat::HoursMinutes,
IntervalFormat::HoursMinutesColonSeparated,
];
if (! in_array($this->intervalFormat, $promoted, true)) {
return $this->formatInterval($interval);
}
$previous = $this->intervalFormat;
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
try {
return $this->formatInterval($interval);
} finally {
$this->intervalFormat = $previous;
}
}
public function formatCurrency(Money $money): string public function formatCurrency(Money $money): string
{ {
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);

View File

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

View File

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

View File

@@ -230,6 +230,37 @@ test('test that theme can be changed to dark and light', async ({ page }) => {
await expect(page.getByText('System default:')).toBeVisible(); await expect(page.getByText('System default:')).toBeVisible();
}); });
// =============================================
// Group similar time entries
// =============================================
test('test that group similar time entries setting can be toggled', async ({ page }) => {
await goToProfilePage(page);
// Get the checkbox
const checkbox = page.getByLabel('Group similar time entries');
// Get initial value and verify it is checked (default is true)
const initialValue = await checkbox.isChecked();
await expect(checkbox).toBeChecked();
// Toggle the checkbox
await checkbox.click();
// Reload
await page.reload();
// Verify the value is toggled
const afterValue = await page.getByLabel('Group similar time entries').isChecked();
expect(afterValue).toBe(!initialValue);
// Verify localStorage persists the setting
const storedValue = await page.evaluate(() =>
localStorage.getItem('group-similar-time-entries')
);
expect(storedValue).toBe(String(!initialValue));
});
// ============================================= // =============================================
// Two Factor Authentication Tests // Two Factor Authentication Tests
// ============================================= // =============================================

View File

@@ -640,7 +640,7 @@ test('test that creating a project with estimated time in human-readable format
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using human-readable format // Fill in estimated time using human-readable format
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2h 30m'); await estimatedTimeInput.fill('2h 30m');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -668,7 +668,7 @@ test('test that creating a project with estimated time using decimal notation wo
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using decimal notation (1.5 hours = 1h 30m) // Fill in estimated time using decimal notation (1.5 hours = 1h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('1.5'); await estimatedTimeInput.fill('1.5');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -696,7 +696,7 @@ test('test that creating a project with estimated time using comma decimal notat
await page.getByLabel('Project Name').fill(newProjectName); await page.getByLabel('Project Name').fill(newProjectName);
// Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m) // Fill in estimated time using comma decimal notation (2,5 hours = 2h 30m)
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('2,5'); await estimatedTimeInput.fill('2,5');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -727,7 +727,7 @@ test('test that updating estimated time on existing project works', async ({ pag
await page.getByRole('menuitem').getByText('Edit').first().click(); await page.getByRole('menuitem').getByText('Edit').first().click();
// Fill in estimated time // Fill in estimated time
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); const estimatedTimeInput = page.getByLabel('Time Estimated');
await estimatedTimeInput.fill('4h 15m'); await estimatedTimeInput.fill('4h 15m');
await estimatedTimeInput.press('Tab'); await estimatedTimeInput.press('Tab');
@@ -748,7 +748,7 @@ test('test that estimated time input displays formatted value after blur', async
await goToProjectsOverview(page); await goToProjectsOverview(page);
await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByRole('button', { name: 'Create Project' }).click();
const estimatedTimeInput = page.getByPlaceholder('e.g. 2h 30m or 1.5'); const estimatedTimeInput = page.getByLabel('Time Estimated');
// Enter time in various formats and check the displayed value // Enter time in various formats and check the displayed value
await estimatedTimeInput.fill('90'); await estimatedTimeInput.fill('90');

View File

@@ -32,7 +32,7 @@ test('test that detailed view shows time entries correctly', async ({ page, ctx
// Verify the time entry is shown with all details // Verify the time entry is shown with all details
await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible(); await expect(page.getByText(projectName, { exact: true }).first()).toBeVisible();
await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1h 00min'); await expect(page.locator('input[name="Duration"]').first()).toHaveValue('1:00:00');
await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible(); await expect(page.getByText('Entry for ' + projectName, { exact: true }).first()).toBeVisible();
}); });
@@ -62,8 +62,8 @@ test('test that updating duration in detailed view works correctly', async ({ pa
), ),
]); ]);
// Verify the new duration is displayed // Verify the new duration is displayed (reporting views promote to HH:MM:SS format)
await expect(durationInput).toHaveValue(updatedDuration); await expect(durationInput).toHaveValue('2:30:00');
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -333,7 +333,7 @@ test('test that task filtering works in reporting', async ({ page, ctx }) => {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
// Verify the report only shows 1h (task1's duration) // Verify the report only shows 1h (task1's duration)
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
}); });
test('test that task multiselect search filters the option list', async ({ page, ctx }) => { test('test that task multiselect search filters the option list', async ({ page, ctx }) => {
@@ -474,7 +474,7 @@ test('test that tag filtering works in reporting', async ({ page, ctx }) => {
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
// Verify only time entries with tag1 are shown // Verify only time entries with tag1 are shown
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
}); });
test('test that tag dropdown search filters the option list', async ({ page, ctx }) => { test('test that tag dropdown search filters the option list', async ({ page, ctx }) => {
@@ -594,7 +594,7 @@ test('test that billable status filtering works in reporting', async ({ page, ct
waitForReportingUpdate(page), waitForReportingUpdate(page),
]); ]);
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
}); });
test('test that billable filter can switch between all three states', async ({ page }) => { test('test that billable filter can switch between all three states', async ({ page }) => {
@@ -885,7 +885,7 @@ test.describe('Employee Reporting Restrictions', () => {
// Employee's data should be visible (1h) // Employee's data should be visible (1h)
await expect( await expect(
employee.page.getByTestId('reporting_view').getByText('1h 00min').first() employee.page.getByTestId('reporting_view').getByText('1:00:00').first()
).toBeVisible(); ).toBeVisible();
}); });

View File

@@ -292,8 +292,8 @@ test('test that shared report respects task filter', async ({ page, ctx }) => {
await page.goto(shareableLink); await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible(); await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible(); await expect(page.getByText('3:00:00')).not.toBeVisible();
}); });
test('test that shared report respects client filter', async ({ page, ctx }) => { test('test that shared report respects client filter', async ({ page, ctx }) => {
@@ -369,8 +369,8 @@ test('test that shared report respects tag filter', async ({ page, ctx }) => {
await page.goto(shareableLink); await page.goto(shareableLink);
await expect(page.getByText('Reporting')).toBeVisible(); await expect(page.getByText('Reporting')).toBeVisible();
await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText('1h 00min').first()).toBeVisible(); await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible(); await expect(page.getByText('3:00:00')).not.toBeVisible();
}); });
test('test that shared report respects member filter', async ({ page, ctx }) => { test('test that shared report respects member filter', async ({ page, ctx }) => {
@@ -425,7 +425,7 @@ test('test that shared report with billable filter only shows billable entries',
]); ]);
// Verify only 1h shows before saving // Verify only 1h shows before saving
await expect(page.getByTestId('reporting_view').getByText('1h 00min').first()).toBeVisible(); await expect(page.getByTestId('reporting_view').getByText('1:00:00').first()).toBeVisible();
const { shareableLink } = await saveAsSharedReport(page, reportName); const { shareableLink } = await saveAsSharedReport(page, reportName);
@@ -435,8 +435,8 @@ test('test that shared report with billable filter only shows billable entries',
await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible();
// Shared report should only show the 1h billable entry, not the 2h non-billable // Shared report should only show the 1h billable entry, not the 2h non-billable
await expect(page.getByText('1h 00min').first()).toBeVisible(); await expect(page.getByText('1:00:00').first()).toBeVisible();
await expect(page.getByText('3h 00min')).not.toBeVisible(); await expect(page.getByText('3:00:00')).not.toBeVisible();
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -39,6 +39,10 @@ function getMonthFromTimestamp(timestamp: string): number {
return new Date(timestamp).getUTCMonth() + 1; return new Date(timestamp).getUTCMonth() + 1;
} }
async function goToProfilePage(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
}
async function goToTimeOverview(page: Page) { async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await page.goto(PLAYWRIGHT_BASE_URL + '/time');
} }
@@ -67,6 +71,14 @@ async function createEmptyTimeEntry(page: Page) {
]); ]);
} }
async function setTimeEntriesGrouping(page: Page, enabled: boolean) {
await goToProfilePage(page);
const checkbox = page.getByLabel('Group similar time entries');
const isChecked = await checkbox.isChecked();
if (isChecked !== enabled) await checkbox.click();
await goToTimeOverview(page);
}
test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({ test('test that starting and stopping an empty time entry shows a new time entry in the overview', async ({
page, page,
}) => { }) => {
@@ -333,6 +345,30 @@ test.skip('test that load more works when the end of page is reached', async ({
await expect(page.locator('body')).toHaveText(/All time entries are loaded!/); await expect(page.locator('body')).toHaveText(/All time entries are loaded!/);
}); });
test('test that Group similar time entries option is affected', async ({ page }) => {
// Enable grouping
await setTimeEntriesGrouping(page, true);
// Create 2 similar time entries
await createEmptyTimeEntry(page);
await page.waitForSelector('[data-testid="time_entry_row"]', { timeout: 1000 });
await createEmptyTimeEntry(page);
// Verify similar time entries are grouped
await expect(page.getByTestId('grouped_items_count_button').first()).toBeVisible({
timeout: 1000,
});
// Disable grouping
await setTimeEntriesGrouping(page, false);
// Verify similar time entries are not grouped
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2, { timeout: 1000 });
await expect(page.locator('[data-testid="grouped_items_count_button"]')).toHaveCount(0, {
timeout: 1000,
});
});
// TODO: Test that updating the time entry start / end times works while it is running // TODO: Test that updating the time entry start / end times works while it is running
// TODO: Test for project update // TODO: Test for project update

View File

@@ -9,7 +9,7 @@ import {
} from './utils/currentTimeEntry'; } from './utils/currentTimeEntry';
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags'; import { newTagResponse } from './utils/tags';
import { updateOrganizationCurrencyViaWeb } from './utils/api'; import { createProjectViaApi, updateOrganizationCurrencyViaWeb } from './utils/api';
// Date picker button name patterns for different date formats // Date picker button name patterns for different date formats
const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/; const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
@@ -368,6 +368,45 @@ test('test that timer started on dashboard is visible on time page', async ({ pa
await assertThatTimerIsStopped(page); await assertThatTimerIsStopped(page);
}); });
test('test that creating a new project from the time tracker dropdown prefills the search text', async ({
page,
ctx,
}) => {
const existingProjectName = 'Existing Project ' + Math.floor(Math.random() * 10000);
const searchText = 'PrefillProject ' + Math.floor(Math.random() * 10000);
// Create a project so the dropdown renders (not the "Add new project" button)
await createProjectViaApi(ctx, { name: existingProjectName });
await goToDashboard(page);
// Open the project dropdown
await page.getByRole('button', { name: 'No Project' }).click();
// Type a search term that won't match any existing project
await page.getByTestId('client_dropdown_search').fill(searchText);
// Click "Create new Project"
await page.getByText('Create new Project').click();
// Verify the project name input is pre-filled with the search text
await expect(page.getByLabel('Project name')).toHaveValue(searchText);
// Complete project creation to verify full flow works
await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/projects') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.name === searchText
),
page.getByRole('button', { name: 'Create Project' }).click(),
]);
// The project dropdown should now show the newly created project
await expect(page.getByRole('button', { name: searchText })).toBeVisible();
});
test('test that adding a project and tag before starting timer works', async ({ page }) => { test('test that adding a project and tag before starting timer works', async ({ page }) => {
const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000); const newTagName = 'TimerTag ' + Math.floor(Math.random() * 10000);
await goToDashboard(page); await goToDashboard(page);

8
package-lock.json generated
View File

@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "solidtime",
"workspaces": [ "workspaces": [
"resources/js/packages/ui", "resources/js/packages/ui",
"resources/js/packages/api" "resources/js/packages/api"
@@ -7412,10 +7413,13 @@
}, },
"resources/js/packages/ui": { "resources/js/packages/ui": {
"name": "@solidtime/ui", "name": "@solidtime/ui",
"version": "0.0.16", "version": "0.0.17",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"vite-plugin-dts": "^4.0.3" "@types/chroma-js": "^3.1.0",
"@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.0.3",
"zod": "^3.23.8"
}, },
"peerDependencies": { "peerDependencies": {
"@floating-ui/vue": "^1.1.4", "@floating-ui/vue": "^1.1.4",

View File

@@ -57,11 +57,11 @@ const showEditModal = ref(false);
</span> </span>
</div> </div>
<div <div
class="whitespace-nowrap flex items-center px-3 py-4 text-sm font-medium text-text-primary"> class="whitespace-nowrap flex items-center px-3 py-4 text-sm text-text-primary">
<span class="text-text-secondary"> {{ projectCount }} Projects </span> <span> {{ projectCount }} Projects </span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
<template v-if="client.is_archived"> <template v-if="client.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span> <span>Archived</span>

View File

@@ -83,27 +83,28 @@ const userHasValidMailAddress = computed(() => {
{{ member.name }} {{ member.name }}
</span> </span>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ member.email }} {{ member.email }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ capitalizeFirstLetter(member.role) }} {{ capitalizeFirstLetter(member.role) }}
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ <span v-if="member.billable_rate">
member.billable_rate {{
? formatCents( formatCents(
member.billable_rate, member.billable_rate,
organization?.currency, organization?.currency,
organization?.currency_format, organization?.currency_format,
organization?.currency_symbol, organization?.currency_symbol,
organization?.number_format organization?.number_format
) )
: '--' }}
}} </span>
<span v-else class="text-text-tertiary"> -- </span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center">
<template v-if="member.is_placeholder === false"> <template v-if="member.is_placeholder === false">
<CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon> <CheckCircleIcon class="w-4 text-icon-default"></CheckCircleIcon>
<span>Active</span> <span>Active</span>

View File

@@ -72,7 +72,7 @@ const billableRateInfo = computed(() => {
return 'Default Rate'; return 'Default Rate';
} }
} }
return '--'; return null;
}); });
const showEditProjectModal = ref(false); const showEditProjectModal = ref(false);
@@ -98,13 +98,13 @@ const showEditProjectModal = ref(false);
</span> </span>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span> <span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div> </div>
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden"> <div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
{{ client?.name }} {{ client?.name }}
</div> </div>
<div v-else>No client</div> <div v-else class="text-text-tertiary">No client</div>
</div> </div>
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
<div v-if="project.spent_time"> <div v-if="project.spent_time">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
@@ -114,23 +114,24 @@ const showEditProjectModal = ref(false);
) )
}} }}
</div> </div>
<div v-else>--</div> <div v-else class="text-text-tertiary">--</div>
</div> </div>
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary"> <div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge> <UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
<EstimatedTimeProgress <EstimatedTimeProgress
v-else-if="project.estimated_time" v-else-if="project.estimated_time"
:estimated="project.estimated_time" :estimated="project.estimated_time"
:current="project.spent_time"></EstimatedTimeProgress> :current="project.spent_time"></EstimatedTimeProgress>
<span v-else> -- </span> <span v-else class="text-text-tertiary"> -- </span>
</div> </div>
<div <div
v-if="showBillableRate" v-if="showBillableRate"
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary"> class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
{{ billableRateInfo }} <span v-if="billableRateInfo">{{ billableRateInfo }}</span>
<span v-else class="text-text-tertiary">--</span>
</div> </div>
<div <div
class="whitespace-nowrap px-3 py-4 text-sm text-text-secondary flex space-x-1.5 items-center font-medium"> 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_archived"> <template v-if="project.is_archived">
<ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon> <ArchiveBoxIcon class="w-4 text-icon-default"></ArchiveBoxIcon>
<span>Archived</span> <span>Archived</span>

View File

@@ -2,7 +2,7 @@
import VChart, { THEME_KEY } from 'vue-echarts'; import VChart, { THEME_KEY } from 'vue-echarts';
import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue'; import { computed, provide, inject, shallowRef, type ComputedRef } from 'vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import { formatDate, formatHumanReadableDuration, formatWeek } from '@/packages/ui/src/utils/time'; import { formatDate, formatReportingDuration, formatWeek } from '@/packages/ui/src/utils/time';
import { use } from 'echarts/core'; import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts'; import { BarChart } from 'echarts/charts';
@@ -137,7 +137,7 @@ const option = computed(() => ({
type: 'bar', type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatReportingDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format

View File

@@ -8,7 +8,7 @@ import {
import { SaveIcon } from 'lucide-vue-next'; import { SaveIcon } from 'lucide-vue-next';
import { getOrganizationCurrencyString } from '@/utils/money'; import { getOrganizationCurrencyString } from '@/utils/money';
import { import {
formatHumanReadableDuration, formatReportingDuration,
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
@@ -426,7 +426,7 @@ const tableData = computed(() => {
class="justify-end flex items-center font-medium" class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''"> :class="!showBillableRate ? 'pr-6' : ''">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -10,7 +10,7 @@ import {
TitleComponent, TitleComponent,
TooltipComponent, TooltipComponent,
} from 'echarts/components'; } from 'echarts/components';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { useCssVariable } from '@/packages/ui/src'; import { useCssVariable } from '@/packages/ui/src';
import type { Organization } from '@/packages/api/src'; import type { Organization } from '@/packages/api/src';
@@ -67,7 +67,7 @@ const option = computed(() => ({
}, },
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatReportingDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue'; import GroupedItemsCountButton from '@/packages/ui/src/GroupedItemsCountButton.vue';
import { ref, inject, type ComputedRef } from 'vue'; import { ref, inject, type ComputedRef } from 'vue';
@@ -44,7 +44,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
</div> </div>
<div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''"> <div class="justify-end flex items-center" :class="!showCost ? 'pr-6' : ''">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
entry.seconds, entry.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -7,8 +7,8 @@ defineProps<{
<template> <template>
<div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5"> <div class="rounded-lg bg-card-background border-card-border shadow-card border px-3.5 py-2.5">
<dt class="font-semibold text-sm text-text-secondary">{{ title }}</dt> <dt class="font-medium text-sm text-text-secondary">{{ title }}</dt>
<dd class="text-xl text-text-primary pt-1 font-semibold"> <dd class="text-xl text-text-primary pt-1 font-medium">
{{ value ?? '--' }} {{ value ?? '--' }}
</dd> </dd>
</div> </div>

View File

@@ -23,7 +23,7 @@ defineProps<{
<div class="items-center justify-center flex-1 hidden @2xs:flex"> <div class="items-center justify-center flex-1 hidden @2xs:flex">
<DayOverviewCardChart :history="history"></DayOverviewCardChart> <DayOverviewCardChart :history="history"></DayOverviewCardChart>
</div> </div>
<div class="flex text-sm items-center justify-center text-text-secondary min-w-[65px]"> <div class="flex text-sm items-center justify-center text-text-primary min-w-[65px]">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(
duration, duration,

View File

@@ -47,9 +47,9 @@ async function startTaskTimer() {
<template> <template>
<div class="px-3.5 py-2 grid grid-cols-5"> <div class="px-3.5 py-2 grid grid-cols-5">
<div class="col-span-4"> <div class="col-span-4">
<p class="text-text-secondary text-sm pb-1.5 truncate"> <p class="text-text-primary text-sm pb-1.5 truncate">
<span v-if="timeEntry.description"> {{ timeEntry.description }}</span> <span v-if="timeEntry.description"> {{ timeEntry.description }}</span>
<span v-else>No description</span> <span v-else class="text-text-secondary">No description</span>
</p> </p>
<ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color"> <ProjectBadge size="base" class="min-w-0 max-w-full" :color="project?.color">
<div class="flex items-center lg:space-x-0.5 min-w-0"> <div class="flex items-center lg:space-x-0.5 min-w-0">

View File

@@ -48,7 +48,7 @@ const { data: latestTeamActivity, isLoading } = useQuery({
class="text-center flex flex-1 justify-center items-center"> class="text-center flex flex-1 justify-center items-center">
<div> <div>
<UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon> <UserGroupIcon class="w-8 text-icon-default inline pb-2"></UserGroupIcon>
<h3 class="text-text-primary font-semibold text-sm">Invite your co-workers</h3> <h3 class="text-text-primary font-medium text-sm">Invite your co-workers</h3>
<p class="pb-5 text-sm">You can invite your entire team.</p> <p class="pb-5 text-sm">You can invite your entire team.</p>
<SecondaryButton @click="router.visit(route('members'))" <SecondaryButton @click="router.visit(route('members'))"
>Go to Members >Go to Members

View File

@@ -11,7 +11,7 @@ defineProps<{
<div class="col-span-2"> <div class="col-span-2">
<div class="flex justify-between"> <div class="flex justify-between">
<p <p
class="text-xs min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-secondary"> class="text-sm font-medium min-w-0 overflow-ellipsis overflow-hidden flex-1 text-text-primary">
{{ name }} {{ name }}
</p> </p>
<div v-if="working" class="flex space-x-1.5 items-center justify-end"> <div v-if="working" class="flex space-x-1.5 items-center justify-end">
@@ -20,11 +20,11 @@ defineProps<{
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span> class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span> <span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span> </span>
<span class="text-green-500 font-medium text-sm block pb-0.5"> working </span> <span class="text-green-500 text-sm block pb-0.5"> working </span>
</div> </div>
</div> </div>
<div <div
class="text-text-secondary text-sm font-medium text-ellipsis whitespace-nowrap max-w-full overflow-hidden"> class="text-text-secondary text-sm text-ellipsis whitespace-nowrap max-w-full overflow-hidden">
{{ description }} {{ description }}
</div> </div>
</div> </div>

View File

@@ -16,7 +16,7 @@ import CardTitle from '@/packages/ui/src/CardTitle.vue';
import LinearGradient from 'zrender/lib/graphic/LinearGradient'; import LinearGradient from 'zrender/lib/graphic/LinearGradient';
import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue'; import ProjectsChartCard from '@/Components/Dashboard/ProjectsChartCard.vue';
import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue'; import ThisWeekReportingTable from '@/Components/Dashboard/ThisWeekReportingTable.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
import { getWeekStart } from '@/packages/ui/src/utils/settings'; import { getWeekStart } from '@/packages/ui/src/utils/settings';
import { useCssVariable } from '@/packages/ui/src'; import { useCssVariable } from '@/packages/ui/src';
@@ -223,7 +223,7 @@ const option = computed(() => {
type: 'bar', type: 'bar',
tooltip: { tooltip: {
valueFormatter: (value: number) => { valueFormatter: (value: number) => {
return formatHumanReadableDuration( return formatReportingDuration(
value, value,
organization?.value?.interval_format, organization?.value?.interval_format,
organization?.value?.number_format organization?.value?.number_format
@@ -252,7 +252,7 @@ const option = computed(() => {
title="Spent Time" title="Spent Time"
:value=" :value="
totalWeeklyTime totalWeeklyTime
? formatHumanReadableDuration( ? formatReportingDuration(
totalWeeklyTime, totalWeeklyTime,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format
@@ -263,7 +263,7 @@ const option = computed(() => {
title="Billable Time" title="Billable Time"
:value=" :value="
totalWeeklyBillableTime totalWeeklyBillableTime
? formatHumanReadableDuration( ? formatReportingDuration(
totalWeeklyBillableTime, totalWeeklyBillableTime,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -2,7 +2,7 @@
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue'; import ReportingGroupBySelect from '@/Components/Common/Reporting/ReportingGroupBySelect.vue';
import { import {
formatHumanReadableDuration, formatReportingDuration,
getDayJsInstance, getDayJsInstance,
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
@@ -174,7 +174,7 @@ const showBillableRate = computed(() => {
class="justify-end flex items-center font-medium" class="justify-end flex items-center font-medium"
:class="!showBillableRate ? 'pr-6' : ''"> :class="!showBillableRate ? 'pr-6' : ''">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
organization?.interval_format, organization?.interval_format,
organization?.number_format organization?.number_format

View File

@@ -28,7 +28,7 @@ const open = useSessionStorage('nav-collapse-state-' + props.title, true);
<CollapsibleRoot v-else v-model:open="open" <CollapsibleRoot v-else v-model:open="open"
><CollapsibleTrigger class="w-full group py-0.5"> ><CollapsibleTrigger class="w-full group py-0.5">
<div <div
class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-medium text-sm items-center justify-between"> class="text-text-secondary group-hover:text-text-primary group-hover:bg-menu-active group flex gap-x-2 rounded-md transition leading-6 py-0.5 px-2 font-regular text-sm items-center justify-between">
<div class="flex items-center gap-x-2"> <div class="flex items-center gap-x-2">
<component <component
:is="icon" :is="icon"

View File

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

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import DurationSecondsInput from '@/packages/ui/src/Input/DurationSecondsInput.vue';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/packages/ui/src/tooltip';
import type { TimesheetCell } from '@/utils/useTimesheetGrid';
defineProps<{
cell?: TimesheetCell;
dayIndex: number;
date: string;
isToday: boolean;
hasRunningEntry: boolean;
}>();
const emit = defineEmits<{
update: [newSeconds: number];
}>();
</script>
<template>
<div
data-testid="timesheet_cell"
class="flex items-center justify-center border-t border-default-background-separator"
:class="{ 'bg-default-background': isToday }">
<TooltipProvider v-if="hasRunningEntry" :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-block cursor-not-allowed">
<DurationSecondsInput
:model-value="cell?.totalSeconds ?? 0"
disabled
default-unit="hours"
placeholder="-"
size="sm"
input-class="w-[80px] mx-auto text-center font-medium
bg-transparent text-text-primary placeholder:text-text-quaternary
rounded-lg border border-input-border shadow-none
pointer-events-none
disabled:opacity-50 disabled:cursor-not-allowed" />
</span>
</TooltipTrigger>
<TooltipContent> Stop the running time entry to edit the timesheet </TooltipContent>
</Tooltip>
</TooltipProvider>
<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)" />
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,10 @@
import FormSection from '@/Components/FormSection.vue'; import FormSection from '@/Components/FormSection.vue';
import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field'; import { Field, FieldLabel, FieldDescription } from '@/packages/ui/src/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/packages/ui/src';
import { Checkbox } from '@/packages/ui/src';
import { usePreferredColorScheme } from '@vueuse/core'; import { usePreferredColorScheme } from '@vueuse/core';
import { themeSetting } from '@/utils/theme'; import { themeSetting } from '@/utils/theme';
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
const preferredColor = usePreferredColorScheme(); const preferredColor = usePreferredColorScheme();
</script> </script>
@@ -15,6 +17,7 @@ const preferredColor = usePreferredColorScheme();
<template #description> Choose how you want solidtime to look on your device </template> <template #description> Choose how you want solidtime to look on your device </template>
<template #form> <template #form>
<!-- Theme -->
<Field class="col-span-6 sm:col-span-4"> <Field class="col-span-6 sm:col-span-4">
<FieldLabel for="theme">Theme</FieldLabel> <FieldLabel for="theme">Theme</FieldLabel>
<Select id="theme" v-model="themeSetting"> <Select id="theme" v-model="themeSetting">
@@ -31,6 +34,14 @@ const preferredColor = usePreferredColorScheme();
System default: {{ preferredColor }} System default: {{ preferredColor }}
</FieldDescription> </FieldDescription>
</Field> </Field>
<!-- Group similar time entries -->
<Field class="col-span-6 sm:col-span-4" orientation="horizontal">
<Checkbox
id="group_similar_time_entries"
v-model:checked="groupSimilarTimeEntriesSetting" />
<FieldLabel for="group_similar_time_entries">Group similar time entries</FieldLabel>
</Field>
</template> </template>
</FormSection> </FormSection>
</template> </template>

View File

@@ -390,6 +390,7 @@ async function downloadExport(format: ExportFormat) {
:organization-billable-rate="organization?.billable_rate ?? null" :organization-billable-rate="organization?.billable_rate ?? null"
:duplicate-time-entry="() => createTimeEntry(entry)" :duplicate-time-entry="() => createTimeEntry(entry)"
:members="members" :members="members"
is-report
show-date show-date
show-member show-member
:time-entry="entry" :time-entry="entry"

View File

@@ -3,7 +3,7 @@ import MainContainer from '@/packages/ui/src/MainContainer.vue';
import PageTitle from '@/Components/Common/PageTitle.vue'; import PageTitle from '@/Components/Common/PageTitle.vue';
import { ChartBarIcon } from '@heroicons/vue/20/solid'; import { ChartBarIcon } from '@heroicons/vue/20/solid';
import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue'; import ReportingChart from '@/Components/Common/Reporting/ReportingChart.vue';
import { formatHumanReadableDuration } from '@/packages/ui/src/utils/time'; import { formatReportingDuration } from '@/packages/ui/src/utils/time';
import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue'; import ReportingRow from '@/Components/Common/Reporting/ReportingRow.vue';
import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue'; import ReportingPieChart from '@/Components/Common/Reporting/ReportingPieChart.vue';
import { formatCents } from '@/packages/ui/src/utils/money'; import { formatCents } from '@/packages/ui/src/utils/money';
@@ -231,7 +231,7 @@ onMounted(async () => {
</div> </div>
<div class="justify-end flex items-center font-medium"> <div class="justify-end flex items-center font-medium">
{{ {{
formatHumanReadableDuration( formatReportingDuration(
aggregatedTableTimeEntries.seconds, aggregatedTableTimeEntries.seconds,
reportIntervalFormat, reportIntervalFormat,
reportNumberFormat reportNumberFormat

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import FormSection from '@/Components/FormSection.vue'; import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { Field, FieldLabel } from '@/packages/ui/src/field'; import { Field, FieldDescription, FieldLabel } from '@/packages/ui/src/field';
import type { UpdateOrganizationBody } from '@/packages/api/src'; import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization'; import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -52,6 +52,12 @@ onMounted(async () => {
} }
}); });
const showsHhMmSsInReports = computed(
() =>
form.value.interval_format === 'hours-minutes' ||
form.value.interval_format === 'hours-minutes-colon-separated'
);
async function submit() { async function submit() {
mutation.mutate(form.value); mutation.mutate(form.value);
} }
@@ -149,6 +155,12 @@ async function submit() {
> >
</SelectContent> </SelectContent>
</Select> </Select>
<FieldDescription v-if="showsHhMmSsInReports">
Reports and totals shown next to cost use HH:MM:SS for this format, so the
duration reconciles with the billable amount down to the second. Everywhere else
(time tracker, calendar, entry rows) seconds are omitted and durations stay in
your chosen format.
</FieldDescription>
</Field> </Field>
</template> </template>

View File

@@ -16,6 +16,7 @@ import { useElementVisibility } from '@vueuse/core';
import { ClockIcon } from '@heroicons/vue/20/solid'; import { ClockIcon } from '@heroicons/vue/20/solid';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue'; import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
import { groupSimilarTimeEntriesSetting } from '@/utils/timeEntryGrouping';
import { useTasksQuery } from '@/utils/useTasksQuery'; import { useTasksQuery } from '@/utils/useTasksQuery';
import { useProjectsQuery } from '@/utils/useProjectsQuery'; import { useProjectsQuery } from '@/utils/useProjectsQuery';
import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue'; import TimeEntryGroupedTable from '@/packages/ui/src/TimeEntry/TimeEntryGroupedTable.vue';
@@ -151,6 +152,7 @@ function deleteSelected() {
:tasks="tasks" :tasks="tasks"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:time-entries="timeEntries" :time-entries="timeEntries"
:group-similar-time-entries="groupSimilarTimeEntriesSetting"
:tags="tags"></TimeEntryGroupedTable> :tags="tags"></TimeEntryGroupedTable>
<div v-if="isPending" class="flex justify-center items-center py-12"> <div v-if="isPending" class="flex justify-center items-center py-12">
<LoadingSpinner></LoadingSpinner> <LoadingSpinner></LoadingSpinner>

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ const sizeClasses = {
:disabled="loading" :disabled="loading"
:class=" :class="
twMerge( twMerge(
'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-semibold inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out', 'bg-button-secondary-background border border-button-secondary-border hover:bg-button-secondary-background-hover shadow-sm transition text-text-primary rounded-lg font-medium inline-flex items-center space-x-1.5 focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-2 focus-visible:ring-ring focus:border-transparent disabled:opacity-25 ease-in-out',
sizeClasses[props.size], sizeClasses[props.size],
props.class props.class
) )

View File

@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '..';
import type { DayEvent, ActivityBox } from './calendarTypes'; import type { DayEvent, ActivityBox } from './calendarTypes';
import type { WindowActivityInPeriod } from './activityTypes'; import type { WindowActivityInPeriod } from './activityTypes';
defineProps<{ const props = defineProps<{
dayStr: string; dayStr: string;
totalGridHeight: number; totalGridHeight: number;
hasActivityStatus: boolean; hasActivityStatus: boolean;
@@ -34,6 +34,8 @@ defineProps<{
getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[]; getActivityBoxActivities: (box: ActivityBox) => WindowActivityInPeriod[];
getActivityPercentage: (count: number, total: number) => string; getActivityPercentage: (count: number, total: number) => string;
getActivityText: (activity: WindowActivityInPeriod) => string; getActivityText: (activity: WindowActivityInPeriod) => string;
getTopActivity: (box: ActivityBox) => WindowActivityInPeriod | null;
isDayView: boolean;
// Selection // Selection
showSelection: boolean; showSelection: boolean;
@@ -46,6 +48,16 @@ defineProps<{
selectionEndHeight: number; selectionEndHeight: number;
}>(); }>();
function isUncoveredByEvents(abox: ActivityBox): boolean {
return !props.dayEvents.some((de) => {
const eTop = de.top;
const eBottom = de.top + de.height;
const aTop = abox.top;
const aBottom = abox.top + abox.height;
return eTop < aBottom && eBottom > aTop;
});
}
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void; (e: 'event-pointerdown', event: PointerEvent, dayEvent: DayEvent): void;
(e: 'event-keydown-enter', dayEvent: DayEvent): void; (e: 'event-keydown-enter', dayEvent: DayEvent): void;
@@ -55,6 +67,7 @@ const emit = defineEmits<{
dayEvent: DayEvent, dayEvent: DayEvent,
edge: 'start' | 'end' edge: 'start' | 'end'
): void; ): void;
(e: 'activity-pointerdown', event: PointerEvent): void;
}>(); }>();
</script> </script>
@@ -63,16 +76,20 @@ const emit = defineEmits<{
class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none" class="fc-timegrid-col relative border-r border-border bg-transparent pointer-events-none"
:class="{ :class="{
'has-activity-status': hasActivityStatus, 'has-activity-status': hasActivityStatus,
'activity-expanded': hasActivityStatus && isDayView,
}" }"
:data-date="dayStr" :data-date="dayStr"
:style="{ height: totalGridHeight + 'px' }"> :style="{ height: totalGridHeight + 'px' }">
<div <div
class="absolute inset-y-0 left-0.5 right-0.5" class="absolute inset-y-0 left-0.5 right-0.5"
:class="{ 'fc-events-inset': hasActivityStatus }"> :class="{
'fc-events-inset': hasActivityStatus && !isDayView,
'fc-events-inset-expanded': hasActivityStatus && isDayView,
}">
<div <div
v-for="dayEvent in dayEvents" v-for="dayEvent in dayEvents"
:key="dayEvent.event.id" :key="dayEvent.event.id"
class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card overflow-hidden border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1" class="fc-event group pointer-events-auto rounded-sm text-xs cursor-pointer shadow-card border border-border touch-none select-none hover:shadow-dropdown focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
:class="[ :class="[
getEventOpacityClass(dayEvent, dayStr), getEventOpacityClass(dayEvent, dayStr),
{ {
@@ -91,9 +108,7 @@ const emit = defineEmits<{
:aria-label="dayEvent.event.title" :aria-label="dayEvent.event.title"
role="button" role="button"
@pointerdown="emit('event-pointerdown', $event, dayEvent)" @pointerdown="emit('event-pointerdown', $event, dayEvent)"
@keydown.enter.prevent=" @keydown.enter.prevent="emit('event-keydown-enter', dayEvent)">
!dayEvent.event.isRunning && emit('event-keydown-enter', dayEvent)
">
<div <div
v-if="!dayEvent.isClippedStart" v-if="!dayEvent.isClippedStart"
class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100" class="fc-event-resizer fc-event-resizer-start absolute z-[99] w-full h-3 left-0 top-[-2px] cursor-row-resize flex items-center justify-center opacity-0 group-hover:opacity-100"
@@ -122,15 +137,47 @@ const emit = defineEmits<{
class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none" class="fc-timegrid-now-indicator-line absolute left-0 right-0 border-t-2 border-red-500 z-50 pointer-events-none"
:style="{ top: nowIndicatorTop + 'px' }"></div> :style="{ top: nowIndicatorTop + 'px' }"></div>
<TooltipProvider :delay-duration="0"> <TooltipProvider :disable-hoverable-content="true" :delay-duration="0">
<Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai"> <Tooltip v-for="(abox, ai) in activityBoxes" :key="'activity-' + ai">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<div <div
class="activity-status-box" class="activity-status-box"
:class="abox.isIdle ? 'idle' : 'active'" :class="[
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"></div> abox.isIdle ? 'idle' : 'active',
{
'activity-status-box-expanded': isDayView,
'activity-status-box-uncovered':
!isDayView &&
!abox.isIdle &&
getTopActivity(abox) &&
isUncoveredByEvents(abox),
},
]"
:style="{ top: abox.top + 'px', height: abox.height + 'px' }"
@pointerdown="emit('activity-pointerdown', $event)">
<div
v-if="
!abox.isIdle &&
getTopActivity(abox) &&
abox.height >= 16 &&
(isDayView || isUncoveredByEvents(abox))
"
class="activity-status-content">
<img
v-if="getTopActivity(abox)?.icon"
:src="getTopActivity(abox)!.icon!"
:alt="getTopActivity(abox)!.appName"
class="activity-status-icon" />
<div v-else class="activity-status-icon-fallback">
{{ getTopActivity(abox)!.appName.charAt(0).toUpperCase() }}
</div>
<span class="activity-status-label">
{{ getTopActivity(abox)!.label || getTopActivity(abox)!.appName }}
</span>
</div>
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" :side-offset="8"> <TooltipContent :side="isDayView ? 'right' : 'left'" :side-offset="8">
<template v-if="getActivityBoxActivities(abox).length === 0"> <template v-if="getActivityBoxActivities(abox).length === 0">
{{ getActivityBoxLabel(abox) }} {{ getActivityBoxLabel(abox) }}
</template> </template>
@@ -271,13 +318,99 @@ const emit = defineEmits<{
background-color: rgba(156, 163, 175, 0.5); background-color: rgba(156, 163, 175, 0.5);
} }
.activity-status-box.active::before { .activity-status-box.active::before {
background-color: rgba(34, 197, 94, 0.3); background-color: rgba(14, 165, 233, 0.3);
} }
.activity-status-box.active:hover::before { .activity-status-box.active:hover::before {
background-color: rgba(34, 197, 94, 1); background-color: rgba(14, 165, 233, 1);
}
/* Uncovered activity boxes in week view — fill column width */
.activity-status-box-uncovered {
width: calc(100% - 4px);
border-radius: 3px;
overflow: hidden;
}
.activity-status-box-uncovered::before {
left: 0;
right: 0;
width: auto;
}
.activity-status-box-uncovered.active::before {
background-color: rgba(14, 165, 233, 0.12);
}
.activity-status-box-uncovered.active:hover::before {
background-color: rgba(14, 165, 233, 0.25);
}
/* Expanded activity boxes for day view */
.activity-status-box-expanded {
width: 200px;
border-radius: 3px;
overflow: hidden;
}
.activity-status-box-expanded::before {
left: 0;
right: 0;
width: auto;
}
.activity-status-box-expanded.idle::before {
background-color: rgba(156, 163, 175, 0.08);
}
.activity-status-box-expanded.idle:hover::before {
background-color: rgba(156, 163, 175, 0.2);
}
.activity-status-box-expanded.active::before {
background-color: rgba(14, 165, 233, 0.12);
}
.activity-status-box-expanded.active:hover::before {
background-color: rgba(14, 165, 233, 0.25);
}
.activity-status-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
height: 100%;
overflow: hidden;
}
.activity-status-icon {
width: 14px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.activity-status-icon-fallback {
width: 14px;
height: 14px;
border-radius: 2px;
background-color: rgba(14, 165, 233, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 8px;
flex-shrink: 0;
color: rgba(14, 165, 233, 0.8);
}
.activity-status-label {
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
} }
.fc-events-inset { .fc-events-inset {
left: 8px; left: 8px;
} }
.fc-events-inset-expanded {
left: 204px;
}
</style> </style>

View File

@@ -22,7 +22,7 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<div class="flex items-center justify-between bg-background px-2 py-1.5"> <div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<!-- Left: Navigation --> <!-- Left: Navigation -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Button <Button

View File

@@ -57,7 +57,7 @@ import type {
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'dates-change', payload: { start: Date; end: Date }): void; (e: 'dates-change', payload: { start: Dayjs; end: Dayjs }): void;
(e: 'refresh'): void; (e: 'refresh'): void;
}>(); }>();
@@ -163,6 +163,7 @@ const {
getActivityBoxActivities, getActivityBoxActivities,
getActivityPercentage, getActivityPercentage,
getActivityText, getActivityText,
getTopActivity,
} = useActivityBoxes({ } = useActivityBoxes({
activityPeriods: () => props.activityPeriods, activityPeriods: () => props.activityPeriods,
viewDays, viewDays,
@@ -280,6 +281,22 @@ watch(showEditTimeEntryModal, (value) => {
} }
}); });
/**
* Guards slot pointer-down so that clicks which dismiss an open Reka UI
* layer (context menu, popover, dialog) don't simultaneously start a
* new time-entry selection on the calendar grid.
*
* Because Reka's DismissableLayer registers its document-level
* `pointerdown` listener *without* capture, it fires AFTER the
* calendar grid's own handler. That means when this guard runs,
* `contextMenuOpen` (and modal refs) still reflect the *open* state.
*/
function guardedSlotPointerDown(e: PointerEvent) {
if (contextMenuOpen.value) return;
if (showCreateTimeEntryModal.value || showEditTimeEntryModal.value) return;
onSlotPointerDown(e);
}
const scrollToCurrentTime = () => { const scrollToCurrentTime = () => {
nextTick(() => { nextTick(() => {
if (!scrollerRef.value) return; if (!scrollerRef.value) return;
@@ -314,6 +331,18 @@ watch(
{ deep: true } { deep: true }
); );
let hasScrolledOnLoad = false;
watch(
() => props.loading,
(loading) => {
if (!loading && !hasScrolledOnLoad) {
hasScrolledOnLoad = true;
scrollToCurrentTime();
}
}
);
onMounted(() => { onMounted(() => {
scrollToCurrentTime(); scrollToCurrentTime();
emitDatesChange(); emitDatesChange();
@@ -465,7 +494,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div <div
class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background"> class="fc-header-scroll flex border-b border-border shrink-0 sticky top-0 z-10 bg-default-background">
<div <div
class="shrink-0 bg-background border-r border-border" class="shrink-0 bg-default-background border-r border-border"
:style="{ :style="{
width: TIME_AXIS_WIDTH + 'px', width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px', minWidth: TIME_AXIS_WIDTH + 'px',
@@ -478,7 +507,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div <div
v-for="day in viewDays" v-for="day in viewDays"
:key="day.format('YYYY-MM-DD')" :key="day.format('YYYY-MM-DD')"
class="fc-col-header-cell border-r border-b border-border px-2 py-3 bg-default-background text-center" class="fc-col-header-cell border-r border-border px-2 py-3 bg-default-background text-center"
:class="{ :class="{
'bg-secondary': isToday(day), 'bg-secondary': isToday(day),
'fc-day-today': isToday(day), 'fc-day-today': isToday(day),
@@ -497,7 +526,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div ref="scrollerRef" class="fc-scroller"> <div ref="scrollerRef" class="fc-scroller">
<div class="flex min-w-0"> <div class="flex min-w-0">
<div <div
class="shrink-0 bg-background border-r border-border" class="shrink-0 bg-default-background border-r border-border"
:style="{ :style="{
width: TIME_AXIS_WIDTH + 'px', width: TIME_AXIS_WIDTH + 'px',
minWidth: TIME_AXIS_WIDTH + 'px', minWidth: TIME_AXIS_WIDTH + 'px',
@@ -514,7 +543,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:style="{ height: SLOT_HEIGHT + 'px' }"> :style="{ height: SLOT_HEIGHT + 'px' }">
<span <span
v-if="slot.isHour" v-if="slot.isHour"
class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block"> class="fc-timegrid-slot-label-cushion text-[0.8125rem] text-muted-foreground leading-none block font-light">
{{ formatSlotLabel(slot.minutes / 60) }} {{ formatSlotLabel(slot.minutes / 60) }}
</span> </span>
</div> </div>
@@ -522,14 +551,32 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<div <div
class="flex-1 min-w-0 relative" class="flex-1 min-w-0 relative"
@pointerdown="onSlotPointerDown($event)"> @pointerdown="guardedSlotPointerDown($event)">
<div <div
class="bg-background" class="bg-default-background relative"
:style="{ height: totalGridHeight + 'px' }"> :style="{ height: totalGridHeight + 'px' }">
<div
class="absolute inset-0 grid"
:style="{
gridTemplateColumns:
'repeat(' + viewDays.length + ', 1fr)',
}">
<div
v-for="day in viewDays"
:key="'bg-' + day.format('YYYY-MM-DD')"
:style="
isToday(day)
? {
backgroundColor:
'var(--theme-color-default-background)',
}
: undefined
" />
</div>
<div <div
v-for="slot in slots" v-for="slot in slots"
:key="'lane-' + slot.time" :key="'lane-' + slot.time"
class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border" class="fc-timegrid-slot fc-timegrid-slot-lane border-t border-border box-border relative"
:class="{ :class="{
'fc-timegrid-slot-minor border-dotted': 'fc-timegrid-slot-minor border-dotted':
!slot.isHour, !slot.isHour,
@@ -581,6 +628,8 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:get-activity-box-activities="getActivityBoxActivities" :get-activity-box-activities="getActivityBoxActivities"
:get-activity-percentage="getActivityPercentage" :get-activity-percentage="getActivityPercentage"
:get-activity-text="getActivityText" :get-activity-text="getActivityText"
:get-top-activity="getTopActivity"
:is-day-view="activeView === 'timeGridDay'"
:show-selection=" :show-selection="
isSelecting || showCreateTimeEntryModal isSelecting || showCreateTimeEntryModal
" "
@@ -599,6 +648,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
:selection-height="selectionHeight" :selection-height="selectionHeight"
:selection-end-top="selectionEndTop" :selection-end-top="selectionEndTop"
:selection-end-height="selectionEndHeight" :selection-end-height="selectionEndHeight"
@activity-pointerdown="guardedSlotPointerDown"
@event-pointerdown=" @event-pointerdown="
(e, dayEvent) => (e, dayEvent) =>
onEventPointerDown(e, dayEvent.event, dayEvent) onEventPointerDown(e, dayEvent.event, dayEvent)

View File

@@ -1,6 +1,6 @@
export interface WindowActivityInPeriod { export interface WindowActivityInPeriod {
appName: string; appName: string;
url: string | null; label: string | null;
count: number; count: number;
icon?: string | null; icon?: string | null;
} }

View File

@@ -12,19 +12,13 @@ export function useActivityBoxes(params: {
calendarSettings: Ref<CalendarSettings>; calendarSettings: Ref<CalendarSettings>;
minutesToPixels: (minutes: number) => number; minutesToPixels: (minutes: number) => number;
}) { }) {
function formatActivityDuration(durationMinutes: number): string {
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function getActivityBoxLabel(box: ActivityBox): string { function getActivityBoxLabel(box: ActivityBox): string {
const periodStart = getLocalizedDayJs(box.period.start); const periodStart = getLocalizedDayJs(box.period.start);
const periodEnd = getLocalizedDayJs(box.period.end); const periodEnd = getLocalizedDayJs(box.period.end);
const durationMinutes = Math.round(periodEnd.diff(periodStart, 'minute', true)); const startText = periodStart.format('HH:mm');
const durationText = formatActivityDuration(durationMinutes); const endText = periodEnd.format('HH:mm');
const status = box.isIdle ? 'Idling' : 'Active'; const status = box.isIdle ? 'Idling' : 'Active';
return `${status} (${durationText})`; return `${status} (${startText} - ${endText})`;
} }
function getActivityBoxActivities(box: ActivityBox) { function getActivityBoxActivities(box: ActivityBox) {
@@ -37,7 +31,16 @@ export function useActivityBoxes(params: {
} }
function getActivityText(activity: WindowActivityInPeriod): string { function getActivityText(activity: WindowActivityInPeriod): string {
return activity.url ? `${activity.appName} - ${activity.url}` : activity.appName; return activity.label ? `${activity.appName} - ${activity.label}` : activity.appName;
}
function getTopActivity(box: ActivityBox): WindowActivityInPeriod | null {
const activities = box.period.windowActivities;
if (!activities || activities.length === 0) return null;
return activities.reduce<WindowActivityInPeriod>(
(top, a) => (a.count > top.count ? a : top),
activities[0]!
);
} }
const activityBoxes = computed<ActivityBox[]>(() => { const activityBoxes = computed<ActivityBox[]>(() => {
@@ -99,5 +102,6 @@ export function useActivityBoxes(params: {
getActivityBoxActivities, getActivityBoxActivities,
getActivityPercentage, getActivityPercentage,
getActivityText, getActivityText,
getTopActivity,
}; };
} }

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { ref, type Ref, type ComputedRef } from 'vue'; import { ref, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src'; import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance } from '../utils/time'; import { getDayJsInstance, getLocalizedDayJsFromMinutes } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent } from './calendarTypes'; import type { CalendarEvent } from './calendarTypes';
@@ -34,11 +34,8 @@ export function useContextMenu(params: {
const snap = params.calendarSettings.value.snapMinutes; const snap = params.calendarSettings.value.snapMinutes;
const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap; const snappedMinutes = Math.floor(minutesFromGridStart / snap) * snap;
const dayjs = getDayJsInstance(); const startLocal = getLocalizedDayJsFromMinutes(date, snappedMinutes);
const startLocal = dayjs(`${date}T00:00:00`) const snappedEnd = getLocalizedDayJsFromMinutes(date, snappedMinutes + snap);
.tz(getUserTimezone(), true)
.add(snappedMinutes, 'minute');
const snappedEnd = startLocal.add(snap, 'minute');
return { start: startLocal.utc(), end: snappedEnd.utc() }; return { start: startLocal.utc(), end: snappedEnd.utc() };
} }

View File

@@ -1,8 +1,7 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue'; import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src'; import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time'; import { getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes'; import type { CalendarEvent, DayEvent } from './calendarTypes';
import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes'; import { SLOT_HEIGHT, DRAG_THRESHOLD } from './calendarTypes';
@@ -67,8 +66,7 @@ export function useEventDrag(params: {
} }
if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) { if (dayEvent.isClippedStart && originDay && ev.timeEntry.end) {
const dayjs = getDayJsInstance(); const dayMidnight = getLocalizedDayJsFromMinutes(originDay, 0);
const dayMidnight = dayjs(`${originDay}T00:00:00`).tz(getUserTimezone(), true);
const evStart = getLocalizedDayJs(ev.timeEntry.start); const evStart = getLocalizedDayJs(ev.timeEntry.start);
const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60; const eventStartFromGridStart = evStart.diff(dayMidnight, 'minute') - s.startHour * 60;
const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes; const segmentTopMinutes = (dayEvent.top / SLOT_HEIGHT) * s.slotMinutes;
@@ -154,13 +152,11 @@ export function useEventDrag(params: {
const lowerBound = startMin - 4 * 60; const lowerBound = startMin - 4 * 60;
const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60)); const clampedMinutes = Math.max(lowerBound, Math.min(snappedMinutes, s.endHour * 60));
const dayjs = getDayJsInstance(); const originalSegmentStart = getLocalizedDayJsFromMinutes(
const originalSegmentStart = dayjs(`${savedOriginalDayStr}T00:00:00`) savedOriginalDayStr,
.tz(getUserTimezone(), true) startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop)
.add(startMin + params.pixelsToMinutesFromMidnight(dragStartEventTop), 'minute'); );
const newSegmentStart = dayjs(`${targetDateStr}T00:00:00`) const newSegmentStart = getLocalizedDayJsFromMinutes(targetDateStr, clampedMinutes);
.tz(getUserTimezone(), true)
.add(clampedMinutes, 'minute');
const deltaMs = newSegmentStart.diff(originalSegmentStart); const deltaMs = newSegmentStart.diff(originalSegmentStart);
const origStart = getLocalizedDayJs(timeEntry.start); const origStart = getLocalizedDayJs(timeEntry.start);
@@ -240,11 +236,14 @@ export function useEventDrag(params: {
} }
// Multi-day: compute actual start/end datetimes, then clip per day // Multi-day: compute actual start/end datetimes, then clip per day
const dayjs = getDayJsInstance(); const eventStartAbsolute = getLocalizedDayJsFromMinutes(
const eventStartAbsolute = dayjs(`${dragCurrentDay.value}T00:00:00`) dragCurrentDay.value,
.tz(getUserTimezone(), true) startMin + eventStartOnGrid
.add(startMin + eventStartOnGrid, 'minute'); );
const eventEndAbsolute = eventStartAbsolute.add(dragFullDurationMinutes, 'minute'); const eventEndAbsolute = getLocalizedDayJsFromMinutes(
dragCurrentDay.value,
startMin + eventStartOnGrid + dragFullDurationMinutes
);
const result: Record<string, Record<string, string>> = {}; const result: Record<string, Record<string, string>> = {};

View File

@@ -1,8 +1,7 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue'; import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { TimeEntry } from '@/packages/api/src'; import type { TimeEntry } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '../utils/time'; import { getDayJsInstance, getLocalizedDayJs, getLocalizedDayJsFromMinutes } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
import type { CalendarEvent, DayEvent } from './calendarTypes'; import type { CalendarEvent, DayEvent } from './calendarTypes';
import { SLOT_HEIGHT } from './calendarTypes'; import { SLOT_HEIGHT } from './calendarTypes';
@@ -11,10 +10,6 @@ function snapTo(value: number, step: number): number {
return Math.round(value / step) * step; return Math.round(value / step) * step;
} }
function dayMidnightLocal(dayStr: string): Dayjs {
return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true);
}
export function useEventResize(params: { export function useEventResize(params: {
calendarSettings: Ref<CalendarSettings>; calendarSettings: Ref<CalendarSettings>;
viewDays: ComputedRef<Dayjs[]>; viewDays: ComputedRef<Dayjs[]>;
@@ -89,7 +84,7 @@ export function useEventResize(params: {
), ),
s.snapMinutes s.snapMinutes
); );
return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') }; return { start, end: getLocalizedDayJsFromMinutes(endDay, endMinutes) };
} else { } else {
const end = resizeOriginalEvent.isRunning const end = resizeOriginalEvent.isRunning
? getLocalizedDayJs() ? getLocalizedDayJs()
@@ -105,7 +100,7 @@ export function useEventResize(params: {
params.pixelsToMinutesFromMidnight(resizeCurrentTop.value), params.pixelsToMinutesFromMidnight(resizeCurrentTop.value),
s.snapMinutes s.snapMinutes
); );
return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end }; return { start: getLocalizedDayJsFromMinutes(startDay, startMinutes), end };
} }
} }

View File

@@ -1,7 +1,7 @@
import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue'; import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { getDayJsInstance } from '../utils/time'; import { getLocalizedDayJsFromMinutes } from '../utils/time';
import { getUserTimezone } from '../utils/settings';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
import { SLOT_HEIGHT } from './calendarTypes'; import { SLOT_HEIGHT } from './calendarTypes';
@@ -29,7 +29,7 @@ export function useSlotSelection(params: {
function onSlotPointerDown(e: PointerEvent) { function onSlotPointerDown(e: PointerEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.fc-event') || target.closest('.activity-status-box')) return; if (target.closest('.fc-event')) return;
const dateStr = params.getDayFromClientX(e.clientX); const dateStr = params.getDayFromClientX(e.clientX);
if (!dateStr) return; if (!dateStr) return;
@@ -102,8 +102,6 @@ export function useSlotSelection(params: {
const s = params.calendarSettings.value; const s = params.calendarSettings.value;
const snap = s.snapMinutes; const snap = s.snapMinutes;
const dayjs = getDayJsInstance();
const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value); const startMinutes = params.pixelsToMinutesFromMidnight(selectionTop.value);
const snappedStartMin = Math.floor(startMinutes / snap) * snap; const snappedStartMin = Math.floor(startMinutes / snap) * snap;
@@ -138,12 +136,8 @@ export function useSlotSelection(params: {
if (endMin <= 0) endMin = snap; if (endMin <= 0) endMin = snap;
} }
startLocal = dayjs(`${startDateStr}T00:00:00`) startLocal = getLocalizedDayJsFromMinutes(startDateStr, startMin);
.tz(getUserTimezone(), true) endLocal = getLocalizedDayJsFromMinutes(endDateStr, endMin);
.add(startMin, 'minute');
endLocal = dayjs(`${endDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(endMin, 'minute');
} else { } else {
const startDateStr = selectionStartDay; const startDateStr = selectionStartDay;
const endMinutes = params.pixelsToMinutesFromMidnight( const endMinutes = params.pixelsToMinutesFromMidnight(
@@ -153,12 +147,8 @@ export function useSlotSelection(params: {
if (snappedEndMin <= snappedStartMin) { if (snappedEndMin <= snappedStartMin) {
snappedEndMin = snappedStartMin + snap; snappedEndMin = snappedStartMin + snap;
} }
startLocal = dayjs(`${startDateStr}T00:00:00`) startLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedStartMin);
.tz(getUserTimezone(), true) endLocal = getLocalizedDayJsFromMinutes(startDateStr, snappedEndMin);
.add(snappedStartMin, 'minute');
endLocal = dayjs(`${startDateStr}T00:00:00`)
.tz(getUserTimezone(), true)
.add(snappedEndMin, 'minute');
} }
params.onSelectionComplete(startLocal.utc(), endLocal.utc()); params.onSelectionComplete(startLocal.utc(), endLocal.utc());

View File

@@ -6,10 +6,15 @@ const props = withDefaults(
defineProps<{ defineProps<{
expanded?: boolean; expanded?: boolean;
size?: string; size?: string;
/**
* Test ID used for Playwright/E2E tests.
*/
testId?: string;
}>(), }>(),
{ {
expanded: false, expanded: false,
size: 'w-7 h-7', size: 'w-7 h-7',
testId: 'grouped_items_count_button',
} }
); );
@@ -23,6 +28,7 @@ const expandedStatusClasses = computed(() => {
<template> <template>
<button <button
:data-testid="props.testId"
:class=" :class="
twMerge( twMerge(
'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent', 'font-medium text-base rounded flex items-center transition justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent',

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ const props = defineProps<{
currency: string; currency: string;
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
organizationBillableRate: number | null; organizationBillableRate: number | null;
initialProjectName?: string;
}>(); }>();
const activeClients = computed(() => { const activeClients = computed(() => {
@@ -34,7 +35,7 @@ const activeClients = computed(() => {
}); });
const project = ref<CreateProjectBody>({ const project = ref<CreateProjectBody>({
name: '', name: props.initialProjectName ?? '',
color: getRandomColor(), color: getRandomColor(),
client_id: null, client_id: null,
billable_rate: null, billable_rate: null,

View File

@@ -32,10 +32,10 @@ const displaysPlaceholder = computed(() => {
<template> <template>
<div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden"> <div class="relative min-w-0 text-ellipsis whitespace-nowrap overflow-hidden">
<div class="relative text-sm font-medium min-w-0"> <div class="relative text-sm min-w-0">
<div <div
:class="[ :class="[
'opacity-0 h-4 text-sm whitespace-pre font-medium min-w-0 pl-1.5 @lg:pl-3 pr-1', 'opacity-0 h-4 text-sm whitespace-pre min-w-0 pl-1.5 @lg:pl-3 pr-1',
{ 'min-w-[130px]': displaysPlaceholder }, { 'min-w-[130px]': displaysPlaceholder },
]"> ]">
{{ liveDataValue }} {{ liveDataValue }}
@@ -44,7 +44,7 @@ const displaysPlaceholder = computed(() => {
data-testid="time_entry_description" data-testid="time_entry_description"
:value="liveDataValue" :value="liveDataValue"
placeholder="Add a description" placeholder="Add a description"
class="absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary font-medium bg-transparent focus-visible:ring-0 rounded-lg border-0" class="absolute px-0 h-full min-w-0 pl-1.5 @lg:pl-3 pr-1 left-0 top-0 w-full text-sm text-text-primary bg-transparent focus-visible:ring-0 rounded-lg border-0"
@blur="onChange" @blur="onChange"
@input="onInput" @input="onInput"
@keydown.enter="onChange" /> @keydown.enter="onChange" />

View File

@@ -20,24 +20,30 @@ const selectedTimeEntries = defineModel<TimeEntry[]>('selected', {
default: [], default: [],
}); });
const props = defineProps<{ const props = withDefaults(
timeEntries: TimeEntry[]; defineProps<{
projects: Project[]; timeEntries: TimeEntry[];
tasks: Task[]; projects: Project[];
tags: Tag[]; tasks: Task[];
clients: Client[]; tags: Tag[];
createTag: (name: string) => Promise<Tag | undefined>; clients: Client[];
updateTimeEntry: (entry: TimeEntry) => void; createTag: (name: string) => Promise<Tag | undefined>;
updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void; updateTimeEntry: (entry: TimeEntry) => void;
deleteTimeEntries: (entries: TimeEntry[]) => void; updateTimeEntries: (ids: string[], changes: Partial<TimeEntry>) => void;
createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void; deleteTimeEntries: (entries: TimeEntry[]) => void;
createProject: (project: CreateProjectBody) => Promise<Project | undefined>; createTimeEntry: (entry: Omit<CreateTimeEntryBody, 'member_id'>) => void;
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createProject: (project: CreateProjectBody) => Promise<Project | undefined>;
currency: string; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
organizationBillableRate: number | null; currency: string;
enableEstimatedTime: boolean; organizationBillableRate: number | null;
canCreateProject: boolean; enableEstimatedTime: boolean;
}>(); canCreateProject: boolean;
groupSimilarTimeEntries?: boolean;
}>(),
{
groupSimilarTimeEntries: true,
}
);
const groupedTimeEntries = computed(() => { const groupedTimeEntries = computed(() => {
const groupedEntriesByDay: Record<string, TimeEntry[]> = {}; const groupedEntriesByDay: Record<string, TimeEntry[]> = {};
@@ -58,6 +64,11 @@ const groupedTimeEntries = computed(() => {
const newDailyEntries: TimeEntriesGroupedByType[] = []; const newDailyEntries: TimeEntriesGroupedByType[] = [];
for (const entry of dailyEntries) { for (const entry of dailyEntries) {
if (!props.groupSimilarTimeEntries) {
newDailyEntries.push({ ...entry, timeEntries: [entry] });
continue;
}
// check if same entry already exists // check if same entry already exists
const oldEntriesIndex = newDailyEntries.findIndex( const oldEntriesIndex = newDailyEntries.findIndex(
(e) => (e) =>

View File

@@ -14,7 +14,7 @@ import type {
} from '@/packages/api/src'; } from '@/packages/api/src';
import { ref } from 'vue'; import { ref } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Checkbox } from '@/packages/ui/src'; import { Button, Checkbox } from '@/packages/ui/src';
import { FieldLabel } from '../field'; import { FieldLabel } from '../field';
const props = defineProps<{ const props = defineProps<{
@@ -66,7 +66,7 @@ const showMassUpdateModal = ref(false);
:class=" :class="
twMerge( twMerge(
props.class, props.class,
'text-sm py-1.5 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3' 'text-sm h-8 font-medium hidden sm:flex border-b border-border-primary items-center space-x-3'
) )
"> ">
<Checkbox <Checkbox
@@ -83,20 +83,24 @@ const showMassUpdateModal = ref(false);
<FieldLabel v-else for="selectAll" class="text-text-secondary select-none" <FieldLabel v-else for="selectAll" class="text-text-secondary select-none"
>Select All</FieldLabel >Select All</FieldLabel
> >
<button <Button
v-if="selectedTimeEntries.length" v-if="selectedTimeEntries.length"
class="text-text-tertiary flex space-x-1 items-center hover:text-text-secondary transition focus-visible:ring-2 outline-0 focus-visible:text-text-primary focus-visible:ring-ring rounded h-full px-2" variant="ghost"
size="xs"
class="text-text-tertiary hover:text-text-secondary"
@click="showMassUpdateModal = true"> @click="showMassUpdateModal = true">
<PencilSquareIcon class="w-4"></PencilSquareIcon> <PencilSquareIcon class="w-4"></PencilSquareIcon>
<span> Edit </span> <span>Edit</span>
</button> </Button>
<button <Button
v-if="selectedTimeEntries.length" v-if="selectedTimeEntries.length"
class="text-red-400 h-full px-2 space-x-1 items-center flex hover:text-red-500 transition focus-visible:ring-2 outline-0 focus-visible:text-red-500 focus-visible:ring-ring rounded" variant="ghost"
size="xs"
class="text-red-400 hover:text-red-500 hover:bg-red-500/10"
@click="deleteSelected"> @click="deleteSelected">
<TrashIcon class="w-3.5"></TrashIcon> <TrashIcon class="w-3.5"></TrashIcon>
<span> Delete </span> <span>Delete</span>
</button> </Button>
</MainContainer> </MainContainer>
</template> </template>

View File

@@ -36,15 +36,13 @@ const organization = inject<ComputedRef<Organization>>('organization');
:class=" :class="
twMerge( twMerge(
'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary', 'text-text-secondary px-1 bg-transparent text-center hover:bg-card-background rounded-lg border border-transparent hover:border-card-border focus-visible:outline-none focus:outline-none focus-visible:ring-2 focus-visible:text-text-primary focus-visible:ring-ring focus-visible:bg-tertiary',
showDate showDate ? 'text-xs py-1.5 font-medium' : 'text-sm py-1.5 font-normal',
? 'text-xs py-1.5 font-semibold'
: 'text-sm py-1.5 font-medium',
organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]', organization?.time_format === '12-hours' ? 'w-[160px]' : 'w-[100px]',
open && 'border-card-border bg-card-background' open && 'border-card-border bg-card-background'
) )
"> ">
{{ formatStartEnd(start, end, organization?.time_format) }} {{ formatStartEnd(start, end, organization?.time_format) }}
<span v-if="showDate" class="text-text-tertiary font-medium" <span v-if="showDate" class="text-text-tertiary font-normal"
>{{ formatDateLocalized(start, organization?.date_format) }} >{{ formatDateLocalized(start, organization?.date_format) }}
</span> </span>
</button> </button>

View File

@@ -52,6 +52,7 @@ const props = defineProps<{
selected?: boolean; selected?: boolean;
canCreateProject: boolean; canCreateProject: boolean;
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
isReport?: boolean;
}>(); }>();
const emit = defineEmits<{ selected: []; unselected: [] }>(); const emit = defineEmits<{ selected: []; unselected: [] }>();
@@ -148,8 +149,7 @@ async function handleDeleteTimeEntry() {
:task="timeEntry.task_id" :task="timeEntry.task_id"
@changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown> @changed="updateProjectAndTask"></TimeTrackerProjectTaskDropdown>
</div> </div>
<div <div class="hidden @lg:flex items-center space-x-1 @lg:space-x-2 shrink-0">
class="hidden @lg:flex items-center font-medium space-x-1 @lg:space-x-2 shrink-0">
<div v-if="showMember && members" class="text-sm px-2"> <div v-if="showMember && members" class="text-sm px-2">
{{ memberName }} {{ memberName }}
</div> </div>
@@ -173,6 +173,7 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput <TimeEntryRowDurationInput
:start="timeEntry.start" :start="timeEntry.start"
:end="timeEntry.end" :end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput> @changed="updateStartEndTime"></TimeEntryRowDurationInput>
<TimeTrackerStartStop <TimeTrackerStartStop
:active="!!(timeEntry.start && !timeEntry.end)" :active="!!(timeEntry.start && !timeEntry.end)"
@@ -197,6 +198,7 @@ async function handleDeleteTimeEntry() {
<TimeEntryRowDurationInput <TimeEntryRowDurationInput
:start="timeEntry.start" :start="timeEntry.start"
:end="timeEntry.end" :end="timeEntry.end"
:is-report="props.isReport"
@changed="updateStartEndTime"></TimeEntryRowDurationInput> @changed="updateStartEndTime"></TimeEntryRowDurationInput>
</div> </div>
<!-- Second row: project/task - tags - billable - start - more --> <!-- Second row: project/task - tags - billable - start - more -->

View File

@@ -2,6 +2,7 @@
import { import {
calculateDifference, calculateDifference,
formatHumanReadableDuration, formatHumanReadableDuration,
formatReportingDuration,
parseTimeInput, parseTimeInput,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { computed, ref, inject, type ComputedRef } from 'vue'; import { computed, ref, inject, type ComputedRef } from 'vue';
@@ -18,6 +19,7 @@ const organizationSettings = computed(() => ({
const props = defineProps<{ const props = defineProps<{
start: string; start: string;
end: string | null; end: string | null;
isReport?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
changed: [start: string, end: string | null]; changed: [start: string, end: string | null];
@@ -51,7 +53,8 @@ const currentTime = computed({
if (temporaryCustomTimerEntry.value !== '') { if (temporaryCustomTimerEntry.value !== '') {
return temporaryCustomTimerEntry.value; return temporaryCustomTimerEntry.value;
} }
return formatHumanReadableDuration( const formatter = props.isReport ? formatReportingDuration : formatHumanReadableDuration;
return formatter(
calculateDifference(props.start, props.end), calculateDifference(props.start, props.end),
organizationSettings.value.intervalFormat, organizationSettings.value.intervalFormat,
organizationSettings.value.numberFormat organizationSettings.value.numberFormat

View File

@@ -47,14 +47,14 @@ function selectUnselectAll(value: boolean) {
class="group-hover:block hidden" class="group-hover:block hidden"
@update:checked="selectUnselectAll"></Checkbox> @update:checked="selectUnselectAll"></Checkbox>
</div> </div>
<span class="font-medium text-text-secondary"> <span class="text-text-primary">
{{ formatWeekday(date) }} {{ formatWeekday(date) }}
</span> </span>
<span class="text-text-tertiary ml-2"> <span class="text-text-secondary ml-2">
{{ formatDate(date, organization?.date_format) }} {{ formatDate(date, organization?.date_format) }}
</span> </span>
</div> </div>
<div class="text-text-secondary pr-2 @lg:pr-[92px]"> <div class="text-text-primary pr-2 @lg:pr-[92px]">
<span class="font-medium"> <span class="font-medium">
{{ {{
formatHumanReadableDuration( formatHumanReadableDuration(

View File

@@ -215,7 +215,7 @@ useSelectEvents(
v-model="tempDescription" v-model="tempDescription"
placeholder="What are you working on?" placeholder="What are you working on?"
data-testid="time_entry_description" data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary font-medium focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
type="text" type="text"
@keydown.enter="startTimerIfNotActive" @keydown.enter="startTimerIfNotActive"
@keydown.esc="showDropdown = false" @keydown.esc="showDropdown = false"

View File

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

View File

@@ -29,7 +29,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }" v-bind="{ ...forwarded, ...$attrs }"
:class=" :class="
cn( cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'z-50 overflow-hidden rounded-md shadow-dropdown border border-border bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class props.class
) )
"> ">

View File

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

View File

@@ -3,6 +3,8 @@ import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import isToday from 'dayjs/plugin/isToday'; import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday'; import isYesterday from 'dayjs/plugin/isYesterday';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import weekOfYear from 'dayjs/plugin/weekOfYear'; import weekOfYear from 'dayjs/plugin/weekOfYear';
@@ -68,6 +70,8 @@ function configureParseLocale(numberFormat?: string) {
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(isToday); dayjs.extend(isToday);
dayjs.extend(isYesterday); dayjs.extend(isYesterday);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);
dayjs.extend(duration); dayjs.extend(duration);
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@@ -118,6 +122,26 @@ export function formatHumanReadableDuration(
} }
} }
/**
* Format a duration for reporting views where cost and duration must reconcile.
*
* When the org's `hours-minutes` format is selected, seconds are normally dropped for
* readability (e.g. "14h 45min"). In reports this can make the total duration appear
* inconsistent with the billable cost (which is computed to the second). To keep the
* two columns reconcilable without inflating column widths with "14h 45min 06s",
* promote to the compact `HH:MM:SS` format in reporting contexts.
*/
export function formatReportingDuration(
duration: number,
intervalFormat?: string,
numberFormat?: string
): string {
const promoted =
intervalFormat === 'hours-minutes' || intervalFormat === 'hours-minutes-colon-separated';
const effectiveFormat = promoted ? 'hours-minutes-seconds-colon-separated' : intervalFormat;
return formatHumanReadableDuration(duration, effectiveFormat, numberFormat);
}
export function formatDuration(duration: number): string { export function formatDuration(duration: number): string {
const dayJsDuration = dayjs.duration(duration, 's'); const dayJsDuration = dayjs.duration(duration, 's');
const hours = Math.floor(dayJsDuration.asHours()); const hours = Math.floor(dayJsDuration.asHours());
@@ -147,10 +171,36 @@ export function getLocalizedDayJs(timestamp?: string | null) {
return dayjs.utc(timestamp).tz(getUserTimezone()); return dayjs.utc(timestamp).tz(getUserTimezone());
} }
/**
* Create a dayjs instance for a specific wall-clock time on a given day.
* Sets hour/minute directly to avoid DST issues with .add(minutes) on
* transition days. Negative or overflow values are normalised by shifting
* whole days (`.add(n, 'day')` is DST-safe).
*/
export function getLocalizedDayJsFromMinutes(dayStr: string, minutesFromMidnight: number) {
const dayOffset = Math.floor(minutesFromMidnight / (24 * 60));
const remainder = minutesFromMidnight - dayOffset * 24 * 60;
return dayjs
.tz(`${dayStr}T00:00:00`, getUserTimezone())
.add(dayOffset, 'day')
.hour(Math.floor(remainder / 60))
.minute(Math.round(remainder % 60))
.second(0);
}
export function getLocalizedDateFromTimestamp(timestamp: string) { export function getLocalizedDateFromTimestamp(timestamp: string) {
return getLocalizedDayJs(timestamp).format('YYYY-MM-DD'); return getLocalizedDayJs(timestamp).format('YYYY-MM-DD');
} }
/**
* Converts a local Date to a UTC-formatted ISO string.
* Treats the Date as being in the user's timezone and converts to UTC.
* This is the inverse of getLocalizedDayJs (which goes UTC → local).
*/
export function localDateToUtc(date: dayjs.Dayjs): string {
return date.tz(getUserTimezone(), true).utc().format();
}
/* /*
* Returns a formatted date. * Returns a formatted date.
* @param date - date in the format of 'YYYY-MM-DD' * @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -14,28 +14,30 @@
@tailwind utilities; @tailwind utilities;
:root.dark { :root.dark {
--color-bg-primary: oklch(0.14 0.0041 285.97); /* Linear/Raycast: cool blue-tinted neutrals, content surface at Material floor */
--color-bg-secondary: oklch(0.18 0.005 285.97); --color-bg-primary: oklch(0.17 0 0); /* content surface */
--color-bg-tertiary: oklch(0.22 0.0112 285.97); --color-bg-secondary: oklch(0.20 0 0); /* cards, input fills */
--color-bg-quaternary: oklch(0.26 0.015 285.97); --color-bg-tertiary: oklch(0.25 0 0); /* hover / active row */
--color-bg-background: oklch(0.1 0 0); --color-bg-quaternary: oklch(0.29 0 0); /* pressed / selected */
--color-text-primary: #ffffff; --color-bg-elevated: oklch(0.22 0 0); /* popovers, modals, dropdowns */
--color-text-secondary: #e3e4e6; --color-bg-background: oklch(0.14 0 0); /* sidebar / chrome — recessed */
--color-text-tertiary: #969799; --color-text-primary: oklch(0.97 0 0);
--color-text-quaternary: #595a5c; --color-text-secondary: oklch(0.85 0 0);
--color-text-tertiary: oklch(0.70 0 0);
--color-text-quaternary: oklch(0.55 0 0);
--color-border-primary: #191b1f; --color-border-primary: oklch(0.24 0 0); /* separators — above elevated */
--color-border-secondary: oklch(0.25 0.0098 268.31); --color-border-secondary: oklch(0.28 0 0); /* card borders */
--color-border-tertiary: #2c2e33; --color-border-tertiary: oklch(0.32 0 0); /* input borders */
--color-border-quaternary: #393b42; --color-border-quaternary: oklch(0.36 0 0); /* emphasized borders */
--color-input-border-active: rgba(255, 255, 255, 0.15); --color-input-border-active: rgba(255, 255, 255, 0.18);
--theme-color-chart: var(--color-accent-200); --theme-color-chart: var(--color-accent-200);
--theme-color-menu-active: var(--color-bg-secondary); --theme-color-menu-active: var(--color-bg-secondary);
--theme-color-card-background: var(--color-bg-secondary); --theme-color-card-background: var(--color-bg-secondary);
--theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%); --theme-shadow-card: 0 4px 7px 0px rgb(0 0 0 / 15%);
--theme-shadow-dropdown: 0 4px 7px 0px rgb(0 0 0 / 40%); --theme-shadow-dropdown: 0 8px 24px -4px rgb(0 0 0 / 55%), 0 2px 6px 0 rgb(0 0 0 / 35%);
--theme-color-card-background-active: var(--color-bg-tertiary); --theme-color-card-background-active: var(--color-bg-tertiary);
@@ -187,7 +189,7 @@ body {
--foreground: var(--color-text-primary); --foreground: var(--color-text-primary);
--card: var(--theme-color-card-background); --card: var(--theme-color-card-background);
--card-foreground: var(--color-text-primary); --card-foreground: var(--color-text-primary);
--popover: var(--color-bg-secondary); --popover: var(--color-bg-elevated, var(--color-bg-secondary));
--popover-foreground: var(--color-text-primary); --popover-foreground: var(--color-text-primary);
--primary: var(--color-bg-primary); --primary: var(--color-bg-primary);
--primary-foreground: var(--color-text-primary); --primary-foreground: var(--color-text-primary);

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { useStorage } from '@vueuse/core';
export const groupSimilarTimeEntriesSetting = useStorage<boolean>(
'group-similar-time-entries',
true
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -150,7 +150,7 @@
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div> <div style="color: #71717a; font-weight: 600;">Duration</div>
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div> </div>
@if($showBillableRate) @if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
@@ -199,7 +199,7 @@
</span> </span>
</td> </td>
<td style="text-align: left;"> <td style="text-align: left;">
{{ $localization->formatInterval(CarbonInterval::seconds($group1Entry['seconds'])) }} {{ $localization->formatIntervalForReporting(CarbonInterval::seconds($group1Entry['seconds'])) }}
</td> </td>
@if($showBillableRate) @if($showBillableRate)
<td style="text-align: right;"> <td style="text-align: right;">
@@ -214,7 +214,7 @@
Total Total
</td> </td>
<td style="font-weight: 500;color: #18181b;"> <td style="font-weight: 500;color: #18181b;">
{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} {{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }}
</td> </td>
@if($showBillableRate) @if($showBillableRate)
<td style="text-align: right; font-weight: 500;color: #18181b;"> <td style="text-align: right; font-weight: 500;color: #18181b;">
@@ -282,7 +282,7 @@
@endif @endif
</td> </td>
<td> <td>
{{ $localization->formatInterval($duration) }} {{ $localization->formatIntervalForReporting($duration) }}
</td> </td>
<td> <td>
{{ $localization->formatNumber($duration->totalHours) }} {{ $localization->formatNumber($duration->totalHours) }}
@@ -403,7 +403,7 @@
type: "bar", type: "bar",
data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [ data: {!! json_encode(collect($dataHistoryChart['grouped_data'])->map(fn($value) => (object) [
'value' => $value['seconds'], 'value' => $value['seconds'],
'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatInterval(CarbonInterval::seconds((int) $value['seconds'])) 'name' => ((int) $value['seconds']) === 0 ? '' : $localization->formatIntervalForReporting(CarbonInterval::seconds((int) $value['seconds']))
])->toArray()) !!}, ])->toArray()) !!},
itemStyle: { itemStyle: {
borderColor: "#7dd3fc", borderColor: "#7dd3fc",

View File

@@ -138,7 +138,7 @@
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
<div style="color: #71717a; font-weight: 600;">Duration</div> <div style="color: #71717a; font-weight: 600;">Duration</div>
<div <div
style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatInterval(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div> style="font-size: 24px; font-weight: 500; margin-top: 2px;">{{ $localization->formatIntervalForReporting(CarbonInterval::seconds($aggregatedData['seconds'])) }} </div>
</div> </div>
@if($showBillableRate) @if($showBillableRate)
<div style="padding: 8px 12px; border-radius: 8px;"> <div style="padding: 8px 12px; border-radius: 8px;">
@@ -189,7 +189,7 @@
{{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }} {{ $localization->formatTime($timeEntry->start->timezone($timezone)) }} - {{ $localization->formatTime($timeEntry->end->timezone($timezone)) }}
</td> </td>
<td style="overflow-wrap: break-word; min-width: 75px;"> <td style="overflow-wrap: break-word; min-width: 75px;">
{{ $localization->formatInterval($timeEntry->getDuration()) }} {{ $localization->formatIntervalForReporting($timeEntry->getDuration()) }}
</td> </td>
<td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td> <td style="overflow-wrap: break-word;">{{ $timeEntry->billable ? 'Yes' : 'No' }}</td>
<td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td> <td style="overflow-wrap: break-word; min-width: 75px;">{{ count($timeEntry->tagsRelation) === 0 ? '-' : $timeEntry->tagsRelation->implode('name', ', ') }}</td>

View File

@@ -17,6 +17,7 @@ use App\Models\TimeEntry;
use App\Service\CurrencyService; use App\Service\CurrencyService;
use App\Service\Dto\ReportPropertiesDto; use App\Service\Dto\ReportPropertiesDto;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract; use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract;
@@ -82,10 +83,11 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$timezone = 'Europe/Vienna'; $timezone = 'Europe/Vienna';
$now = Carbon::now($timezone);
$reportDto = new ReportPropertiesDto; $reportDto = new ReportPropertiesDto;
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$reportDto->start = now()->subDays(2); $reportDto->start = $now->copy()->subDays(2);
$reportDto->end = now(); $reportDto->end = $now->copy();
$reportDto->group = TimeEntryAggregationType::Project; $reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task; $reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
@@ -102,9 +104,9 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
$task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([ $task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([
'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae', 'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae',
]); ]);
TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration(now()->subDay(), 100)->create(); TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration($now->copy()->subDay(), 100)->create();
TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create(); TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration($now->copy()->subDay(), 100)->create();
TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create(); TimeEntry::factory()->forOrganization($organization)->startWithDuration($now->copy()->subDay(), 100)->create();
$currencyService = app(CurrencyService::class); $currencyService = app(CurrencyService::class);
@@ -193,7 +195,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value, 'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
'grouped_data' => [ 'grouped_data' => [
[ [
'key' => now()->timezone($timezone)->subDays(2)->toDateString(), 'key' => $now->copy()->subDays(2)->toDateString(),
'seconds' => 0, 'seconds' => 0,
'cost' => 0, 'cost' => 0,
'grouped_type' => null, 'grouped_type' => null,
@@ -202,7 +204,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'color' => null, 'color' => null,
], ],
[ [
'key' => now()->timezone($timezone)->subDays(1)->toDateString(), 'key' => $now->copy()->subDays(1)->toDateString(),
'seconds' => 300, 'seconds' => 300,
'cost' => 0, 'cost' => 0,
'grouped_type' => null, 'grouped_type' => null,
@@ -211,7 +213,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'color' => null, 'color' => null,
], ],
[ [
'key' => now()->timezone($timezone)->toDateString(), 'key' => $now->toDateString(),
'seconds' => 0, 'seconds' => 0,
'cost' => 0, 'cost' => 0,
'grouped_type' => null, 'grouped_type' => null,
@@ -332,6 +334,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
{ {
// Arrange // Arrange
$timezone = 'Europe/Vienna'; $timezone = 'Europe/Vienna';
$now = Carbon::now($timezone);
$organization = Organization::factory()->create(); $organization = Organization::factory()->create();
$client = Client::factory()->forOrganization($organization)->create(); $client = Client::factory()->forOrganization($organization)->create();
$project = Project::factory()->forClient($client)->forOrganization($organization)->create(); $project = Project::factory()->forClient($client)->forOrganization($organization)->create();
@@ -341,14 +344,14 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
TimeEntry::factory()->forOrganization($organization) TimeEntry::factory()->forOrganization($organization)
->forTask($task) ->forTask($task)
->billable() ->billable()
->startWithDuration(now()->subDay(), 100) ->startWithDuration($now->copy()->subDay(), 100)
->create([ ->create([
'tags' => [$tag->getKey()], 'tags' => [$tag->getKey()],
]); ]);
$reportDto = new ReportPropertiesDto; $reportDto = new ReportPropertiesDto;
$reportDto->start = now()->subDays(2); $reportDto->start = $now->copy()->subDays(2);
$reportDto->end = now(); $reportDto->end = $now->copy();
$reportDto->group = TimeEntryAggregationType::Project; $reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task; $reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
@@ -394,7 +397,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'grouped_type' => TimeEntryAggregationTypeInterval::Day->value, 'grouped_type' => TimeEntryAggregationTypeInterval::Day->value,
'grouped_data' => [ 'grouped_data' => [
[ [
'key' => now()->timezone($timezone)->subDays(2)->toDateString(), 'key' => $now->copy()->subDays(2)->toDateString(),
'seconds' => 0, 'seconds' => 0,
'cost' => 0, 'cost' => 0,
'grouped_type' => null, 'grouped_type' => null,
@@ -403,7 +406,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'color' => null, 'color' => null,
], ],
[ [
'key' => now()->timezone($timezone)->subDays(1)->toDateString(), 'key' => $now->copy()->subDays(1)->toDateString(),
'seconds' => 0, 'seconds' => 0,
'cost' => 0, 'cost' => 0,
'grouped_type' => null, 'grouped_type' => null,
@@ -412,7 +415,7 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
'color' => null, 'color' => null,
], ],
[ [
'key' => now()->timezone($timezone)->toDateString(), 'key' => $now->toDateString(),
'seconds' => 0, 'seconds' => 0,
'cost' => 0, 'cost' => 0,
'grouped_type' => null, 'grouped_type' => null,

View File

@@ -2490,6 +2490,47 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertForbidden(); $response->assertForbidden();
} }
public function test_update_endpoint_fails_if_time_entry_belongs_to_different_organization_than_url_even_with_update_all_permission(): void
{
// Arrange
// Attacker: has `time-entries:update:all` in their own organization (orgA).
$data = $this->createUserWithPermission([
'time-entries:update:all',
'projects:view:all',
]);
$attackerProject = Project::factory()->forOrganization($data->organization)->create();
// Victim: entirely separate organization (orgB). Attacker has NO membership in orgB.
$victimOrgData = $this->createUserWithPermission([], true);
$victimTimeEntry = TimeEntry::factory()
->forOrganization($victimOrgData->organization)
->forMember($victimOrgData->ownerMember)
->create([
'description' => 'victim-original',
'project_id' => null,
'task_id' => null,
]);
Passport::actingAs($data->user);
// Act: PUT to /organizations/{orgA}/time-entries/{victim_uuid} — URL org is attacker's
// own org, but the route-bound time entry belongs to orgB.
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $victimTimeEntry->getKey()]), [
'description' => 'attacker-overwrite',
'project_id' => $attackerProject->getKey(),
]);
// Assert: must be rejected. Before the fix this returned 200 and rewrote the
// victim row with attacker's project_id while keeping organization_id = orgB.
$response->assertForbidden();
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $victimTimeEntry->getKey(),
'organization_id' => $victimOrgData->organization->getKey(),
'description' => 'victim-original',
'project_id' => null,
]);
}
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void public function test_update_endpoint_fails_if_user_has_no_permission_to_update_time_entries_for_other_users_in_organization(): void
{ {
// Arrange // Arrange

View File

@@ -129,6 +129,58 @@ class LocalizationServiceTest extends TestCaseWithDatabase
$this->assertSame('30001:03:04', $formatted); $this->assertSame('30001:03:04', $formatted);
} }
public function test_format_interval_for_reporting_with_type_decimal(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::Decimal);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30.001,05 h', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutes);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesColonSeparated);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_interval_for_reporting_with_type_hours_minutes_seconds_colon_separated(): void
{
// Arrange
$interval = CarbonInterval::seconds(4 + (60 * 3) + (60 * 60 * 30001));
$this->localizationService->setIntervalFormat(IntervalFormat::HoursMinutesSecondsColonSeparated);
// Act
$formatted = $this->localizationService->formatIntervalForReporting($interval);
// Assert
$this->assertSame('30001:03:04', $formatted);
}
public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void public function test_format_currency_with_type_symbol_after_with_space_and_number_format_thousands_space_decimal_comma(): void
{ {
// Arrange // Arrange

View File

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