migrate datepickers to shadcn, Fixes #877, #807

This commit is contained in:
Gregor Vostrak
2026-02-05 15:02:01 +01:00
parent 9832c688fe
commit a154293348
14 changed files with 613 additions and 93 deletions

View File

@@ -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,
}) => {

View File

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

View File

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

View File

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

View File

@@ -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() {
<InputLabel for="public_until" value="Expires at" />
<div class="text-text-tertiary font-medium">(optional)</div>
</div>
<DatePicker id="public_until"></DatePicker>
<DatePicker v-model="report.public_until"></DatePicker>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import TextInput from '../../../packages/ui/src/Input/TextInput.vue';
import SecondaryButton from '../../../packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import PrimaryButton from '../../../packages/ui/src/Buttons/PrimaryButton.vue';
import InputLabel from '../../../packages/ui/src/Input/InputLabel.vue';
import type { UpdateReportBody } from '@/packages/api/src';
@@ -13,6 +13,7 @@ import { Checkbox } from '@/packages/ui/src';
import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { useNotificationsStore } from '@/utils/notification';
import type { Report } from '@/packages/api/src';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
const show = defineModel('show', { default: false });
const saving = ref(false);
@@ -61,9 +62,21 @@ watch(
}
);
// Intermediate local variable for DatePicker (converts between UTC and localized)
const localPublicUntil = computed({
get: () => {
if (!report.value.public_until) return null;
return getLocalizedDayJs(report.value.public_until).format();
},
set: (value: string | null) => {
report.value.public_until = value ? getDayJsInstance()(value).utc().format() : null;
},
});
const { handleApiRequestNotifications } = useNotificationsStore();
async function submit() {
// public_until is already in UTC format from the computed setter
await handleApiRequestNotifications(
() => updateReportMutation.mutateAsync(report.value),
'Success',
@@ -111,7 +124,7 @@ async function submit() {
</div>
<div v-if="report.is_public" class="flex items-center space-x-4">
<InputLabel for="public_until" value="Expires at" />
<DatePicker id="public_until"></DatePicker>
<DatePicker v-model="localPublicUntil"></DatePicker>
</div>
</div>
</div>

View File

@@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps);
'h-8 w-8 p-0 font-normal',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
'data-[selected]:bg-quaternary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-quaternary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable

View File

@@ -1,11 +1,21 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import { twMerge } from 'tailwind-merge';
import { computed, inject, ref, type ComputedRef } from 'vue';
import {
getLocalizedDayJs,
formatDate,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import { Popover, PopoverContent, PopoverTrigger } from '@/packages/ui/src/popover';
import { Calendar } from '@/Components/ui/calendar';
import { Button } from '@/Components/ui/button';
import { CalendarIcon } from 'lucide-vue-next';
import { parseDate, type DateValue } from '@internationalized/date';
import type { Organization } from '@/packages/api/src';
const props = defineProps<{
class?: string;
tabindex?: string;
class?: string;
}>();
// This has to be a localized timestamp, not UTC
@@ -13,61 +23,64 @@ const model = defineModel<string | null>({
default: null,
});
const tempDate = ref(getLocalizedDayJs(model.value).format('YYYY-MM-DD'));
const emit = defineEmits(['changed']);
watch(model, (value) => {
tempDate.value = getLocalizedDayJs(value).format('YYYY-MM-DD');
const open = ref(false);
const organization = inject<ComputedRef<Organization>>('organization');
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const dateString = computed(() => {
if (!model.value) return null;
return getLocalizedDayJs(model.value).format('YYYY-MM-DD');
});
function updateDate(event: Event) {
const target = event.target as HTMLInputElement;
const newValue = target.value;
const newDate = getDayJsInstance()(newValue);
if (newDate.isValid()) {
model.value = getLocalizedDayJs(model.value)
.set('year', newDate.year())
.set('month', newDate.month())
.set('date', newDate.date())
.format();
emit('changed', model.value);
}
const calendarDate = computed(() => {
if (!dateString.value) return undefined;
return parseDate(dateString.value);
});
const displayDate = computed(() => {
if (!dateString.value) return '';
return formatDate(dateString.value, organization?.value?.date_format);
});
function handleDateSelect(newDate: DateValue | undefined) {
if (!newDate) return;
// If model.value is null, start from current date/time
const baseDate = model.value ? getLocalizedDayJs(model.value) : getLocalizedDayJs();
const newValue = baseDate
.set('year', newDate.year)
.set('month', newDate.month - 1) // CalendarDate months are 1-indexed, dayjs is 0-indexed
.set('date', newDate.day)
.format();
model.value = newValue;
emit('changed', newValue);
open.value = false;
}
const datePicker = ref<HTMLInputElement | null>(null);
function updateTempValue(event: Event) {
const target = event.target as HTMLInputElement;
tempDate.value = target.value;
}
const emit = defineEmits(['changed']);
</script>
<template>
<div class="flex items-center text-text-secondary">
<input
id="start"
ref="datePicker"
:tabindex="tabindex"
:class="
twMerge(
'bg-input-background border text-text-primary border-input-border focus-visible:outline-0 focus-visible:border-input-border-active focus-visible:ring-0 rounded-md',
props.class
)
"
type="date"
name="trip-start"
:value="tempDate"
@change="updateTempValue"
@blur="updateDate"
@keydown.enter="updateDate" />
<div class="w-full">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="input"
size="sm"
:tabindex="tabindex"
:class="['w-full px-2 gap-1.5', props.class]">
<CalendarIcon class="!size-3 text-muted-foreground" />
<span>{{ displayDate || 'Pick a date' }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="center">
<Calendar
mode="single"
:model-value="calendarDate"
:week-starts-on="weekStartsOn"
@update:model-value="handleDateSelect" />
</PopoverContent>
</Popover>
</div>
</template>
<style scoped>
input::-webkit-calendar-picker-indicator {
filter: invert(1);
opacity: 0.2;
}
</style>

View File

@@ -6,11 +6,18 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from 'lucide-vue-next';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
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 = defineProps<{
start: string;
end: string;
@@ -207,7 +214,8 @@ watch(open, (value) => {
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="today" />
:max-value="today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>

View File

@@ -4,7 +4,7 @@ import DatePicker from '@/packages/ui/src/Input/DatePicker.vue';
import { getDayJsInstance, getLocalizedDayJs } from '@/packages/ui/src/utils/time';
import dayjs from 'dayjs';
import TimePickerSimple from '@/packages/ui/src/Input/TimePickerSimple.vue';
import Button from '../Buttons/Button.vue';
import { Button } from '@/Components/ui/button';
const props = defineProps<{
start: string;
@@ -61,9 +61,10 @@ const dropdownContent = ref();
class="grid grid-cols-2 divide-x divide-card-background-separator text-center py-2">
<div class="px-2" @keydown.enter.prevent="nextTick(() => emit('close'))">
<div class="font-semibold text-text-primary text-sm pb-2">Start</div>
<div class="space-y-2">
<div class="flex flex-col items-center space-y-2 w-28 mx-auto">
<TimePickerSimple
v-model="tempStart"
class="w-full"
data-testid="time_entry_range_start"
tabindex="0"
:focus
@@ -71,35 +72,37 @@ const dropdownContent = ref();
@changed="updateTimeEntry"></TimePickerSimple>
<DatePicker
v-model="tempStart"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@changed="updateTimeEntry"
@blur.stop.prevent="emit('close')"></DatePicker>
class="w-full"
@changed="updateTimeEntry"></DatePicker>
</div>
</div>
<div class="px-2">
<div class="font-semibold text-text-primary text-sm pb-2">End</div>
<div v-if="end !== null && tempEnd !== null" class="space-y-2">
<div
v-if="end !== null && tempEnd !== null"
class="flex flex-col items-center space-y-2 w-28 mx-auto">
<TimePickerSimple
v-model="tempEnd"
class="w-full"
data-testid="time_entry_range_end"
@changed="updateTimeEntry"></TimePickerSimple>
<DatePicker
v-model="tempEnd"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
class="w-full"
@changed="updateTimeEntry"></DatePicker>
</div>
<div v-else-if="end === null && !showEndTimePicker">
<Button variant="outline" size="sm" @click="setEndTime"> Set End Time </Button>
</div>
<div v-else-if="showEndTimePicker && tempEnd !== null" class="space-y-2">
<div
v-else-if="showEndTimePicker && tempEnd !== null"
class="flex flex-col items-center space-y-2 w-28 mx-auto">
<TimePickerSimple
v-model="tempEnd"
class="w-full"
data-testid="time_entry_range_end"
@keydown.enter.prevent.stop="confirmEndTime"></TimePickerSimple>
<DatePicker
v-model="tempEnd"
class="text-xs text-text-tertiary max-w-24 px-1.5 py-1.5"
@keydown.enter.prevent="confirmEndTime"></DatePicker>
<DatePicker v-model="tempEnd" class="w-full"></DatePicker>
<Button variant="outline" size="sm" class="w-full" @click="confirmEndTime">
Confirm
</Button>

View File

@@ -235,22 +235,22 @@ const billableProxy = computed({
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
<DatePicker
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localStart" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
<DatePicker
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
</div>

View File

@@ -251,22 +251,25 @@ const billableProxy = computed({
</div>
<div class="">
<InputLabel>Start</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localStart" size="large"></TimePickerSimple>
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localStart"
class="w-full"
size="large"></TimePickerSimple>
<DatePicker
v-model="localStart"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
tabindex="1"></DatePicker>
</div>
</div>
<div class="">
<InputLabel>End</InputLabel>
<div class="flex flex-col items-center space-y-2 mt-1">
<TimePickerSimple v-model="localEnd" size="large"></TimePickerSimple>
<DatePicker
<div class="flex flex-col items-center space-y-2 mt-1 w-28 mx-auto">
<TimePickerSimple
v-model="localEnd"
tabindex="1"
class="text-xs text-text-tertiary max-w-28 px-1.5 py-1.5"></DatePicker>
class="w-full"
size="large"></TimePickerSimple>
<DatePicker v-model="localEnd" class="w-full" tabindex="1"></DatePicker>
</div>
</div>
</div>

View File

@@ -26,9 +26,9 @@ const forwardedProps = useForwardProps(delegatedProps);
'h-8 w-8 p-0 font-normal data-[selected]:opacity-100',
'[&[data-today]:not([data-selected])]:border-accent [&[data-today]:not([data-selected])]:border [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selection Start
'data-[selection-start]:bg-primary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-primary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
'data-[selection-start]:bg-quaternary data-[selection-start]:text-primary-foreground data-[selection-start]:hover:bg-quaternary data-[selection-start]:hover:text-primary-foreground data-[selection-start]:focus:bg-primary data-[selection-start]:focus:text-primary-foreground',
// Selection End
'data-[selection-end]:bg-primary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-primary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
'data-[selection-end]:bg-quaternary data-[selection-end]:text-primary-foreground data-[selection-end]:hover:bg-quaternary data-[selection-end]:hover:text-primary-foreground data-[selection-end]:focus:bg-primary data-[selection-end]:focus:text-primary-foreground',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
// Disabled

View File

@@ -21,6 +21,9 @@ export type DateFormat =
| 'hyphen-separated-mm-dd-yyyy'
| 'hyphen-separated-yyyy-mm-dd';
// Day of week index type for calendar components (0 = Sunday, 6 = Saturday)
export type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const dateFormatMap: Record<DateFormat, string> = {
'point-separated-d-m-yyyy': 'D.M.YYYY',
'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY',