diff --git a/e2e/shared-reports.spec.ts b/e2e/shared-reports.spec.ts
index db82235d..27605619 100644
--- a/e2e/shared-reports.spec.ts
+++ b/e2e/shared-reports.spec.ts
@@ -19,6 +19,10 @@ import {
// Each test registers a new user and creates test data, which needs more time
test.describe.configure({ timeout: 60000 });
+// Date picker button name patterns for different date formats
+const DATE_PICKER_BUTTON_PATTERN =
+ /^Pick a date$|^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
+
// ──────────────────────────────────────────────────
// Shared Report Lifecycle Tests
// ──────────────────────────────────────────────────
@@ -203,6 +207,128 @@ test('test that shared report with No Task filter shows entries without a task',
await expect(page.getByText('Total')).toBeVisible();
});
+// ──────────────────────────────────────────────────
+// Report Date Picker Tests
+// ──────────────────────────────────────────────────
+
+test('test that creating a report with an expiration date works', async ({ page }) => {
+ const projectName = 'DatePickerProj ' + Math.floor(Math.random() * 10000);
+ const reportName = 'DatePickerReport ' + Math.floor(Math.random() * 10000);
+
+ await createProject(page, projectName);
+ await createTimeEntryWithProject(page, projectName, '1h');
+
+ await goToReporting(page);
+ await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
+
+ // Open the save report modal
+ await page.getByRole('button', { name: 'Save Report' }).click();
+ await page.getByLabel('Name').fill(reportName);
+
+ // The "Public" checkbox should be checked by default, showing the date picker
+ const datePicker = page
+ .getByRole('dialog')
+ .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
+ await expect(datePicker).toBeVisible();
+ await datePicker.click();
+
+ // Select a date in the next month
+ const calendarGrid = page.getByRole('grid');
+ await expect(calendarGrid).toBeVisible({ timeout: 5000 });
+ await page.getByRole('button', { name: /Next/i }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
+
+ // Wait for the calendar to close
+ await expect(calendarGrid).not.toBeVisible();
+
+ // Create the report and verify it includes the public_until date
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (response) =>
+ response.url().includes('/reports') &&
+ response.request().method() === 'POST' &&
+ response.status() === 201
+ ),
+ page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),
+ ]);
+ const responseBody = await response.json();
+ expect(responseBody.data.public_until).toBeTruthy();
+});
+
+test('test that editing a report to make it public with expiration date works', async ({
+ page,
+}) => {
+ const projectName = 'EditDateProj ' + Math.floor(Math.random() * 10000);
+ const reportName = 'EditDateReport ' + Math.floor(Math.random() * 10000);
+
+ await createProject(page, projectName);
+ await createTimeEntryWithProject(page, projectName, '1h');
+
+ await goToReporting(page);
+ await expect(page.getByTestId('reporting_view').getByText(projectName)).toBeVisible();
+
+ // Open the save report modal and create a private report
+ await page.getByRole('button', { name: 'Save Report' }).click();
+ await page.getByLabel('Name').fill(reportName);
+
+ // Uncheck "Public" to create a private report
+ await page.getByLabel('Public').click();
+
+ await Promise.all([
+ page.waitForResponse(
+ (response) =>
+ response.url().includes('/reports') &&
+ response.request().method() === 'POST' &&
+ response.status() === 201
+ ),
+ page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),
+ ]);
+
+ // Go to shared reports and edit
+ await goToReportingShared(page);
+ await expect(page.getByText(reportName)).toBeVisible();
+ await expect(page.getByText('Private')).toBeVisible();
+
+ // Click more options and edit
+ await page
+ .getByRole('button', { name: new RegExp('Actions for Project ' + reportName) })
+ .click();
+ await page.getByRole('menuitem', { name: /^Edit Report/ }).click();
+
+ // Check "Public" to make it public - this should show the date picker
+ await page.getByLabel('Public').click();
+
+ // The date picker should now be visible
+ const datePicker = page
+ .getByRole('dialog')
+ .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN });
+ await expect(datePicker).toBeVisible();
+ await datePicker.click();
+
+ // Select a date in the next month
+ const calendarGrid = page.getByRole('grid');
+ await expect(calendarGrid).toBeVisible({ timeout: 5000 });
+ await page.getByRole('button', { name: /Next/i }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click();
+
+ // Wait for the calendar to close
+ await expect(calendarGrid).not.toBeVisible();
+
+ // Update the report and verify it includes the public_until date
+ const [response] = await Promise.all([
+ page.waitForResponse(
+ (response) =>
+ response.url().includes('/reports/') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ ),
+ page.getByRole('button', { name: 'Update Report' }).click(),
+ ]);
+ const responseBody = await response.json();
+ expect(responseBody.data.public_until).toBeTruthy();
+ expect(responseBody.data.is_public).toBe(true);
+});
+
test('test that shared report with No Client filter shows entries without a client', async ({
page,
}) => {
diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts
index 0776790c..06c7f2eb 100644
--- a/e2e/time.spec.ts
+++ b/e2e/time.spec.ts
@@ -11,10 +11,37 @@ import {
} from './utils/currentTimeEntry';
import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting';
+// Date picker button name patterns for different date formats
+// Matches: "Pick a date", "YYYY-MM-DD", "DD/MM/YYYY", "DD.MM.YYYY", "MM/DD/YYYY", "DD-MM-YYYY", "MM-DD-YYYY"
+const DATE_PICKER_BUTTON_PATTERN =
+ /^Pick a date$|^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
+// Same pattern but without "Pick a date" - for when we expect an actual date to be displayed
+const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
+
+/**
+ * Extracts day of month from an ISO timestamp string
+ */
+function getDayFromTimestamp(timestamp: string): number {
+ return new Date(timestamp).getUTCDate();
+}
+
+/**
+ * Extracts month (1-indexed) from an ISO timestamp string
+ */
+function getMonthFromTimestamp(timestamp: string): number {
+ return new Date(timestamp).getUTCMonth() + 1;
+}
+
async function goToTimeOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
}
+async function goToOrganizationSettings(page: Page) {
+ await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
+ await page.locator('[data-testid="organization_switcher"]:visible').click();
+ await page.getByText('Organization Settings').click();
+}
+
async function createEmptyTimeEntry(page: Page) {
await Promise.all([
newTimeEntryResponse(page),
@@ -310,7 +337,262 @@ test.skip('test that load more works when the end of page is reached', async ({
// TODO: Test Grouped time entries by description/project
-// TODO: Add Test for Date Update
+// Date Update Tests
+
+test('test that updating the start date of a time entry via the edit modal works', async ({
+ page,
+}) => {
+ await createBareTimeEntry(page, 'Date edit test', '1h');
+ await goToTimeOverview(page);
+
+ const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
+ const newTimeEntry = timeEntryRows.first();
+
+ // Open edit modal via the actions dropdown
+ const actionsDropdown = newTimeEntry
+ .getByRole('button', { name: 'Actions for the time entry' })
+ .first();
+ await actionsDropdown.click();
+ await page.getByTestId('time_entry_edit').click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ // Click the start date picker (first date picker button in the Start section)
+ const startDatePicker = page
+ .getByRole('dialog')
+ .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN })
+ .first();
+ await startDatePicker.click();
+
+ // Navigate to the previous month and select the 15th
+ await page.getByRole('button', { name: /Previous/i }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
+
+ // Get current month to calculate expected month after going to previous
+ const now = new Date();
+ const expectedMonth = now.getMonth() === 0 ? 12 : now.getMonth(); // Previous month (1-indexed)
+
+ // Submit the update and verify the response has correct date
+ const [updateResponse] = await Promise.all([
+ page.waitForResponse(
+ (response) =>
+ response.url().includes('/time-entries') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ ),
+ page.getByRole('button', { name: 'Update Time Entry' }).click(),
+ ]);
+ const updateBody = await updateResponse.json();
+ expect(updateBody.data.start).toBeTruthy();
+ expect(updateBody.data.end).toBeTruthy();
+ // Verify the day was changed to 15th
+ expect(getDayFromTimestamp(updateBody.data.start)).toBe(15);
+ // Verify the month is the previous month
+ expect(getMonthFromTimestamp(updateBody.data.start)).toBe(expectedMonth);
+});
+
+test('test that setting a date in the create modal works', async ({ page }) => {
+ await goToTimeOverview(page);
+
+ // Get today's date to compare later
+ const today = new Date();
+
+ // Open create modal
+ await page.getByRole('button', { name: 'Time entry actions' }).click();
+ await page.getByRole('menuitem', { name: 'Manual time entry' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ // Set description
+ await page
+ .getByRole('dialog')
+ .getByRole('textbox', { name: 'Description' })
+ .fill('Date picker test entry');
+
+ // Set duration first (to ensure the form is valid)
+ await page.locator('[role="dialog"] input[name="Duration"]').fill('1h');
+ await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
+
+ // Click the start date picker
+ const startDatePicker = page
+ .getByRole('dialog')
+ .getByRole('button', { name: DATE_PICKER_BUTTON_PATTERN })
+ .first();
+ await startDatePicker.click();
+
+ // Wait for calendar to appear
+ const calendarGrid = page.getByRole('grid');
+ await expect(calendarGrid).toBeVisible({ timeout: 5000 });
+
+ // Navigate to previous month and select the 15th (a day that's always in the middle of the month)
+ await page.getByRole('button', { name: /Previous/i }).click();
+ await page.getByRole('gridcell', { name: '15' }).getByRole('button').click();
+
+ // Wait for calendar to close
+ await expect(calendarGrid).not.toBeVisible();
+
+ // Get current month to calculate expected month after going to previous
+ const expectedMonth = today.getMonth() === 0 ? 12 : today.getMonth(); // Previous month (1-indexed)
+
+ // Submit and verify creation succeeds with correct date
+ const [createResponse] = await Promise.all([
+ page.waitForResponse(
+ (response) => response.url().includes('/time-entries') && response.status() === 201
+ ),
+ page.getByRole('button', { name: 'Create Time Entry' }).click(),
+ ]);
+ const createBody = await createResponse.json();
+ expect(createBody.data.start).toBeTruthy();
+ // Verify the day was set to 15th
+ expect(getDayFromTimestamp(createBody.data.start)).toBe(15);
+ // Verify the month is the previous month
+ expect(getMonthFromTimestamp(createBody.data.start)).toBe(expectedMonth);
+});
+
+test('test that updating the date via the time entry row range selector works', async ({
+ page,
+}) => {
+ await createBareTimeEntry(page, 'Date range test', '1h');
+ await goToTimeOverview(page);
+
+ const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
+ const newTimeEntry = timeEntryRows.first();
+ await expect(newTimeEntry).toBeVisible();
+
+ // Open the time range popover
+ const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
+ await timeEntryRangeElement.click();
+
+ // Verify the range selector dropdown is open
+ const rangeStart = page.getByTestId('time_entry_range_start');
+ await expect(rangeStart).toBeVisible();
+
+ // Click the start date picker button within the range selector
+ const startDatePicker = page.getByRole('button', { name: DATE_DISPLAY_PATTERN }).first();
+ await expect(startDatePicker).toBeVisible();
+ await startDatePicker.click();
+
+ // Wait for the calendar to appear and select a day
+ const calendarGrid = page.getByRole('grid');
+ await expect(calendarGrid).toBeVisible({ timeout: 5000 });
+
+ // Navigate to previous month and select the 5th
+ await page.getByRole('button', { name: /Previous/i }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^5$/ }).first().click();
+
+ // Get current month to calculate expected month after going to previous
+ const now = new Date();
+ const expectedMonth = now.getMonth() === 0 ? 12 : now.getMonth(); // Previous month (1-indexed)
+
+ // Verify the time entry update API call succeeds with correct date
+ const updateResponse = await page.waitForResponse(async (response) => {
+ return (
+ response.status() === 200 &&
+ response.request().method() === 'PUT' &&
+ (await response.headerValue('Content-Type')) === 'application/json'
+ );
+ });
+ const updateBody = await updateResponse.json();
+ expect(updateBody.data.start).toBeTruthy();
+ // Verify the day was changed to 5th
+ expect(getDayFromTimestamp(updateBody.data.start)).toBe(5);
+ // Verify the month is the previous month
+ expect(getMonthFromTimestamp(updateBody.data.start)).toBe(expectedMonth);
+});
+
+test('test that updating the end date via the time entry row range selector works', async ({
+ page,
+}) => {
+ await createBareTimeEntry(page, 'End date range test', '1h');
+ await goToTimeOverview(page);
+
+ const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
+ const newTimeEntry = timeEntryRows.first();
+ await expect(newTimeEntry).toBeVisible();
+
+ // Open the time range popover
+ const timeEntryRangeElement = newTimeEntry.getByTestId('time_entry_range_selector');
+ await timeEntryRangeElement.click();
+
+ // Verify the range selector dropdown is open
+ const rangeEnd = page.getByTestId('time_entry_range_end');
+ await expect(rangeEnd).toBeVisible();
+
+ // Click the end date picker button (second date picker)
+ const datePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN });
+ const endDatePicker = datePickers.nth(1);
+ await expect(endDatePicker).toBeVisible();
+ await endDatePicker.click();
+
+ // Wait for the calendar to appear
+ const calendarGrid = page.getByRole('grid');
+ await expect(calendarGrid).toBeVisible({ timeout: 5000 });
+
+ // Navigate to next month and select the 20th (to ensure end > start)
+ await page.getByRole('button', { name: /Next/i }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^20$/ }).first().click();
+
+ // Get current month to calculate expected month after going to next
+ const now = new Date();
+ const expectedMonth = now.getMonth() === 11 ? 1 : now.getMonth() + 2; // Next month (1-indexed)
+
+ // Verify the time entry update API call succeeds with correct date
+ const updateResponse = await page.waitForResponse(async (response) => {
+ return (
+ response.status() === 200 &&
+ response.request().method() === 'PUT' &&
+ (await response.headerValue('Content-Type')) === 'application/json'
+ );
+ });
+ const updateBody = await updateResponse.json();
+ expect(updateBody.data.end).toBeTruthy();
+ // Verify the day was changed to 20th
+ expect(getDayFromTimestamp(updateBody.data.end)).toBe(20);
+ // Verify the month is the next month
+ expect(getMonthFromTimestamp(updateBody.data.end)).toBe(expectedMonth);
+});
+
+test('test that date picker displays date in organization date format', async ({ page }) => {
+ // First change the organization date format to DD/MM/YYYY
+ await goToOrganizationSettings(page);
+ await page.getByLabel('Date Format').click();
+ await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
+ await Promise.all([
+ page
+ .locator('form')
+ .filter({ hasText: 'Date Format' })
+ .getByRole('button', { name: 'Save' })
+ .click(),
+ page.waitForResponse(
+ async (response) =>
+ response.url().includes('/organizations/') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200 &&
+ (await response.json()).data.date_format === 'slash-separated-dd-mm-yyyy'
+ ),
+ ]);
+
+ // Create a time entry and open the edit modal
+ await createBareTimeEntry(page, 'Date format test', '1h');
+ await goToTimeOverview(page);
+
+ const timeEntryRows = page.locator('[data-testid="time_entry_row"]');
+ const newTimeEntry = timeEntryRows.first();
+ await expect(newTimeEntry).toBeVisible();
+
+ // Open edit modal
+ const actionsDropdown = newTimeEntry
+ .getByRole('button', { name: 'Actions for the time entry' })
+ .first();
+ await actionsDropdown.click();
+ await page.getByTestId('time_entry_edit').click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+
+ // Verify the date picker shows the date in DD/MM/YYYY format
+ const datePicker = page
+ .getByRole('dialog')
+ .getByRole('button', { name: /^\d{2}\/\d{2}\/\d{4}$/ })
+ .first();
+ await expect(datePicker).toBeVisible();
+});
// TODO: Test that project can be created in the time entry row
diff --git a/e2e/timetracker.spec.ts b/e2e/timetracker.spec.ts
index ef4063d2..7b35ce65 100644
--- a/e2e/timetracker.spec.ts
+++ b/e2e/timetracker.spec.ts
@@ -10,6 +10,9 @@ import {
import type { Page } from '@playwright/test';
import { newTagResponse } from './utils/tags';
+// Date picker button name patterns for different date formats
+const DATE_DISPLAY_PATTERN = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$|^\d{2}\.\d{2}\.\d{4}$/;
+
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
@@ -254,6 +257,63 @@ test('test that adding a new tag when the timer is running', async ({ page }) =>
await assertThatTimerIsStopped(page);
});
+test('test that setting an end time with a different date via the timetracker range selector works', async ({
+ page,
+}) => {
+ await goToDashboard(page);
+
+ // Start a timer
+ await Promise.all([newTimeEntryResponse(page), startOrStopTimerWithButton(page)]);
+ await assertThatTimerHasStarted(page);
+
+ // Open the time range dropdown by clicking on the time display
+ await page.getByTestId('time_entry_time').click();
+ const rangeStart = page.getByTestId('time_entry_range_start');
+ await expect(rangeStart).toBeVisible();
+
+ // Click "Set End Time" button
+ await page.getByRole('button', { name: 'Set End Time' }).click();
+
+ // The end time picker should now be visible with a Confirm button
+ const rangeEnd = page.getByTestId('time_entry_range_end');
+ await expect(rangeEnd).toBeVisible();
+ const confirmButton = page.getByRole('button', { name: 'Confirm' });
+ await expect(confirmButton).toBeVisible();
+
+ // Click the end date picker to change the date
+ const endDatePickers = page.getByRole('button', { name: DATE_DISPLAY_PATTERN });
+ // The second date picker is the end date (first is the start date)
+ const endDatePicker = endDatePickers.nth(1);
+ await expect(endDatePicker).toBeVisible();
+ await endDatePicker.click();
+
+ // Calendar should appear
+ const calendarGrid = page.getByRole('grid');
+ await expect(calendarGrid).toBeVisible({ timeout: 5000 });
+
+ // Navigate to the next month and select a day to ensure end > start
+ await page.getByRole('button', { name: /Next/i }).click();
+ await page.getByRole('gridcell').filter({ hasText: /^15$/ }).first().click();
+
+ // The dropdown should still be open after selecting a date (not auto-closed)
+ await expect(rangeEnd).toBeVisible();
+ await expect(confirmButton).toBeVisible();
+
+ // Click Confirm to finalize and verify the API call
+ const [updateResponse] = await Promise.all([
+ page.waitForResponse(
+ (response) =>
+ response.url().includes('/time-entries') &&
+ response.request().method() === 'PUT' &&
+ response.status() === 200
+ ),
+ confirmButton.click(),
+ ]);
+ const updateBody = await updateResponse.json();
+ expect(updateBody.data.start).toBeTruthy();
+ expect(updateBody.data.end).toBeTruthy();
+});
+
// test that search is working
// test that adding a tag and project and starting the timer afterwards works and sets the project and tag correctly
diff --git a/e2e/utils/reporting.ts b/e2e/utils/reporting.ts
index acf80ae1..7e7bf973 100644
--- a/e2e/utils/reporting.ts
+++ b/e2e/utils/reporting.ts
@@ -314,5 +314,7 @@ export async function saveAsSharedReport(
page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click(),
]);
const responseBody = await response.json();
+ // Wait for navigation to shared reports page
+ await page.waitForURL('**/reporting/shared');
return { shareableLink: responseBody.data.shareable_link };
}
diff --git a/resources/js/Components/Common/Report/ReportCreateModal.vue b/resources/js/Components/Common/Report/ReportCreateModal.vue
index dd08f03c..62cfa345 100644
--- a/resources/js/Components/Common/Report/ReportCreateModal.vue
+++ b/resources/js/Components/Common/Report/ReportCreateModal.vue
@@ -12,6 +12,8 @@ import { api } from '@/packages/api/src';
import { Checkbox } from '@/packages/ui/src';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { useNotificationsStore } from '@/utils/notification';
+import { getDayJsInstance } from '@/packages/ui/src/utils/time';
+import { router } from '@inertiajs/vue3';
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -44,10 +46,14 @@ const report = ref({
const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() {
+ const publicUntil = report.value.public_until
+ ? getDayJsInstance()(report.value.public_until).utc().format()
+ : null;
await handleApiRequestNotifications(
() =>
createReportMutation.mutateAsync({
...report.value,
+ public_until: publicUntil,
properties: { ...props.properties },
}),
'Success',
@@ -60,6 +66,7 @@ async function submit() {
public_until: null,
};
show.value = false;
+ router.visit(route('reporting.shared'));
}
);
}
@@ -97,7 +104,7 @@ async function submit() {