Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
78536dd1fa Fixed composer auth username 2026-09-21 18:20:55 +02:00
56 changed files with 305 additions and 975 deletions

View File

@@ -141,7 +141,7 @@ jobs:
${{ env.DOCKER_REPO }} ${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}
@@ -195,7 +195,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to solidtime OnPremise Registry" - name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: registry.on-premise.solidtime.io registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }} username: ${{ secrets.ONPREMISE_USERNAME }}

View File

@@ -177,7 +177,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: rg.fr-par.scw.cloud/solidtime registry: rg.fr-par.scw.cloud/solidtime
username: nologin username: nologin

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }} ${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry" - name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry" - name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true merge-multiple: true
- name: "Login to Docker Hub" - name: "Login to Docker Hub"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR" - name: "Login to GHCR"
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4.5.2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -1,136 +0,0 @@
name: PHPUnit Tests - Extensions
on: push
permissions:
contents: read
jobs:
phpunit-extensions:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: pcov
- uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Activate billing extension"
run: php artisan module:enable Billing
- name: "Activate services extension"
run: php artisan module:enable Services
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
- name: "Run PHPUnit"
run: php artisan test extensions/Billing/tests extensions/Services/tests extensions/Invoicing/tests --stop-on-failure

View File

@@ -68,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.1.1 uses: codecov/codecov-action@v7.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -44,7 +44,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
], [ ], [
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => ($record['Billability'] ?? '') === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null, 'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
@@ -54,7 +54,6 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($tasksKey !== null && $record[$tasksKey] !== '') { if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]); $tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) { foreach ($tasks as $task) {
$this->checkTaskNameLength($task);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task, 'name' => $task,
'project_id' => $projectId, 'project_id' => $projectId,
@@ -84,6 +83,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'Project', 'Project',
'Status', 'Status',
'Visibility', 'Visibility',
'Billability',
]; ];
foreach ($requiredFields as $requiredField) { foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) { if (! in_array($requiredField, $header, true)) {

View File

@@ -103,7 +103,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') { if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
$this->checkTaskNameLength($record[$taskKey]);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
@@ -228,6 +227,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Description', 'Description',
'User', 'User',
'Group',
'Email', 'Email',
'Tags', 'Tags',
'Start Date', 'Start Date',

View File

@@ -21,8 +21,6 @@ use Illuminate\Database\Eloquent\Builder;
abstract class DefaultImporter implements ImporterContract abstract class DefaultImporter implements ImporterContract
{ {
protected const TASK_NAME_MAX_LENGTH = 500;
protected Organization $organization; protected Organization $organization;
/** /**
@@ -183,16 +181,6 @@ abstract class DefaultImporter implements ImporterContract
$this->billableRateService = app(BillableRateService::class); $this->billableRateService = app(BillableRateService::class);
} }
/**
* @throws ImportException
*/
protected function checkTaskNameLength(string $taskName): void
{
if (strlen($taskName) > self::TASK_NAME_MAX_LENGTH) {
throw new ImportException('Task name ("'.$taskName.'") is too long, maximum length is '.self::TASK_NAME_MAX_LENGTH.' characters');
}
}
#[\Override] #[\Override]
public function getReport(): ReportDto public function getReport(): ReportDto
{ {

View File

@@ -113,7 +113,6 @@ class GenericTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['task'] !== '') { if ($record['task'] !== '') {
$this->checkTaskNameLength($record['task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['task'], 'name' => $record['task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -92,7 +92,6 @@ class HarvestTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -199,7 +199,6 @@ class SolidtimeImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task['name']);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task['name'], 'name' => $task['name'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -153,16 +153,9 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($projectMembers as $projectMember) { foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
if ($userId === null) {
throw new Exception('User does not exist');
}
$memberId = $this->memberImportHelper->getKeyByExternalIdentifier($userId);
if ($memberId === null) {
throw new Exception('Member does not exist');
}
$this->projectMemberImportHelper->getKey([ $this->projectMemberImportHelper->getKey([
'project_id' => $projectId, 'project_id' => $projectId,
'member_id' => $memberId, 'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
], [ ], [
'user_id' => $userId, 'user_id' => $userId,
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
@@ -189,7 +182,6 @@ class TogglDataImporter extends DefaultImporter
if ($projectId === null) { if ($projectId === null) {
throw new Exception('Project does not exist'); throw new Exception('Project does not exist');
} }
$this->checkTaskNameLength($task->name);
$this->taskImportHelper->getKey([ $this->taskImportHelper->getKey([
'name' => $task->name, 'name' => $task->name,
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -97,7 +97,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
} }
$taskId = null; $taskId = null;
if ($record['Task'] !== '') { if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'], 'name' => $record['Task'],
'project_id' => $projectId, 'project_id' => $projectId,

View File

@@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
test('test that calendar navigation buttons work', async ({ page }) => { test('test that calendar navigation buttons work', async ({ page }) => {
await goToCalendar(page); await goToCalendar(page);
await expect(page.locator('.fc')).toBeVisible(); await expect(page.locator('.fc')).toBeVisible();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate between named periods. // Click the "next" button to navigate forward
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.locator('.fc')).toBeVisible();
// Click the "prev" button to navigate back
await page.getByRole('button', { name: 'Previous' }).click(); await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Last Week'); await expect(page.locator('.fc')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
// Navigate forward first, then use the range button to return to today. // Navigate forward first, then click today
await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
await page.getByRole('button', { name: 'today' }).click(); await page.getByRole('button', { name: 'today' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week'); await expect(page.locator('.fc')).toBeVisible();
}); });
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {

View File

@@ -18,25 +18,6 @@ import {
// Each test registers a new user and creates test data via API // Each test registers a new user and creates test data via API
test.describe.configure({ timeout: 30000 }); test.describe.configure({ timeout: 30000 });
test('date range picker labels and navigates reporting periods', async ({ page }) => {
await goToReporting(page);
const range = page.getByTestId('date_range_picker_display');
const previous = page.getByTestId('date_range_picker_previous');
const next = page.getByTestId('date_range_picker_next');
await expect(range).toContainText('Last 14 Days');
await expect(next).toBeDisabled();
await Promise.all([waitForReportingUpdate(page), previous.click()]);
await expect(range).not.toContainText('Last 14 Days');
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
// does not necessarily trigger another aggregate request.
await next.click();
await expect(range).toContainText('Last 14 Days');
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Project Multiselect Dropdown Tests // Project Multiselect Dropdown Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1579,57 +1579,6 @@ test('test that project selection works in create modal', async ({ page, ctx })
expect(createBody.data.project_id).not.toBeNull(); expect(createBody.data.project_id).not.toBeNull();
}); });
test('test that ctrl+enter submits the create modal after selecting a project via keyboard', async ({
page,
ctx,
}) => {
// Regression test for https://github.com/solidtime-io/solidtime/issues/1238
const projectName = 'Keyboard Submit Project ' + Math.floor(1 + Math.random() * 10000);
await createProjectViaApi(ctx, { name: projectName });
await goToTimeOverview(page);
await page.getByRole('button', { name: 'Time entry actions' }).click();
await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The menu that opened the modal animates out and only then hands focus back to its
// own trigger.
await expect(page.locator('[role="menu"]')).toHaveCount(0);
// Fill the description, then move to the project dropdown and select a project purely via keyboard
const description = page.getByRole('dialog').getByRole('textbox', { name: 'Description' });
await description.fill('Keyboard submit test');
await description.press('Tab');
await expect(
page.getByRole('dialog').getByRole('button', { name: 'No Project' })
).toBeFocused();
await page.keyboard.press('Enter');
await page.getByTestId('client_dropdown_search').fill(projectName);
await expect(page.getByRole('option', { name: projectName })).toBeVisible();
await page.keyboard.press('Enter');
const projectTrigger = page.getByRole('dialog').getByRole('button', { name: projectName });
await expect(projectTrigger).toBeVisible();
// The trigger label updates on the next tick, but the dropdown keeps focus until its
// exit animation has finished and reka-ui hands focus back to the trigger.
await expect(projectTrigger).toBeFocused();
// Ctrl+Enter must submit even though focus is no longer on the description input
const [createResponse] = await Promise.all([
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
page.keyboard.press('Control+Enter'),
]);
const createBody = await createResponse.json();
expect(createBody.data.description).toBe('Keyboard submit test');
expect(createBody.data.project_id).not.toBeNull();
await expect(page.getByRole('dialog')).toBeHidden();
const newTimeEntry = page.locator('[data-testid="time_entry_row"]').first();
await expect(newTimeEntry.getByTestId('time_entry_description').first()).toHaveValue(
'Keyboard submit test'
);
});
test('test that tag selection works in create modal', async ({ page }) => { test('test that tag selection works in create modal', async ({ page }) => {
await goToTimeOverview(page); await goToTimeOverview(page);

View File

@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
test('can navigate forward and return to current week', async ({ page }) => { test('can navigate forward and return to current week', async ({ page }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]); await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This Week" // Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the // Go to next week — the text assertions below auto-retry until the
// header label flips. // header label flips.
await page.getByTestId('timesheet_next_week').click(); await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This Week" // Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week // Go back to this week
await page.getByTestId('timesheet_week_display').click(); await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
}); });
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -1,7 +1,7 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "main" "ref": "v0.0.6"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",

View File

@@ -43,7 +43,5 @@
<env name="NEWSLETTER_URL" value="null"/> <env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/> <env name="PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET" value="null"/>
<env name="PADDLE_API_KEY" value="test_phpunit_paddle_api_key"/>
<env name="PADDLE_SANDBOX" value="true"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -28,7 +28,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Client </span> <span> Create Client </span>

View File

@@ -30,7 +30,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Client </span> <span> Update Client </span>

View File

@@ -136,7 +136,7 @@ const roleDescription = computed(() => {
v-model:show="showOwnershipTransferConfirmModal" v-model:show="showOwnershipTransferConfirmModal"
:member-name="member.name" :member-name="member.name"
@submit="submit"></MemberOwnershipTransferConfirmModal> @submit="submit"></MemberOwnershipTransferConfirmModal>
<DialogModal closeable :show="show" @close="show = false" @submit="saveWithChecks()"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Member </span> <span> Update Member </span>

View File

@@ -78,7 +78,7 @@ useFocus(clientNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Invite Member </span> <span> Invite Member </span>

View File

@@ -79,7 +79,7 @@ async function submitBillableRate() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit Project {{ props.originalProject.name }} </span> <span> Edit Project {{ props.originalProject.name }} </span>

View File

@@ -38,7 +38,7 @@ useFocus(projectNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span>Add Project Member</span> <span>Add Project Member</span>

View File

@@ -63,7 +63,7 @@ useFocus(projectNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span>Edit Project Member</span> <span>Edit Project Member</span>

View File

@@ -79,7 +79,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Report </span> <span> Create Report </span>

View File

@@ -96,7 +96,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Report </span> <span> Create Report </span>

View File

@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = useSessionStorage<string>( const endDate = useSessionStorage<string>(
'reporting-end-date', 'reporting-end-date',

View File

@@ -36,7 +36,7 @@ useFocus(tagNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Tag </span> <span> Update Tag </span>

View File

@@ -50,7 +50,7 @@ useFocus(taskNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Task </span> <span> Create Task </span>

View File

@@ -35,7 +35,7 @@ useFocus(taskNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update Task </span> <span> Update Task </span>

View File

@@ -179,7 +179,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="emit('cancel')" @submit="submit"> <DialogModal closeable :show="show" @close="emit('cancel')">
<template #title> <template #title>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Coffee class="w-5 h-5 text-text-secondary" /> <Coffee class="w-5 h-5 text-text-secondary" />

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue'; import { Button } from '@/packages/ui/src/Buttons';
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
defineProps<{ defineProps<{
isCurrentWeek: boolean;
weekNumber: number; weekNumber: number;
rangeLabel: string; weekRangeDisplay: string;
weekTotalFormatted: string; weekTotalFormatted: string;
}>(); }>();
@@ -17,16 +19,33 @@ defineEmits<{
<template> <template>
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
<!-- Left: Week navigation --> <!-- Left: Week navigation -->
<DateRangeNavigator <div class="flex items-center gap-2">
:label="rangeLabel" <Button
:secondary-label="`W${weekNumber}`" variant="outline"
previous-test-id="timesheet_prev_week" size="icon"
trigger-test-id="timesheet_week_display" class="h-8 w-8"
next-test-id="timesheet_next_week" data-testid="timesheet_prev_week"
trigger-aria-label="Return to current week" @click="$emit('previous')">
@previous="$emit('previous')" <ChevronLeftIcon class="h-4 w-4" />
@next="$emit('next')" </Button>
@select="$emit('current')" /> <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 --> <!-- Right: Week total -->
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">

View File

@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
const startDate = useSessionStorage<string>( const startDate = useSessionStorage<string>(
'reporting-start-date', 'reporting-start-date',
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
); );
const endDate = useSessionStorage<string>( const endDate = useSessionStorage<string>(
'reporting-end-date', 'reporting-end-date',

View File

@@ -30,7 +30,6 @@ import {
getLocalizedDayJs, getLocalizedDayJs,
} from '@/packages/ui/src/utils/time'; } from '@/packages/ui/src/utils/time';
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement'; import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek'; import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations'; import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations'; import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
@@ -45,6 +44,7 @@ const {
weekEnd, weekEnd,
weekDays, weekDays,
weekNumber, weekNumber,
isCurrentWeek,
todayDate, todayDate,
goToPreviousWeek, goToPreviousWeek,
goToNextWeek, goToNextWeek,
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value) formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
); );
const weekRangeLabel = computed(() => const weekRangeDisplay = computed(() => {
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day')) 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 ────────────────────────────────── // ── Cell / row mutation handlers ──────────────────────────────────
const { const {
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
<AppLayout title="Timesheet" data-testid="timesheet_view"> <AppLayout title="Timesheet" data-testid="timesheet_view">
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6"> <div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
<TimesheetHeader <TimesheetHeader
:is-current-week="isCurrentWeek"
:week-number="weekNumber" :week-number="weekNumber"
:range-label="weekRangeLabel" :week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted" :week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek" @previous="goToPreviousWeek"
@next="goToNextWeek" @next="goToNextWeek"

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Modal from './Modal.vue'; import Modal from './Modal.vue';
const emit = defineEmits(['close', 'submit']); const emit = defineEmits(['close']);
defineProps({ defineProps({
show: { show: {
@@ -24,12 +24,7 @@ const close = () => {
</script> </script>
<template> <template>
<Modal <Modal :show="show" :max-width="maxWidth" :closeable="closeable" @close="close">
:show="show"
:max-width="maxWidth"
:closeable="closeable"
@close="close"
@submit="emit('submit')">
<div class="px-4 lg:px-6 py-4"> <div class="px-4 lg:px-6 py-4">
<div class="text-lg font-medium text-text-primary" role="heading"> <div class="text-lg font-medium text-text-primary" role="heading">
<slot name="title" /> <slot name="title" />

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { Tabs, TabsList } from '../tabs'; import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue'; import TabBarItem from '../TabBar/TabBarItem.vue';
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue'; import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings'; import type { CalendarSettings } from './calendarSettings';
defineProps<{ defineProps<{
rangeLabel: string; viewTitle: string;
activeView: string; activeView: string;
settings: CalendarSettings; settings: CalendarSettings;
}>(); }>();
@@ -22,13 +23,31 @@ const emit = defineEmits<{
<template> <template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5"> <div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<DateRangeNavigator <!-- Left: Navigation -->
:label="rangeLabel" <div class="flex items-center gap-1">
trigger-test-id="calendar-title" <Button
trigger-aria-label="today" variant="outline"
@previous="emit('prev')" size="sm"
@next="emit('next')" class="h-8 w-8 p-0"
@select="emit('today')" /> aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<!-- Right: View switcher + Settings --> <!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">

View File

@@ -138,7 +138,7 @@ const {
const { const {
activeView, activeView,
viewDays, viewDays,
rangeLabel, viewTitle,
emitDatesChange, emitDatesChange,
handlePrev, handlePrev,
handleNext, handleNext,
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<template v-if="!loading"> <template v-if="!loading">
<CalendarToolbar <CalendarToolbar
:range-label="rangeLabel" :view-title="viewTitle"
:active-view="activeView" :active-view="activeView"
:settings="calendarSettings" :settings="calendarSettings"
@prev="handlePrev" @prev="handlePrev"

View File

@@ -2,7 +2,6 @@ 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 { getWeekStartDayNumber } from '../utils/settings'; import { getWeekStartDayNumber } from '../utils/settings';
import { getDateRangeLabel } from '../utils/dateRange';
export function useCalendarNavigation(callbacks: { export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void; onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
return days; return days;
}); });
const rangeLabel = computed(() => { const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const days = viewDays.value; const days = viewDays.value;
if (days.length === 0) return ''; if (days.length === 0) return '';
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
const first = days[0]!;
const last = days[days.length - 1]!;
if (first.year() !== last.year()) {
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
}
if (first.month() !== last.month()) {
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
}
return first.format('MMMM YYYY');
}); });
function emitDatesChange() { function emitDatesChange() {
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
activeView, activeView,
currentDate, currentDate,
viewDays, viewDays,
rangeLabel, viewTitle,
emitDatesChange, emitDatesChange,
handlePrev, handlePrev,
handleNext, handleNext,

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
import { Button } from '../Buttons';
withDefaults(
defineProps<{
label: string;
secondaryLabel?: string;
previousDisabled?: boolean;
nextDisabled?: boolean;
previousTestId?: string;
triggerTestId?: string;
nextTestId?: string;
triggerAriaLabel?: string;
}>(),
{
secondaryLabel: undefined,
previousTestId: undefined,
triggerTestId: undefined,
nextTestId: undefined,
triggerAriaLabel: undefined,
}
);
const emit = defineEmits<{
previous: [];
next: [];
select: [];
}>();
const triggerClass =
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
</script>
<template>
<div
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
role="group"
aria-label="Date range navigation">
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Previous"
:disabled="previousDisabled"
:data-testid="previousTestId"
@click="emit('previous')">
<ChevronLeft class="h-4 w-4" />
</Button>
<slot name="trigger" :trigger-class="triggerClass">
<Button
type="button"
variant="ghost"
size="sm"
:class="triggerClass"
:aria-label="triggerAriaLabel"
:data-testid="triggerTestId"
@click="emit('select')">
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
<span class="min-w-0 truncate">{{ label }}</span>
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
&middot; {{ secondaryLabel }}
</span>
</Button>
</slot>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Next"
:disabled="nextDisabled"
:data-testid="nextTestId"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View File

@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from '@lucide/vue'; import { CalendarIcon } from '@lucide/vue';
import { computed, ref, inject, type ComputedRef, watch } from 'vue'; import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import DateRangeNavigator from './DateRangeNavigator.vue'; import {
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time'; getDayJsInstance,
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange'; getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src'; import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings'; import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay); const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const props = withDefaults( const props = defineProps<{
defineProps<{ start: string;
start: string; end: string;
end: string; }>();
allowFuture?: boolean;
}>(),
{ allowFuture: false }
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:start', value: string): void; (e: 'update:start', value: string): void;
@@ -35,13 +35,10 @@ interface CalendarDateRange {
} }
const today = computed(() => { const today = computed(() => {
const now = getLocalizedDayJs(); const now = getDayJsInstance()();
return new CalendarDate(now.year(), now.month() + 1, now.date()); return new CalendarDate(now.year(), now.month() + 1, now.date());
}); });
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
const modelValue = computed<CalendarDateRange>({ const modelValue = computed<CalendarDateRange>({
get: () => ({ get: () => ({
start: props.start start: props.start
@@ -94,7 +91,7 @@ function setLastWeek() {
} }
function setLast14Days() { function setLast14Days() {
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format()); emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format());
open.value = false; open.value = false;
} }
@@ -112,14 +109,14 @@ function setLastMonth() {
} }
function setLast30Days() { function setLast30Days() {
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format()); emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getLocalizedDayJs().format());
open.value = false; open.value = false;
} }
function setLast90Days() { function setLast90Days() {
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format()); emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getLocalizedDayJs().format()); emit('update:end', getDayJsInstance()().format());
open.value = false; open.value = false;
} }
@@ -143,38 +140,6 @@ function setLastYear() {
const organization = inject<ComputedRef<Organization>>('organization'); const organization = inject<ComputedRef<Organization>>('organization');
const displayLabel = computed(() => {
if (!startDay.value) return 'Pick a date';
if (!endDay.value) {
return getDateRangeLabel(startDay.value, startDay.value, {
dateFormat: organization?.value?.date_format,
});
}
return getDateRangeLabel(startDay.value, endDay.value, {
dateFormat: organization?.value?.date_format,
});
});
const nextRange = computed(() => {
if (!startDay.value || !endDay.value) return undefined;
return shiftDateRange(startDay.value, endDay.value, 1);
});
const nextDisabled = computed(
() =>
!props.allowFuture &&
!!nextRange.value &&
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
);
function navigate(direction: DateRangeDirection) {
if (!startDay.value || !endDay.value) return;
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
emit('update:start', shifted.start.format());
emit('update:end', shifted.end.format());
emit('submit');
}
watch(open, (value) => { watch(open, (value) => {
if (value === false) { if (value === false) {
emit('submit'); emit('submit');
@@ -183,120 +148,77 @@ watch(open, (value) => {
</script> </script>
<template> <template>
<DateRangeNavigator <Popover v-model:open="open">
:label="displayLabel" <PopoverTrigger as-child>
:next-disabled="nextDisabled" <Button
previous-test-id="date_range_picker_previous" variant="outline"
trigger-test-id="date_range_picker_display" :class="
next-test-id="date_range_picker_next" twMerge(
@previous="navigate(-1)" 'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
@next="navigate(1)"> !modelValue && 'text-muted-foreground'
<template #trigger="{ triggerClass }"> )
<Popover v-model:open="open"> ">
<PopoverTrigger as-child> <CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<Button <template v-if="modelValue.start">
type="button" <template v-if="modelValue.end">
variant="ghost" {{ formatDate(modelValue.start.toString(), organization?.date_format) }}
size="sm" -
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')" {{ formatDate(modelValue.end.toString(), organization?.date_format) }}
data-testid="date_range_picker_display"> </template>
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" /> <template v-else>
<span class="min-w-0 truncate">{{ displayLabel }}</span> {{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</Button> </template>
</PopoverTrigger> </template>
<PopoverContent class="w-auto p-0"> <template v-else> Pick a date </template>
<div class="flex divide-x divide-border-secondary"> </Button>
<div </PopoverTrigger>
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2"> <PopoverContent class="w-auto p-0">
<Button <div class="flex divide-x divide-border-secondary">
variant="ghost" <div
size="sm" class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
class="justify-start" <Button variant="ghost" size="sm" class="justify-start" @click="setToday"
@click="setToday" >Today</Button
>Today</Button >
> <Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
<Button >This Week</Button
variant="ghost" >
size="sm" <Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
class="justify-start" >Last Week</Button
@click="setThisWeek" >
>This Week</Button <Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
> >Last 14 days</Button
<Button >
variant="ghost" <Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
size="sm" >This Month</Button
class="justify-start" >
@click="setLastWeek" <Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Week</Button >Last Month</Button
> >
<Button <Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
variant="ghost" >Last 30 days</Button
size="sm" >
class="justify-start" <Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
@click="setLast14Days" >Last 90 days</Button
>Last 14 Days</Button >
> <Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
<Button >Last 12 months</Button
variant="ghost" >
size="sm" <Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
class="justify-start" >This year</Button
@click="setThisMonth" >
>This Month</Button <Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
> >Last year</Button
<Button >
variant="ghost" </div>
size="sm" <div class="pl-2">
class="justify-start" <RangeCalendar
@click="setLastMonth" v-model="modelValue"
>Last Month</Button initial-focus
> :number-of-months="2"
<Button :max-value="today"
variant="ghost" :week-starts-on="weekStartsOn" />
size="sm" </div>
class="justify-start" </div>
@click="setLast30Days" </PopoverContent>
>Last 30 Days</Button </Popover>
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast90Days"
>Last 90 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast12Months"
>Last 12 Months</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisYear"
>This Year</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastYear"
>Last Year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="allowFuture ? undefined : today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>
</DateRangeNavigator>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Dialog, DialogContent, DialogFooter } from './dialog/index'; import { Dialog, DialogContent, DialogFooter } from './dialog/index';
import { computed, nextTick } from 'vue'; import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
show: { show: {
@@ -17,7 +17,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['close', 'submit']); const emit = defineEmits(['close']);
const close = () => { const close = () => {
if (props.closeable) { if (props.closeable) {
@@ -25,25 +25,6 @@ const close = () => {
} }
}; };
// Ctrl+Enter (Cmd+Enter on macOS) submits the modal from any focused element inside it.
// Handled in the capture phase so child elements (buttons, dropdown triggers, inputs with
// their own Enter handlers) never see the keystroke and cannot open or double-submit.
async function onKeydownCapture(event: KeyboardEvent) {
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey) || event.isComposing) {
return;
}
event.preventDefault();
event.stopPropagation();
// Inputs like the time and duration fields commit their value on blur, so blur first
// and let the resulting model updates settle before submitting.
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
}
await nextTick();
emit('submit');
}
const maxWidthClass = computed(() => { const maxWidthClass = computed(() => {
return { return {
sm: 'sm:max-w-sm', sm: 'sm:max-w-sm',
@@ -58,7 +39,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog :open="show" @update:open="close"> <Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass"> <DialogContent :class="maxWidthClass">
<div class="min-w-0" @keydown.capture="onKeydownCapture"> <div class="min-w-0">
<slot /> <slot />
</div> </div>

View File

@@ -72,7 +72,7 @@ const currentClientName = computed(() => {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Project </span> <span> Create Project </span>

View File

@@ -30,7 +30,7 @@ useFocus(tagNameInput, { initialValue: true });
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create Tags </span> <span> Create Tags </span>

View File

@@ -78,7 +78,7 @@ async function submit() {
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400"> <div class="flex items-center space-x-2 text-amber-600 dark:text-amber-400">
<Coffee class="w-5 h-5" /> <Coffee class="w-5 h-5" />

View File

@@ -126,7 +126,7 @@ const billableProxy = computed({
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Create manual time entry </span> <span> Create manual time entry </span>

View File

@@ -164,7 +164,7 @@ const typeProxy = computed({
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Edit time entry </span> <span> Edit time entry </span>

View File

@@ -16,7 +16,6 @@ import {
} from '@/packages/api/src'; } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src'; import { Checkbox } from '@/packages/ui/src';
import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid'; import { TagIcon, ExclamationTriangleIcon } from '@heroicons/vue/20/solid';
import { XMarkIcon } from '@heroicons/vue/16/solid';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
import { Button } from '@/packages/ui/src/Buttons'; import { Button } from '@/packages/ui/src/Buttons';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue'; import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
@@ -61,13 +60,6 @@ const projectId = ref<string | null>(null);
const billable = ref<boolean | undefined>(undefined); const billable = ref<boolean | undefined>(undefined);
const selectedTags = ref<string[]>([]); const selectedTags = ref<string[]>([]);
// Clearing the project puts both fields back to "leave unchanged", the same state the form
// returns to after a successful submit.
function resetProject() {
projectId.value = null;
taskId.value = undefined;
}
const timeEntryBillable = computed({ const timeEntryBillable = computed({
get: () => { get: () => {
if (billable.value === undefined) { if (billable.value === undefined) {
@@ -155,7 +147,7 @@ const showBreakWarning = computed(
</script> </script>
<template> <template>
<DialogModal closeable :show="show" @close="show = false" @submit="submit"> <DialogModal closeable :show="show" @close="show = false">
<template #title> <template #title>
<div class="flex space-x-2"> <div class="flex space-x-2">
<span> Update {{ timeEntries.length }} time entries </span> <span> Update {{ timeEntries.length }} time entries </span>
@@ -190,59 +182,42 @@ const showBreakWarning = computed(
</Field> </Field>
<Field> <Field>
<FieldLabel for="project">Project</FieldLabel> <FieldLabel for="project">Project</FieldLabel>
<div class="flex items-center gap-1 min-w-0"> <TimeTrackerProjectTaskDropdown
<!-- the dropdown declares its own `class` prop, which goes to the v-model:project="projectId"
trigger button, so the growing has to happen on a wrapper --> v-model:task="taskId"
<div class="flex-1 min-w-0"> variant="input"
<TimeTrackerProjectTaskDropdown align="start"
v-model:project="projectId" size="default"
v-model:task="taskId" :clients
variant="input" :create-project
align="start" :create-client
size="default" :currency="currency"
:clients :organization-billable-rate="organizationBillableRate"
:create-project :can-create-project
:create-client empty-placeholder="Select project..."
:currency="currency" allow-reset
:organization-billable-rate="organizationBillableRate" :enable-estimated-time
:can-create-project :projects="projects"
empty-placeholder="Select project..." :tasks="tasks"></TimeTrackerProjectTaskDropdown>
:enable-estimated-time
:projects="projects"
:tasks="tasks"></TimeTrackerProjectTaskDropdown>
</div>
<button
v-if="projectId !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</Field> </Field>
<Field> <Field>
<FieldLabel>Tag</FieldLabel> <FieldLabel>Tag</FieldLabel>
<div class="flex items-center space-x-5"> <div class="flex space-x-5">
<!-- the dropdown root is `min-w-0`, so as a flex item it would shrink <TagDropdown
below its trigger and let the button overflow into the checkbox --> v-model="selectedTags"
<div class="shrink-0"> :create-tag
<TagDropdown :tags="tags"
v-model="selectedTags" :show-no-tag-option="false">
:create-tag <template #trigger>
:tags="tags" <Button variant="input" :disabled="removeAllTags">
:show-no-tag-option="false"> <TagIcon class="h-4 text-icon-default" />
<template #trigger> <span v-if="selectedTags.length > 0">
<Button variant="input" :disabled="removeAllTags"> Set {{ selectedTags.length }} tags
<TagIcon class="h-4 text-icon-default" /> </span>
<span v-if="selectedTags.length > 0"> <span v-else>Select Tags...</span>
Set {{ selectedTags.length }} tags </Button>
</span> </template>
<span v-else>Select Tags...</span> </TagDropdown>
</Button>
</template>
</TagDropdown>
</div>
<Field orientation="horizontal"> <Field orientation="horizontal">
<Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox> <Checkbox id="no_tags" v-model:checked="removeAllTags"></Checkbox>
<FieldLabel for="no_tags">Remove all tags</FieldLabel> <FieldLabel for="no_tags">Remove all tags</FieldLabel>

View File

@@ -12,7 +12,7 @@ import type {
Client, Client,
} from '@/packages/api/src'; } from '@/packages/api/src';
import { PlusCircleIcon, MinusIcon } 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';
@@ -58,6 +58,7 @@ const props = withDefaults(
createClient: (client: CreateClientBody) => Promise<Client | undefined>; createClient: (client: CreateClientBody) => Promise<Client | undefined>;
currency: string; currency: string;
emptyPlaceholder?: string; emptyPlaceholder?: string;
allowReset?: boolean;
noProjectValue?: string | null; noProjectValue?: string | null;
enableEstimatedTime: boolean; enableEstimatedTime: boolean;
organizationBillableRate: number | null; organizationBillableRate: number | null;
@@ -69,6 +70,7 @@ const props = withDefaults(
}>(), }>(),
{ {
emptyPlaceholder: 'No Project', emptyPlaceholder: 'No Project',
allowReset: false,
noProjectValue: NO_PROJECT_ID, noProjectValue: NO_PROJECT_ID,
variant: 'ghost', variant: 'ghost',
align: 'center', align: 'center',
@@ -556,6 +558,12 @@ function selectProject(projectId: string) {
emit('changed', project.value, task.value); emit('changed', project.value, task.value);
} }
function resetProject() {
project.value = null;
task.value = null;
emit('changed', project.value, task.value);
}
const showCreateProject = ref(false); const showCreateProject = ref(false);
</script> </script>
@@ -563,28 +571,40 @@ const showCreateProject = ref(false);
<Dropdown v-model="open" :close-on-content-click="false" :align="props.align"> <Dropdown v-model="open" :close-on-content-click="false" :align="props.align">
<template #trigger> <template #trigger>
<slot name="trigger"> <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-2.5 h-2.5 rounded-full shrink-0" <div
:style="{ backgroundColor: selectedProjectColor }"></div> class="w-2.5 h-2.5 rounded-full shrink-0"
<span class="truncate shrink-[1] text-text-primary">{{ :style="{ backgroundColor: selectedProjectColor }"></div>
selectedProjectName <span class="truncate shrink-[1] text-text-primary">{{
}}</span> selectedProjectName
<template v-if="currentTask"> }}</span>
<ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" /> <template v-if="currentTask">
<span class="truncate shrink-[100]">{{ currentTask.name }}</span> <ChevronRightIcon class="!size-3 text-text-primary shrink-0 -mx-1" />
</template> <span class="truncate shrink-[100]">{{ currentTask.name }}</span>
<template v-if="selectedClientName"> </template>
<span class="dark:text-text-tertiary text-text-quaternary shrink-0">•</span> <template v-if="selectedClientName">
<span <span class="dark:text-text-tertiary text-text-quaternary shrink-0"
class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary" >•</span
>{{ selectedClientName }}</span >
> <span
</template> class="truncate shrink-[200] dark:text-text-tertiary text-text-quaternary"
</Button> >{{ selectedClientName }}</span
>
</template>
</Button>
<button
v-if="allowReset && project !== null"
type="button"
data-testid="project_reset_button"
class="p-1 rounded hover:bg-quaternary text-text-tertiary hover:text-text-primary"
@click.stop="resetProject">
<XMarkIcon class="w-4 h-4" />
</button>
</div>
</slot> </slot>
</template> </template>
<template #content> <template #content>

View File

@@ -132,7 +132,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue'; import DateRangePicker from './Input/DateRangePicker.vue';
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
import { Label } from './label/index'; import { Label } from './label/index';
import { import {
NumberField, NumberField,
@@ -212,7 +211,6 @@ export {
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuTrigger, ContextMenuTrigger,
DateRangePicker, DateRangePicker,
DateRangeNavigator,
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,

View File

@@ -1,124 +0,0 @@
import dayjs from 'dayjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { getDayJsInstance, getLocalizedDayJs } from './time';
import { getDateRangeLabel, shiftDateRange } from './dateRange';
const reference = dayjs('2026-09-21');
afterEach(() => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
});
describe('getDateRangeLabel', () => {
test('labels relative weeks', () => {
getDayJsInstance();
expect(
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
referenceDate: reference,
})
).toBe('This Week');
expect(
getDateRangeLabel(
reference.subtract(1, 'week').startOf('week'),
reference.subtract(1, 'week').endOf('week'),
{ referenceDate: reference }
)
).toBe('Last Week');
});
test('labels common rolling ranges', () => {
expect(
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
referenceDate: reference,
})
).toBe('Last 14 Days');
});
test('labels full calendar months and years', () => {
expect(
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
referenceDate: reference,
})
).toBe('September 2025');
expect(
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
referenceDate: reference,
})
).toBe('2024');
});
test('keeps relative labels for the current periods', () => {
expect(
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
referenceDate: reference,
})
).toBe('This Month');
expect(
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
referenceDate: reference,
})
).toBe('This Year');
});
test('uses the organization date format for custom ranges', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
dateFormat: 'slash-separated-dd-mm-yyyy',
referenceDate: reference,
})
).toBe('03/08/2026 – 12/08/2026');
});
test('renders a single historical day without duplicating it', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
referenceDate: reference,
})
).toBe('Aug 3, 2026');
});
});
describe('shiftDateRange', () => {
test('moves full weeks as calendar periods', () => {
getDayJsInstance();
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
});
test('moves full months without truncating longer months', () => {
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
});
test('moves custom ranges by their inclusive duration', () => {
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
});
test('normalizes custom range offsets across the spring DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-03-14T23:00:00Z'),
getLocalizedDayJs('2026-03-27T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
});
test('normalizes custom range offsets across the autumn DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-10-17T22:00:00Z'),
getLocalizedDayJs('2026-10-30T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
});
});

View File

@@ -1,135 +0,0 @@
import type { Dayjs } from 'dayjs';
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
export type DateRangeDirection = -1 | 1;
function isSameDay(left: Dayjs, right: Dayjs): boolean {
return left.isSame(right, 'day');
}
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
}
function formatCompactRange(start: Dayjs, end: Dayjs): string {
if (start.year() !== end.year()) {
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
}
if (start.month() !== end.month()) {
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
}
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
}
/**
* Returns a human-friendly label for an inclusive date range. Relative labels
* are intentionally shared by reporting, the timesheet, and the calendar.
*/
export function getDateRangeLabel(
start: Dayjs,
end: Dayjs,
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
): string {
// Ensure the configured first day of the week is applied before using startOf('week').
getDayJsInstance();
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
const rangeStart = start.startOf('day');
const rangeEnd = end.startOf('day');
const relativeDays: Array<[number, string]> = [
[0, 'Today'],
[-1, 'Yesterday'],
[1, 'Tomorrow'],
];
for (const [offset, label] of relativeDays) {
const day = reference.add(offset, 'day');
if (isRange(rangeStart, rangeEnd, day, day)) return label;
}
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
['week', 0, 'This Week'],
['week', -1, 'Last Week'],
['week', 1, 'Next Week'],
['month', 0, 'This Month'],
['month', -1, 'Last Month'],
['month', 1, 'Next Month'],
['year', 0, 'This Year'],
['year', -1, 'Last Year'],
['year', 1, 'Next Year'],
];
for (const [unit, offset, label] of relativePeriods) {
const period = reference.add(offset, unit);
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
}
// Named calendar periods outside the relative windows above, e.g. "May 2025"
// for a full month and "2025" for a full year.
const monthStart = rangeStart.startOf('month');
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
return rangeStart.format('MMMM YYYY');
}
const yearStart = rangeStart.startOf('year');
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
return rangeStart.format('YYYY');
}
const rollingRanges: Array<[number, string]> = [
[14, 'Last 14 Days'],
[30, 'Last 30 Days'],
[90, 'Last 90 Days'],
];
for (const [days, label] of rollingRanges) {
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
return label;
}
}
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
return 'Last 12 Months';
}
if (isSameDay(rangeStart, rangeEnd)) {
return options.dateFormat
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
: rangeStart.format('MMM D, YYYY');
}
if (options.dateFormat) {
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
rangeEnd.format('YYYY-MM-DD'),
options.dateFormat
)}`;
}
return formatCompactRange(rangeStart, rangeEnd);
}
/** Shift an inclusive range by one period, preserving full calendar periods. */
export function shiftDateRange(
start: Dayjs,
end: Dayjs,
direction: DateRangeDirection
): { start: Dayjs; end: Dayjs } {
getDayJsInstance();
const rangeStart = start.startOf('day');
const rangeEnd = end.endOf('day');
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
return { start: shiftedStart, end: shiftedStart.endOf('year') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
return { start: shiftedStart, end: shiftedStart.endOf('month') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
return { start: shiftedStart, end: shiftedStart.endOf('week') };
}
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
return {
start: rangeStart.add(days * direction, 'day').startOf('day'),
end: rangeEnd.add(days * direction, 'day').endOf('day'),
};
}

View File

@@ -1,4 +0,0 @@
"Project","Client","Status","Visibility","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Project members","Project manager","Note"
"Project for Big Company","Big Company","Active","Public","Task 1, Task 2, Task 3","1.38","","","","","Constantin Graf","",""
"Project without Client","","Active","Public","","0.00","","","","","Constantin Graf","",""
"TEST","Big Company","Active","Public","","0.00","","","","","Constantin Graf","",""
1 Project Client Status Visibility Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Progress(%) Project members Project manager Note
2 Project for Big Company Big Company Active Public Task 1, Task 2, Task 3 1.38 Constantin Graf
3 Project without Client Active Public 0.00 Constantin Graf
4 TEST Big Company Active Public 0.00 Constantin Graf

View File

@@ -119,24 +119,6 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
); );
} }
public function test_import_of_test_file_without_billability_column_defaults_to_not_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports don't contain a "Billability" column.
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project for Big Company')->firstOrFail();
$this->assertFalse($project->is_billable);
}
public function test_import_supports_activities_column_alias_for_tasks(): void public function test_import_supports_activities_column_alias_for_tasks(): void
{ {
// Arrange // Arrange

View File

@@ -66,27 +66,4 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated); $this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated); $this->assertSame(0, $report->clientsCreated);
} }
public function test_import_fails_if_task_name_is_too_long(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$taskName = str_repeat('a', 501);
$data = "description,billable,client,project,tags,start,end,task,user_name,user_email\n".
'"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","'.$taskName.'","Peter Tester","peter.test@email.test"';
// Act
try {
$importer->importData($data, $timezone);
} catch (ImportException $e) {
// Assert
$this->assertSame('Task name ("'.$taskName.'") is too long, maximum length is 500 characters', $e->getMessage());
return;
}
$this->fail();
}
} }