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

View File

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

View File

@@ -117,13 +117,13 @@ jobs:
${{ env.GHCR_REPO }}
- name: "Login to Docker Hub Container Registry"
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -177,13 +177,13 @@ jobs:
merge-multiple: true
- name: "Login to Docker Hub"
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GHCR"
uses: docker/login-action@v4.6.0
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
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
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v7.1.1
uses: codecov/codecov-action@v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -44,7 +44,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'organization_id' => $this->organization->id,
], [
'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,
'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,
@@ -54,7 +54,6 @@ class ClockifyProjectsImporter extends DefaultImporter
if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]);
foreach ($tasks as $task) {
$this->checkTaskNameLength($task);
$this->taskImportHelper->getKey([
'name' => $task,
'project_id' => $projectId,
@@ -84,6 +83,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'Project',
'Status',
'Visibility',
'Billability',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {

View File

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

View File

@@ -21,8 +21,6 @@ use Illuminate\Database\Eloquent\Builder;
abstract class DefaultImporter implements ImporterContract
{
protected const TASK_NAME_MAX_LENGTH = 500;
protected Organization $organization;
/**
@@ -183,16 +181,6 @@ abstract class DefaultImporter implements ImporterContract
$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]
public function getReport(): ReportDto
{

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,7 +97,6 @@ class TogglTimeEntriesImporter extends DefaultImporter
}
$taskId = null;
if ($record['Task'] !== '') {
$this->checkTaskNameLength($record['Task']);
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'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 }) => {
await goToCalendar(page);
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 expect(page.getByTestId('calendar-title')).toContainText('Last Week');
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
await expect(page.locator('.fc')).toBeVisible();
// 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 expect(page.getByTestId('calendar-title')).toContainText('Next Week');
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 }) => {

View File

@@ -18,25 +18,6 @@ import {
// Each test registers a new user and creates test data via API
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
// ──────────────────────────────────────────────────

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();
});
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 }) => {
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 }) => {
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
// Should show "This Week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
// Should show "This week"
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
// Go to next week — the text assertions below auto-retry until the
// header label flips.
await page.getByTestId('timesheet_next_week').click();
// Should no longer show "This Week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
// Should no longer show "This week"
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
// Go back to this week
await page.getByTestId('timesheet_week_display').click();
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
});
// ──────────────────────────────────────────────────

View File

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

View File

@@ -43,7 +43,5 @@
<env name="NEWSLETTER_URL" value="null"/>
<env name="PASSPORT_PERSONAL_ACCESS_CLIENT_ID" 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>
</phpunit>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
<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<{
isCurrentWeek: boolean;
weekNumber: number;
rangeLabel: string;
weekRangeDisplay: string;
weekTotalFormatted: string;
}>();
@@ -17,16 +19,33 @@ defineEmits<{
<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 -->
<DateRangeNavigator
:label="rangeLabel"
:secondary-label="`W${weekNumber}`"
previous-test-id="timesheet_prev_week"
trigger-test-id="timesheet_week_display"
next-test-id="timesheet_next_week"
trigger-aria-label="Return to current week"
@previous="$emit('previous')"
@next="$emit('next')"
@select="$emit('current')" />
<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">

View File

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

View File

@@ -30,7 +30,6 @@ import {
getLocalizedDayJs,
} from '@/packages/ui/src/utils/time';
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
@@ -45,6 +44,7 @@ const {
weekEnd,
weekDays,
weekNumber,
isCurrentWeek,
todayDate,
goToPreviousWeek,
goToNextWeek,
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
);
const weekRangeLabel = computed(() =>
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
);
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 {
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
<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"
:range-label="weekRangeLabel"
:week-range-display="weekRangeDisplay"
:week-total-formatted="weekTotalFormatted"
@previous="goToPreviousWeek"
@next="goToNextWeek"

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
import { getWeekStartDayNumber } from '../utils/settings';
import { getDateRangeLabel } from '../utils/dateRange';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
return days;
});
const rangeLabel = computed(() => {
const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const days = viewDays.value;
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() {
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
activeView,
currentDate,
viewDays,
rangeLabel,
viewTitle,
emitDatesChange,
handlePrev,
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 { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import DateRangeNavigator from './DateRangeNavigator.vue';
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
import {
getDayJsInstance,
getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { type Organization } from '@/packages/api/src';
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 props = withDefaults(
defineProps<{
start: string;
end: string;
allowFuture?: boolean;
}>(),
{ allowFuture: false }
);
const props = defineProps<{
start: string;
end: string;
}>();
const emit = defineEmits<{
(e: 'update:start', value: string): void;
@@ -35,13 +35,10 @@ interface CalendarDateRange {
}
const today = computed(() => {
const now = getLocalizedDayJs();
const now = getDayJsInstance()();
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>({
get: () => ({
start: props.start
@@ -94,7 +91,7 @@ function setLastWeek() {
}
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());
open.value = false;
}
@@ -112,14 +109,14 @@ function setLastMonth() {
}
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());
open.value = false;
}
function setLast90Days() {
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
emit('update:end', getLocalizedDayJs().format());
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getDayJsInstance()().format());
open.value = false;
}
@@ -143,38 +140,6 @@ function setLastYear() {
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) => {
if (value === false) {
emit('submit');
@@ -183,120 +148,77 @@ watch(open, (value) => {
</script>
<template>
<DateRangeNavigator
:label="displayLabel"
:next-disabled="nextDisabled"
previous-test-id="date_range_picker_previous"
trigger-test-id="date_range_picker_display"
next-test-id="date_range_picker_next"
@previous="navigate(-1)"
@next="navigate(1)">
<template #trigger="{ triggerClass }">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="ghost"
size="sm"
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
data-testid="date_range_picker_display">
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
<span class="min-w-0 truncate">{{ displayLabel }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setToday"
>Today</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisWeek"
>This Week</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastWeek"
>Last Week</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast14Days"
>Last 14 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisMonth"
>This Month</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastMonth"
>Last Month</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast30Days"
>Last 30 Days</Button
>
<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>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
:class="
twMerge(
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
!modelValue && 'text-muted-foreground'
)
">
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
-
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
</template>
<template v-else>
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</template>
</template>
<template v-else> Pick a date </template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
>Today</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
>This Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
>Last Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
>Last 14 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
>This Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
>Last 30 days</Button
>
<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="today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Dialog, DialogContent, DialogFooter } from './dialog/index';
import { computed, nextTick } from 'vue';
import { computed } from 'vue';
const props = defineProps({
show: {
@@ -17,7 +17,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['close', 'submit']);
const emit = defineEmits(['close']);
const close = () => {
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(() => {
return {
sm: 'sm:max-w-sm',
@@ -58,7 +39,7 @@ const maxWidthClass = computed(() => {
<template>
<Dialog :open="show" @update:open="close">
<DialogContent :class="maxWidthClass">
<div class="min-w-0" @keydown.capture="onKeydownCapture">
<div class="min-w-0">
<slot />
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,27 +66,4 @@ class GenericTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->projectsCreated);
$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();
}
}