mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 22:03:16 +01:00
Compare commits
2 Commits
feature/ad
...
project-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7233dfba | ||
|
|
398a81798b |
@@ -53,6 +53,47 @@ function todayAt(hour: number, minute: number = 0): string {
|
|||||||
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-day tests anchor to Wednesday and Thursday of the current week instead of today and
|
||||||
|
// tomorrow. E2E users start the week on Monday, so both days are always in the visible week,
|
||||||
|
// whichever day the suite runs on.
|
||||||
|
function wednesdayAt(hour: number, minute: number = 0): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const daysSinceMonday = (now.getDay() + 6) % 7;
|
||||||
|
return new Date(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth(),
|
||||||
|
now.getDate() - daysSinceMonday + 2,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function thursdayAt(hour: number, minute: number = 0): Date {
|
||||||
|
const d = wednesdayAt(hour, minute);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiTimestamp(date: Date): string {
|
||||||
|
return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local calendar date (YYYY-MM-DD), matching FullCalendar's data-date attributes
|
||||||
|
function toDateStr(date: Date): string {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function columnHeaderCenterX(page: Page, date: Date): Promise<number> {
|
||||||
|
const header = page.locator(`.fc-col-header-cell[data-date="${toDateStr(date)}"]`);
|
||||||
|
await expect(header).toBeVisible();
|
||||||
|
const box = await header.boundingBox();
|
||||||
|
return box!.x + box!.width / 2;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These tests verify that changing the project on a time entry via the calendar
|
* These tests verify that changing the project on a time entry via the calendar
|
||||||
* updates the billable status to match the new project's is_billable setting.
|
* updates the billable status to match the new project's is_billable setting.
|
||||||
@@ -252,19 +293,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c
|
|||||||
test('test that calendar navigation buttons work', async ({ page }) => {
|
test('test that calendar navigation buttons work', async ({ page }) => {
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
|
||||||
|
|
||||||
// Navigate between named periods.
|
// Click the "next" button to navigate forward
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
|
|
||||||
|
// Click the "prev" button to navigate back
|
||||||
await page.getByRole('button', { name: 'Previous' }).click();
|
await page.getByRole('button', { name: 'Previous' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('Last Week');
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
|
||||||
|
|
||||||
// Navigate forward first, then use the range button to return to today.
|
// Navigate forward first, then click today
|
||||||
await page.getByRole('button', { name: 'Next' }).click();
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('Next Week');
|
|
||||||
await page.getByRole('button', { name: 'today' }).click();
|
await page.getByRole('button', { name: 'today' }).click();
|
||||||
await expect(page.getByTestId('calendar-title')).toContainText('This Week');
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => {
|
||||||
@@ -962,14 +1003,9 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const now = new Date();
|
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
|
||||||
const dayOfWeek = now.getDay();
|
const start = toApiTimestamp(thursdayAt(0, 30));
|
||||||
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
|
const end = toApiTimestamp(thursdayAt(1, 30));
|
||||||
test.skip(dayOfWeek === 1, 'Skipping on Monday — previous day not visible in week view');
|
|
||||||
|
|
||||||
// Create entry: today 00:30 → today 01:30 (1 hour, near midnight)
|
|
||||||
const start = todayAt(0, 30);
|
|
||||||
const end = todayAt(1, 30);
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Drag up past midnight test',
|
description: 'Drag up past midnight test',
|
||||||
start,
|
start,
|
||||||
@@ -978,11 +1014,10 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const thursdayCol = page.locator(
|
||||||
.toISOString()
|
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||||||
.split('T')[0];
|
);
|
||||||
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
const event = thursdayCol
|
||||||
const event = todayCol
|
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Drag up past midnight test' });
|
.filter({ hasText: 'Drag up past midnight test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
@@ -1018,10 +1053,7 @@ test.describe('Drag-to-Move Events', () => {
|
|||||||
expect(newDurationMs).toBe(3600000);
|
expect(newDurationMs).toBe(3600000);
|
||||||
|
|
||||||
// The event should have moved to the previous day
|
// The event should have moved to the previous day
|
||||||
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
|
||||||
.toISOString()
|
|
||||||
.split('T')[0];
|
|
||||||
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1261,8 +1293,8 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
|
||||||
const start = todayAt(10);
|
const start = toApiTimestamp(wednesdayAt(10));
|
||||||
const end = todayAt(11);
|
const end = toApiTimestamp(wednesdayAt(11));
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Cross resize end test',
|
description: 'Cross resize end test',
|
||||||
start,
|
start,
|
||||||
@@ -1271,26 +1303,8 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Find a column AFTER today (for end resize, end must be > start)
|
// Resize into Thursday's column (for end resize, end must be > start)
|
||||||
const headers = page.locator('.fc-col-header-cell');
|
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
||||||
const headerCount = await headers.count();
|
|
||||||
let targetX: number | undefined;
|
|
||||||
let todayIndex = -1;
|
|
||||||
for (let i = 0; i < headerCount; i++) {
|
|
||||||
const header = headers.nth(i);
|
|
||||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
|
||||||
if (isToday) {
|
|
||||||
todayIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pick first column after today, or skip if today is last
|
|
||||||
for (let i = todayIndex + 1; i < headerCount; i++) {
|
|
||||||
const box = await headers.nth(i).boundingBox();
|
|
||||||
targetX = box!.x + box!.width / 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
test.skip(targetX === undefined, 'No column after today to resize to');
|
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1318,7 +1332,7 @@ test.describe('Resize Events', () => {
|
|||||||
// First drag down vertically to engage resize (like test 3.1)
|
// First drag down vertically to engage resize (like test 3.1)
|
||||||
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
|
||||||
// Then move horizontally to a later day column
|
// Then move horizontally to a later day column
|
||||||
await page.mouse.move(targetX!, bottomY + slotHeight * 4, { steps: 10 });
|
await page.mouse.move(targetX, bottomY + slotHeight * 4, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1340,8 +1354,8 @@ test.describe('Resize Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const start = todayAt(10);
|
const start = toApiTimestamp(wednesdayAt(10));
|
||||||
const end = todayAt(14);
|
const end = toApiTimestamp(wednesdayAt(14));
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Cross resize end test',
|
description: 'Cross resize end test',
|
||||||
start,
|
start,
|
||||||
@@ -1350,30 +1364,8 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
|
// Resize into Thursday's column (later day needed for end-edge resize)
|
||||||
const headers = page.locator('.fc-col-header-cell');
|
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
|
||||||
const headerCount = await headers.count();
|
|
||||||
let targetX: number | undefined;
|
|
||||||
let foundToday = false;
|
|
||||||
for (let i = 0; i < headerCount; i++) {
|
|
||||||
const header = headers.nth(i);
|
|
||||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
|
||||||
if (isToday) {
|
|
||||||
foundToday = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (foundToday) {
|
|
||||||
const box = await header.boundingBox();
|
|
||||||
targetX = box!.x + box!.width / 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If today is the last column, use the one before today instead won't work for end resize,
|
|
||||||
// so skip this test in that edge case
|
|
||||||
if (targetX === undefined) {
|
|
||||||
test.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1398,7 +1390,7 @@ test.describe('Resize Events', () => {
|
|||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Move to a different day column at same Y position
|
// Move to a different day column at same Y position
|
||||||
await page.mouse.move(targetX!, bottomY - 3, { steps: 15 });
|
await page.mouse.move(targetX, bottomY - 3, { steps: 15 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1464,22 +1456,9 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
|
||||||
// Create entry spanning today evening → tomorrow morning
|
// Create entry spanning Wednesday evening → Thursday morning
|
||||||
const start = todayAt(20);
|
const start = toApiTimestamp(wednesdayAt(20));
|
||||||
const tomorrow = new Date();
|
const end = toApiTimestamp(thursdayAt(10));
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
|
||||||
const end = new Date(
|
|
||||||
tomorrow.getFullYear(),
|
|
||||||
tomorrow.getMonth(),
|
|
||||||
tomorrow.getDate(),
|
|
||||||
10,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
.toISOString()
|
|
||||||
.replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi-day end resize',
|
description: 'Multi-day end resize',
|
||||||
@@ -1488,14 +1467,14 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
// Check if tomorrow column is visible
|
const thursdayCol = page.locator(
|
||||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||||||
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
);
|
||||||
|
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Find the event segment on tomorrow's column
|
// Find the event segment on Thursday's column
|
||||||
const event = tomorrowCol
|
const event = thursdayCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Multi-day end resize' })
|
.filter({ hasText: 'Multi-day end resize' })
|
||||||
.first();
|
.first();
|
||||||
@@ -1541,22 +1520,9 @@ test.describe('Resize Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
// Create entry spanning today → tomorrow
|
// Create entry spanning Wednesday → Thursday
|
||||||
const start = todayAt(10);
|
const start = toApiTimestamp(wednesdayAt(10));
|
||||||
const tomorrow = new Date();
|
const end = toApiTimestamp(thursdayAt(14));
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
|
|
||||||
const end = new Date(
|
|
||||||
tomorrow.getFullYear(),
|
|
||||||
tomorrow.getMonth(),
|
|
||||||
tomorrow.getDate(),
|
|
||||||
14,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
.toISOString()
|
|
||||||
.replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Backward end resize multi',
|
description: 'Backward end resize multi',
|
||||||
@@ -1565,29 +1531,16 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
|
|
||||||
// Check if tomorrow column is visible
|
const thursdayCol = page.locator(
|
||||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
|
||||||
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
|
);
|
||||||
|
|
||||||
await scrollCalendarToTime(page, '12:00:00');
|
await scrollCalendarToTime(page, '12:00:00');
|
||||||
|
|
||||||
// Find today's column header to get its X center
|
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
|
||||||
const headers = page.locator('.fc-col-header-cell');
|
|
||||||
const headerCount = await headers.count();
|
|
||||||
let todayX: number | undefined;
|
|
||||||
for (let i = 0; i < headerCount; i++) {
|
|
||||||
const header = headers.nth(i);
|
|
||||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
|
||||||
if (isToday) {
|
|
||||||
const box = await header.boundingBox();
|
|
||||||
todayX = box!.x + box!.width / 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test.skip(todayX === undefined, 'Could not find today column header');
|
|
||||||
|
|
||||||
// Find event segment on tomorrow's column and resize end backward to today
|
// Find event segment on Thursday's column and resize end backward to Wednesday
|
||||||
const event = tomorrowCol
|
const event = thursdayCol
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Backward end resize multi' })
|
.filter({ hasText: 'Backward end resize multi' })
|
||||||
.first();
|
.first();
|
||||||
@@ -1609,9 +1562,9 @@ test.describe('Resize Events', () => {
|
|||||||
await page.mouse.move(centerX, bottomY - 3);
|
await page.mouse.move(centerX, bottomY - 3);
|
||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Drag down a bit first, then move to today's column at a Y after the start
|
// Drag down a bit first, then move to Wednesday's column at a Y after the start
|
||||||
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
|
||||||
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
|
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
})(),
|
})(),
|
||||||
]);
|
]);
|
||||||
@@ -1627,8 +1580,8 @@ test.describe('Resize Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
|
||||||
const start = todayAt(10);
|
const start = toApiTimestamp(thursdayAt(10));
|
||||||
const end = todayAt(14);
|
const end = toApiTimestamp(thursdayAt(14));
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'End before start test',
|
description: 'End before start test',
|
||||||
start,
|
start,
|
||||||
@@ -1637,25 +1590,8 @@ test.describe('Resize Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '09:00:00');
|
await scrollCalendarToTime(page, '09:00:00');
|
||||||
|
|
||||||
// Find a column BEFORE today
|
// Target Wednesday's column, the day before the entry
|
||||||
const headers = page.locator('.fc-col-header-cell');
|
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
|
||||||
const headerCount = await headers.count();
|
|
||||||
let targetX: number | undefined;
|
|
||||||
let todayIndex = -1;
|
|
||||||
for (let i = 0; i < headerCount; i++) {
|
|
||||||
const header = headers.nth(i);
|
|
||||||
const isToday = await header.evaluate((el) => el.classList.contains('fc-day-today'));
|
|
||||||
if (isToday) {
|
|
||||||
todayIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = todayIndex - 1; i >= 0; i--) {
|
|
||||||
const box = await headers.nth(i).boundingBox();
|
|
||||||
targetX = box!.x + box!.width / 2;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
test.skip(targetX === undefined, 'No column before today to test');
|
|
||||||
|
|
||||||
const event = page
|
const event = page
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
@@ -1683,7 +1619,7 @@ test.describe('Resize Events', () => {
|
|||||||
await page.waitForTimeout(100);
|
await page.waitForTimeout(100);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
// Move to earlier column at a Y position near the top of the grid (before start time)
|
// Move to earlier column at a Y position near the top of the grid (before start time)
|
||||||
await page.mouse.move(targetX!, eventBox!.y - slotHeight * 4, { steps: 15 });
|
await page.mouse.move(targetX, eventBox!.y - slotHeight * 4, { steps: 15 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
// Wait for any potential API call
|
// Wait for any potential API call
|
||||||
@@ -1732,30 +1668,21 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const now = new Date();
|
|
||||||
const dayOfWeek = now.getDay();
|
|
||||||
// Need today and tomorrow both visible (skip Saturday with Monday week start)
|
|
||||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view');
|
|
||||||
|
|
||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await expect(page.locator('.fc')).toBeVisible();
|
await expect(page.locator('.fc')).toBeVisible();
|
||||||
// Use mid-day times so both start and end slots are visible in the viewport
|
// Use mid-day times so both start and end slots are visible in the viewport
|
||||||
await scrollCalendarToTime(page, '10:00:00');
|
await scrollCalendarToTime(page, '10:00:00');
|
||||||
|
|
||||||
// Find today's and tomorrow's columns
|
// Find Wednesday's and Thursday's columns
|
||||||
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const wednesdayStr = toDateStr(wednesdayAt(0));
|
||||||
.toISOString()
|
const thursdayStr = toDateStr(thursdayAt(0));
|
||||||
.split('T')[0];
|
|
||||||
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
|
||||||
.toISOString()
|
|
||||||
.split('T')[0];
|
|
||||||
|
|
||||||
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
|
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
|
||||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
|
||||||
await expect(todayCol).toBeVisible();
|
await expect(wednesdayCol).toBeVisible();
|
||||||
await expect(tomorrowCol).toBeVisible();
|
await expect(thursdayCol).toBeVisible();
|
||||||
|
|
||||||
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
|
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
|
||||||
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first();
|
||||||
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first();
|
||||||
await expect(startSlot).toBeVisible();
|
await expect(startSlot).toBeVisible();
|
||||||
@@ -1763,18 +1690,18 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
|
|
||||||
const startSlotBox = await startSlot.boundingBox();
|
const startSlotBox = await startSlot.boundingBox();
|
||||||
const endSlotBox = await endSlot.boundingBox();
|
const endSlotBox = await endSlot.boundingBox();
|
||||||
const todayColBox = await todayCol.boundingBox();
|
const wednesdayColBox = await wednesdayCol.boundingBox();
|
||||||
const tomorrowColBox = await tomorrowCol.boundingBox();
|
const thursdayColBox = await thursdayCol.boundingBox();
|
||||||
|
|
||||||
// Start drag at 11:00 on today's column
|
// Start drag at 11:00 on Wednesday's column
|
||||||
const startX = todayColBox!.x + todayColBox!.width / 2;
|
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
|
||||||
const startY = startSlotBox!.y + 2;
|
const startY = startSlotBox!.y + 2;
|
||||||
|
|
||||||
// End drag at 13:00 on tomorrow's column
|
// End drag at 13:00 on Thursday's column
|
||||||
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
|
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
|
||||||
const endY = endSlotBox!.y + 2;
|
const endY = endSlotBox!.y + 2;
|
||||||
|
|
||||||
// Drag from today to tomorrow — move down first, then across
|
// Drag from Wednesday to Thursday, moving down first, then across
|
||||||
const slotHeight = await getSlotHeight(page);
|
const slotHeight = await getSlotHeight(page);
|
||||||
await page.mouse.move(startX, startY);
|
await page.mouse.move(startX, startY);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
@@ -1792,9 +1719,9 @@ test.describe('Click-Drag Selection to Create', () => {
|
|||||||
await expect(dialog.getByText('Start')).toBeVisible();
|
await expect(dialog.getByText('Start')).toBeVisible();
|
||||||
await expect(dialog.getByText('End')).toBeVisible();
|
await expect(dialog.getByText('End')).toBeVisible();
|
||||||
|
|
||||||
// Start date should be today, end date should be tomorrow
|
// Start date should be Wednesday, end date should be Thursday
|
||||||
await expect(dialog.getByText(todayStr)).toBeVisible();
|
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
|
||||||
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
|
await expect(dialog.getByText(thursdayStr)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1876,17 +1803,9 @@ test.describe('Timezone & Localization', () => {
|
|||||||
|
|
||||||
test.describe('Multi-Day Events', () => {
|
test.describe('Multi-Day Events', () => {
|
||||||
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
|
||||||
// Create entry that spans from today 22:00 to tomorrow 02:00
|
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
|
||||||
const now = new Date();
|
const start = toApiTimestamp(wednesdayAt(22));
|
||||||
const dayOfWeek = now.getDay();
|
const end = toApiTimestamp(thursdayAt(2));
|
||||||
// If today is Saturday (6), the entry would span to next week and may not be visible
|
|
||||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
|
||||||
|
|
||||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
|
||||||
|
|
||||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day entry',
|
description: 'Multi day entry',
|
||||||
@@ -1901,14 +1820,8 @@ test.describe('Multi-Day Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
test('multi-day event can be edited via click', async ({ page, ctx }) => {
|
||||||
const now = new Date();
|
const start = toApiTimestamp(wednesdayAt(22));
|
||||||
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
const end = toApiTimestamp(thursdayAt(2));
|
||||||
|
|
||||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
|
||||||
|
|
||||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day edit test',
|
description: 'Multi day edit test',
|
||||||
@@ -1927,14 +1840,8 @@ test.describe('Multi-Day Events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multi-day event context menu works', async ({ page, ctx }) => {
|
test('multi-day event context menu works', async ({ page, ctx }) => {
|
||||||
const now = new Date();
|
const start = toApiTimestamp(wednesdayAt(22));
|
||||||
test.skip(now.getDay() === 6, 'Skip on Saturday');
|
const end = toApiTimestamp(thursdayAt(2));
|
||||||
|
|
||||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
|
||||||
|
|
||||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day ctx test',
|
description: 'Multi day ctx test',
|
||||||
@@ -1954,16 +1861,11 @@ test.describe('Multi-Day Events', () => {
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const now = new Date();
|
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
||||||
const dayOfWeek = now.getDay();
|
const startDate = wednesdayAt(22);
|
||||||
// Need today and tomorrow both visible (skip Saturday)
|
const endDate = thursdayAt(2);
|
||||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
const start = toApiTimestamp(startDate);
|
||||||
|
const end = toApiTimestamp(endDate);
|
||||||
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
|
||||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
|
||||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day drag test',
|
description: 'Multi day drag test',
|
||||||
@@ -1973,12 +1875,9 @@ test.describe('Multi-Day Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
// Find the clipped segment on Thursday's column (00:00-02:00)
|
||||||
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
||||||
.toISOString()
|
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
||||||
.split('T')[0];
|
|
||||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
|
||||||
const event = tomorrowCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
|
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const eventBox = await event.boundingBox();
|
const eventBox = await event.boundingBox();
|
||||||
@@ -2013,27 +1912,19 @@ test.describe('Multi-Day Events', () => {
|
|||||||
// Duration must be preserved (4 hours)
|
// Duration must be preserved (4 hours)
|
||||||
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
|
||||||
|
|
||||||
// The start should still be on today (not jumped to tomorrow)
|
// The start should still be on Wednesday (not jumped to Thursday)
|
||||||
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
|
||||||
.toISOString()
|
|
||||||
.split('T')[0];
|
|
||||||
expect(newStart.toISOString().split('T')[0]).toBe(todayStr);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
test('dragging clipped segment of multi-day event upward shifts event earlier', async ({
|
||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
const now = new Date();
|
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
|
||||||
const dayOfWeek = now.getDay();
|
const startDate = wednesdayAt(22);
|
||||||
// Need today and tomorrow both visible (skip Saturday)
|
const endDate = thursdayAt(2);
|
||||||
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
|
const start = toApiTimestamp(startDate);
|
||||||
|
const end = toApiTimestamp(endDate);
|
||||||
// Create entry: today 22:00 → tomorrow 02:00 (4 hours, spanning 2 days)
|
|
||||||
const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 0, 0);
|
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 2, 0, 0);
|
|
||||||
const start = startDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
const end = endDate.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
||||||
|
|
||||||
await createTimeEntryWithTimestampsViaApi(ctx, {
|
await createTimeEntryWithTimestampsViaApi(ctx, {
|
||||||
description: 'Multi day drag up test',
|
description: 'Multi day drag up test',
|
||||||
@@ -2043,12 +1934,9 @@ test.describe('Multi-Day Events', () => {
|
|||||||
await goToCalendar(page);
|
await goToCalendar(page);
|
||||||
await scrollCalendarToTime(page, '00:00:00');
|
await scrollCalendarToTime(page, '00:00:00');
|
||||||
|
|
||||||
// Find the clipped segment on tomorrow's column (00:00-02:00)
|
// Find the clipped segment on Thursday's column (00:00-02:00)
|
||||||
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
|
||||||
.toISOString()
|
const event = thursdayCol
|
||||||
.split('T')[0];
|
|
||||||
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
|
|
||||||
const event = tomorrowCol
|
|
||||||
.locator('.fc-event')
|
.locator('.fc-event')
|
||||||
.filter({ hasText: 'Multi day drag up test' });
|
.filter({ hasText: 'Multi day drag up test' });
|
||||||
await expect(event).toBeVisible({ timeout: 10000 });
|
await expect(event).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -18,25 +18,6 @@ import {
|
|||||||
// Each test registers a new user and creates test data via API
|
// Each test registers a new user and creates test data via API
|
||||||
test.describe.configure({ timeout: 30000 });
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
test('date range picker labels and navigates reporting periods', async ({ page }) => {
|
|
||||||
await goToReporting(page);
|
|
||||||
|
|
||||||
const range = page.getByTestId('date_range_picker_display');
|
|
||||||
const previous = page.getByTestId('date_range_picker_previous');
|
|
||||||
const next = page.getByTestId('date_range_picker_next');
|
|
||||||
|
|
||||||
await expect(range).toContainText('Last 14 Days');
|
|
||||||
await expect(next).toBeDisabled();
|
|
||||||
|
|
||||||
await Promise.all([waitForReportingUpdate(page), previous.click()]);
|
|
||||||
await expect(range).not.toContainText('Last 14 Days');
|
|
||||||
|
|
||||||
// The initial reporting range is still fresh in Vue Query's cache, so returning to it
|
|
||||||
// does not necessarily trigger another aggregate request.
|
|
||||||
await next.click();
|
|
||||||
await expect(range).toContainText('Last 14 Days');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// Project Multiselect Dropdown Tests
|
// Project Multiselect Dropdown Tests
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page,
|
|||||||
test('can navigate forward and return to current week', async ({ page }) => {
|
test('can navigate forward and return to current week', async ({ page }) => {
|
||||||
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]);
|
||||||
|
|
||||||
// Should show "This Week"
|
// Should show "This week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||||||
|
|
||||||
// Go to next week — the text assertions below auto-retry until the
|
// Go to next week — the text assertions below auto-retry until the
|
||||||
// header label flips.
|
// header label flips.
|
||||||
await page.getByTestId('timesheet_next_week').click();
|
await page.getByTestId('timesheet_next_week').click();
|
||||||
|
|
||||||
// Should no longer show "This Week"
|
// Should no longer show "This week"
|
||||||
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week');
|
||||||
|
|
||||||
// Go back to this week
|
// Go back to this week
|
||||||
await page.getByTestId('timesheet_week_display').click();
|
await page.getByTestId('timesheet_week_display').click();
|
||||||
|
|
||||||
await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week');
|
await expect(page.getByTestId('timesheet_week_display')).toContainText('This week');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
|
|||||||
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
|
||||||
|
|
||||||
export type SortColumn =
|
export type SortColumn =
|
||||||
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
|
| 'name'
|
||||||
|
| 'tasks'
|
||||||
|
| 'client_name'
|
||||||
|
| 'spent_time'
|
||||||
|
| 'progress'
|
||||||
|
| 'billable_rate'
|
||||||
|
| 'status'
|
||||||
|
| 'visibility';
|
||||||
export type { SortDirection } from '@/utils/useSortableTable';
|
export type { SortDirection } from '@/utils/useSortableTable';
|
||||||
import { canCreateProjects } from '@/utils/permissions';
|
import { canCreateProjects } from '@/utils/permissions';
|
||||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||||
import { useProjectsStore } from '@/utils/useProjects';
|
import { useProjectsStore } from '@/utils/useProjects';
|
||||||
import { useClientsStore } from '@/utils/useClients';
|
import { useClientsStore } from '@/utils/useClients';
|
||||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||||
|
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||||
@@ -56,6 +64,16 @@ const clientNameMap = computed(() => {
|
|||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { tasks } = useTasksQuery();
|
||||||
|
|
||||||
|
const taskCountMap = computed(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
tasks.value.forEach((task) => {
|
||||||
|
map.set(task.project_id, (map.get(task.project_id) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
// Define column accessors for sorting.
|
// Define column accessors for sorting.
|
||||||
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
// Numeric columns use sortDescFirst so that the first click (chevron down) sorts highest-first,
|
||||||
// while text columns default to ascending (A-Z) on first click (chevron down).
|
// while text columns default to ascending (A-Z) on first click (chevron down).
|
||||||
@@ -64,6 +82,11 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
|
|||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row: Project) => row.name.toLowerCase(),
|
accessorFn: (row: Project) => row.name.toLowerCase(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tasks',
|
||||||
|
sortDescFirst: true,
|
||||||
|
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'client_name',
|
id: 'client_name',
|
||||||
accessorFn: (row: Project) => {
|
accessorFn: (row: Project) => {
|
||||||
@@ -161,7 +184,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gridTemplate = computed(() => {
|
const gridTemplate = computed(() => {
|
||||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
return `grid-template-columns: minmax(300px, 1fr) minmax(100px, auto) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ function handleSort(column: SortColumn) {
|
|||||||
@sort="handleSort">
|
@sort="handleSort">
|
||||||
Name
|
Name
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
|
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
|
||||||
|
Tasks
|
||||||
|
</SortableTableHeaderCell>
|
||||||
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
|
||||||
Client
|
Client
|
||||||
</SortableTableHeaderCell>
|
</SortableTableHeaderCell>
|
||||||
|
|||||||
@@ -88,17 +88,19 @@ const showEditProjectModal = ref(false);
|
|||||||
<ContextMenuTrigger as-child>
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow :href="route('projects.show', { project: project.id })">
|
<TableRow :href="route('projects.show', { project: project.id })">
|
||||||
<div
|
<div
|
||||||
class="whitespace-nowrap min-w-0 flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
class="whitespace-nowrap min-w-0 flex items-center space-x-3 py-4 pr-3 text-sm font-medium text-text-primary pl-2 sm:pl-4 lg:pl-6">
|
||||||
<div
|
<div
|
||||||
:style="{
|
:style="{ backgroundColor: project.color }"
|
||||||
backgroundColor: project.color,
|
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
|
||||||
boxShadow: `var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) ${project.color}30`,
|
|
||||||
}"
|
|
||||||
class="w-3 h-3 ml-1 rounded-full"></div>
|
|
||||||
<span class="overflow-ellipsis overflow-hidden">
|
<span class="overflow-ellipsis overflow-hidden">
|
||||||
{{ project.name }}
|
{{ project.name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
|
</div>
|
||||||
|
<div class="whitespace-nowrap px-3 py-4 text-sm text-text-primary">
|
||||||
|
<span v-if="projectTasksCount">
|
||||||
|
{{ projectTasksCount }} {{ projectTasksCount === 1 ? 'Task' : 'Tasks' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-text-tertiary">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
<div class="whitespace-nowrap min-w-0 px-3 py-4 text-sm text-text-primary">
|
||||||
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
<div v-if="project.client_id" class="overflow-ellipsis overflow-hidden">
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore();
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DateRangeNavigator from '@/packages/ui/src/Input/DateRangeNavigator.vue';
|
import { Button } from '@/packages/ui/src/Buttons';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, CalendarIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
isCurrentWeek: boolean;
|
||||||
weekNumber: number;
|
weekNumber: number;
|
||||||
rangeLabel: string;
|
weekRangeDisplay: string;
|
||||||
weekTotalFormatted: string;
|
weekTotalFormatted: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -17,16 +19,33 @@ defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 px-2 sm:px-4 lg:px-6">
|
||||||
<!-- Left: Week navigation -->
|
<!-- Left: Week navigation -->
|
||||||
<DateRangeNavigator
|
<div class="flex items-center gap-2">
|
||||||
:label="rangeLabel"
|
<Button
|
||||||
:secondary-label="`W${weekNumber}`"
|
variant="outline"
|
||||||
previous-test-id="timesheet_prev_week"
|
size="icon"
|
||||||
trigger-test-id="timesheet_week_display"
|
class="h-8 w-8"
|
||||||
next-test-id="timesheet_next_week"
|
data-testid="timesheet_prev_week"
|
||||||
trigger-aria-label="Return to current week"
|
@click="$emit('previous')">
|
||||||
@previous="$emit('previous')"
|
<ChevronLeftIcon class="h-4 w-4" />
|
||||||
@next="$emit('next')"
|
</Button>
|
||||||
@select="$emit('current')" />
|
<button
|
||||||
|
data-testid="timesheet_week_display"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-text-primary hover:bg-card-background rounded-md transition"
|
||||||
|
@click="$emit('current')">
|
||||||
|
<CalendarIcon class="h-4 w-4 text-icon-default" />
|
||||||
|
<span v-if="isCurrentWeek">This week</span>
|
||||||
|
<span v-else>{{ weekRangeDisplay }}</span>
|
||||||
|
<span class="text-text-tertiary">· W{{ weekNumber }}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
data-testid="timesheet_next_week"
|
||||||
|
@click="$emit('next')">
|
||||||
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right: Week total -->
|
<!-- Right: Week total -->
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
|
|||||||
|
|
||||||
const startDate = useSessionStorage<string>(
|
const startDate = useSessionStorage<string>(
|
||||||
'reporting-start-date',
|
'reporting-start-date',
|
||||||
getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format()
|
getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format()
|
||||||
);
|
);
|
||||||
const endDate = useSessionStorage<string>(
|
const endDate = useSessionStorage<string>(
|
||||||
'reporting-end-date',
|
'reporting-end-date',
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
getLocalizedDayJs,
|
getLocalizedDayJs,
|
||||||
} from '@/packages/ui/src/utils/time';
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
import { getBreakPlacementHint } from '@/packages/ui/src/utils/breakPlacement';
|
||||||
import { getDateRangeLabel } from '@/packages/ui/src/utils/dateRange';
|
|
||||||
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
import { useTimesheetWeek } from '@/utils/timesheet/useTimesheetWeek';
|
||||||
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
import { useTimesheetCellMutations } from '@/utils/timesheet/useTimesheetCellMutations';
|
||||||
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
import { useTimesheetRowMutations } from '@/utils/timesheet/useTimesheetRowMutations';
|
||||||
@@ -45,6 +44,7 @@ const {
|
|||||||
weekEnd,
|
weekEnd,
|
||||||
weekDays,
|
weekDays,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
|
isCurrentWeek,
|
||||||
todayDate,
|
todayDate,
|
||||||
goToPreviousWeek,
|
goToPreviousWeek,
|
||||||
goToNextWeek,
|
goToNextWeek,
|
||||||
@@ -109,9 +109,13 @@ const weekTotalFormatted = computed(() =>
|
|||||||
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
formatHumanReadableDuration(grandTotal.value, intervalFormat.value, numberFormat.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
const weekRangeLabel = computed(() =>
|
const weekRangeDisplay = computed(() => {
|
||||||
getDateRangeLabel(weekStart.value, weekStart.value.add(6, 'day'))
|
const start = weekStart.value;
|
||||||
);
|
const end = start.add(6, 'day');
|
||||||
|
return start.month() === end.month()
|
||||||
|
? `${start.format('MMM D')} - ${end.format('D')}`
|
||||||
|
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Cell / row mutation handlers ──────────────────────────────────
|
// ── Cell / row mutation handlers ──────────────────────────────────
|
||||||
const {
|
const {
|
||||||
@@ -204,8 +208,9 @@ async function createTag(name: string): Promise<Tag | undefined> {
|
|||||||
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
<AppLayout title="Timesheet" data-testid="timesheet_view">
|
||||||
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
<div class="pt-5 lg:pt-8 pb-4 lg:pb-6">
|
||||||
<TimesheetHeader
|
<TimesheetHeader
|
||||||
|
:is-current-week="isCurrentWeek"
|
||||||
:week-number="weekNumber"
|
:week-number="weekNumber"
|
||||||
:range-label="weekRangeLabel"
|
:week-range-display="weekRangeDisplay"
|
||||||
:week-total-formatted="weekTotalFormatted"
|
:week-total-formatted="weekTotalFormatted"
|
||||||
@previous="goToPreviousWeek"
|
@previous="goToPreviousWeek"
|
||||||
@next="goToNextWeek"
|
@next="goToNextWeek"
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Button } from '..';
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||||
import { Tabs, TabsList } from '../tabs';
|
import { Tabs, TabsList } from '../tabs';
|
||||||
import TabBarItem from '../TabBar/TabBarItem.vue';
|
import TabBarItem from '../TabBar/TabBarItem.vue';
|
||||||
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
|
|
||||||
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
|
||||||
import type { CalendarSettings } from './calendarSettings';
|
import type { CalendarSettings } from './calendarSettings';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
rangeLabel: string;
|
viewTitle: string;
|
||||||
activeView: string;
|
activeView: string;
|
||||||
settings: CalendarSettings;
|
settings: CalendarSettings;
|
||||||
}>();
|
}>();
|
||||||
@@ -22,13 +23,31 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
|
||||||
<DateRangeNavigator
|
<!-- Left: Navigation -->
|
||||||
:label="rangeLabel"
|
<div class="flex items-center gap-1">
|
||||||
trigger-test-id="calendar-title"
|
<Button
|
||||||
trigger-aria-label="today"
|
variant="outline"
|
||||||
@previous="emit('prev')"
|
size="sm"
|
||||||
@next="emit('next')"
|
class="h-8 w-8 p-0"
|
||||||
@select="emit('today')" />
|
aria-label="Previous"
|
||||||
|
@click="emit('prev')">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
aria-label="Next"
|
||||||
|
@click="emit('next')">
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Title -->
|
||||||
|
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
|
||||||
|
viewTitle
|
||||||
|
}}</span>
|
||||||
|
|
||||||
<!-- Right: View switcher + Settings -->
|
<!-- Right: View switcher + Settings -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const {
|
|||||||
const {
|
const {
|
||||||
activeView,
|
activeView,
|
||||||
viewDays,
|
viewDays,
|
||||||
rangeLabel,
|
viewTitle,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
|
|||||||
|
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<CalendarToolbar
|
<CalendarToolbar
|
||||||
:range-label="rangeLabel"
|
:view-title="viewTitle"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:settings="calendarSettings"
|
:settings="calendarSettings"
|
||||||
@prev="handlePrev"
|
@prev="handlePrev"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { getLocalizedDayJs } from '../utils/time';
|
import { getLocalizedDayJs } from '../utils/time';
|
||||||
import { getWeekStartDayNumber } from '../utils/settings';
|
import { getWeekStartDayNumber } from '../utils/settings';
|
||||||
import { getDateRangeLabel } from '../utils/dateRange';
|
|
||||||
|
|
||||||
export function useCalendarNavigation(callbacks: {
|
export function useCalendarNavigation(callbacks: {
|
||||||
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
|
||||||
@@ -35,10 +34,24 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
return days;
|
return days;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rangeLabel = computed(() => {
|
const viewTitle = computed(() => {
|
||||||
|
if (activeView.value === 'timeGridDay') {
|
||||||
|
return currentDate.value.format('MMMM YYYY');
|
||||||
|
}
|
||||||
|
|
||||||
const days = viewDays.value;
|
const days = viewDays.value;
|
||||||
if (days.length === 0) return '';
|
if (days.length === 0) return '';
|
||||||
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
|
|
||||||
|
const first = days[0]!;
|
||||||
|
const last = days[days.length - 1]!;
|
||||||
|
|
||||||
|
if (first.year() !== last.year()) {
|
||||||
|
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
if (first.month() !== last.month()) {
|
||||||
|
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
|
||||||
|
}
|
||||||
|
return first.format('MMMM YYYY');
|
||||||
});
|
});
|
||||||
|
|
||||||
function emitDatesChange() {
|
function emitDatesChange() {
|
||||||
@@ -86,7 +99,7 @@ export function useCalendarNavigation(callbacks: {
|
|||||||
activeView,
|
activeView,
|
||||||
currentDate,
|
currentDate,
|
||||||
viewDays,
|
viewDays,
|
||||||
rangeLabel,
|
viewTitle,
|
||||||
emitDatesChange,
|
emitDatesChange,
|
||||||
handlePrev,
|
handlePrev,
|
||||||
handleNext,
|
handleNext,
|
||||||
|
|||||||
@@ -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">
|
|
||||||
· {{ 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>
|
|
||||||
@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
|
|||||||
import { CalendarIcon } from '@lucide/vue';
|
import { CalendarIcon } from '@lucide/vue';
|
||||||
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import DateRangeNavigator from './DateRangeNavigator.vue';
|
import {
|
||||||
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
|
getDayJsInstance,
|
||||||
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
|
getLocalizedDayJs,
|
||||||
|
firstDayIndex,
|
||||||
|
type WeekStartDay,
|
||||||
|
} from '@/packages/ui/src/utils/time';
|
||||||
import { type Organization } from '@/packages/api/src';
|
import { type Organization } from '@/packages/api/src';
|
||||||
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
|
||||||
|
import { formatDate } from '@/packages/ui/src/utils/time';
|
||||||
|
|
||||||
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = defineProps<{
|
||||||
defineProps<{
|
start: string;
|
||||||
start: string;
|
end: string;
|
||||||
end: string;
|
}>();
|
||||||
allowFuture?: boolean;
|
|
||||||
}>(),
|
|
||||||
{ allowFuture: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:start', value: string): void;
|
(e: 'update:start', value: string): void;
|
||||||
@@ -35,13 +35,10 @@ interface CalendarDateRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = computed(() => {
|
const today = computed(() => {
|
||||||
const now = getLocalizedDayJs();
|
const now = getDayJsInstance()();
|
||||||
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
return new CalendarDate(now.year(), now.month() + 1, now.date());
|
||||||
});
|
});
|
||||||
|
|
||||||
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
|
|
||||||
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
|
|
||||||
|
|
||||||
const modelValue = computed<CalendarDateRange>({
|
const modelValue = computed<CalendarDateRange>({
|
||||||
get: () => ({
|
get: () => ({
|
||||||
start: props.start
|
start: props.start
|
||||||
@@ -94,7 +91,7 @@ function setLastWeek() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast14Days() {
|
function setLast14Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
|
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
@@ -112,14 +109,14 @@ function setLastMonth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLast30Days() {
|
function setLast30Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
|
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getLocalizedDayJs().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLast90Days() {
|
function setLast90Days() {
|
||||||
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
|
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
|
||||||
emit('update:end', getLocalizedDayJs().format());
|
emit('update:end', getDayJsInstance()().format());
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,38 +140,6 @@ function setLastYear() {
|
|||||||
|
|
||||||
const organization = inject<ComputedRef<Organization>>('organization');
|
const organization = inject<ComputedRef<Organization>>('organization');
|
||||||
|
|
||||||
const displayLabel = computed(() => {
|
|
||||||
if (!startDay.value) return 'Pick a date';
|
|
||||||
if (!endDay.value) {
|
|
||||||
return getDateRangeLabel(startDay.value, startDay.value, {
|
|
||||||
dateFormat: organization?.value?.date_format,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return getDateRangeLabel(startDay.value, endDay.value, {
|
|
||||||
dateFormat: organization?.value?.date_format,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextRange = computed(() => {
|
|
||||||
if (!startDay.value || !endDay.value) return undefined;
|
|
||||||
return shiftDateRange(startDay.value, endDay.value, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextDisabled = computed(
|
|
||||||
() =>
|
|
||||||
!props.allowFuture &&
|
|
||||||
!!nextRange.value &&
|
|
||||||
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
|
|
||||||
);
|
|
||||||
|
|
||||||
function navigate(direction: DateRangeDirection) {
|
|
||||||
if (!startDay.value || !endDay.value) return;
|
|
||||||
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
|
|
||||||
emit('update:start', shifted.start.format());
|
|
||||||
emit('update:end', shifted.end.format());
|
|
||||||
emit('submit');
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (value === false) {
|
if (value === false) {
|
||||||
emit('submit');
|
emit('submit');
|
||||||
@@ -183,120 +148,77 @@ watch(open, (value) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DateRangeNavigator
|
<Popover v-model:open="open">
|
||||||
:label="displayLabel"
|
<PopoverTrigger as-child>
|
||||||
:next-disabled="nextDisabled"
|
<Button
|
||||||
previous-test-id="date_range_picker_previous"
|
variant="outline"
|
||||||
trigger-test-id="date_range_picker_display"
|
:class="
|
||||||
next-test-id="date_range_picker_next"
|
twMerge(
|
||||||
@previous="navigate(-1)"
|
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
|
||||||
@next="navigate(1)">
|
!modelValue && 'text-muted-foreground'
|
||||||
<template #trigger="{ triggerClass }">
|
)
|
||||||
<Popover v-model:open="open">
|
">
|
||||||
<PopoverTrigger as-child>
|
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
|
||||||
<Button
|
<template v-if="modelValue.start">
|
||||||
type="button"
|
<template v-if="modelValue.end">
|
||||||
variant="ghost"
|
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||||
size="sm"
|
-
|
||||||
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
|
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
|
||||||
data-testid="date_range_picker_display">
|
</template>
|
||||||
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
|
<template v-else>
|
||||||
<span class="min-w-0 truncate">{{ displayLabel }}</span>
|
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
|
||||||
</Button>
|
</template>
|
||||||
</PopoverTrigger>
|
</template>
|
||||||
<PopoverContent class="w-auto p-0">
|
<template v-else> Pick a date </template>
|
||||||
<div class="flex divide-x divide-border-secondary">
|
</Button>
|
||||||
<div
|
</PopoverTrigger>
|
||||||
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
<PopoverContent class="w-auto p-0">
|
||||||
<Button
|
<div class="flex divide-x divide-border-secondary">
|
||||||
variant="ghost"
|
<div
|
||||||
size="sm"
|
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
|
||||||
class="justify-start"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
|
||||||
@click="setToday"
|
>Today</Button
|
||||||
>Today</Button
|
>
|
||||||
>
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
|
||||||
<Button
|
>This Week</Button
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
|
||||||
class="justify-start"
|
>Last Week</Button
|
||||||
@click="setThisWeek"
|
>
|
||||||
>This Week</Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
|
||||||
>
|
>Last 14 days</Button
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
|
||||||
size="sm"
|
>This Month</Button
|
||||||
class="justify-start"
|
>
|
||||||
@click="setLastWeek"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
|
||||||
>Last Week</Button
|
>Last Month</Button
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
|
||||||
variant="ghost"
|
>Last 30 days</Button
|
||||||
size="sm"
|
>
|
||||||
class="justify-start"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
|
||||||
@click="setLast14Days"
|
>Last 90 days</Button
|
||||||
>Last 14 Days</Button
|
>
|
||||||
>
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
|
||||||
<Button
|
>Last 12 months</Button
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
|
||||||
class="justify-start"
|
>This year</Button
|
||||||
@click="setThisMonth"
|
>
|
||||||
>This Month</Button
|
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
|
||||||
>
|
>Last year</Button
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
</div>
|
||||||
size="sm"
|
<div class="pl-2">
|
||||||
class="justify-start"
|
<RangeCalendar
|
||||||
@click="setLastMonth"
|
v-model="modelValue"
|
||||||
>Last Month</Button
|
initial-focus
|
||||||
>
|
:number-of-months="2"
|
||||||
<Button
|
:max-value="today"
|
||||||
variant="ghost"
|
:week-starts-on="weekStartsOn" />
|
||||||
size="sm"
|
</div>
|
||||||
class="justify-start"
|
</div>
|
||||||
@click="setLast30Days"
|
</PopoverContent>
|
||||||
>Last 30 Days</Button
|
</Popover>
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLast90Days"
|
|
||||||
>Last 90 Days</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLast12Months"
|
|
||||||
>Last 12 Months</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setThisYear"
|
|
||||||
>This Year</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="justify-start"
|
|
||||||
@click="setLastYear"
|
|
||||||
>Last Year</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="pl-2">
|
|
||||||
<RangeCalendar
|
|
||||||
v-model="modelValue"
|
|
||||||
initial-focus
|
|
||||||
:number-of-months="2"
|
|
||||||
:max-value="allowFuture ? undefined : today"
|
|
||||||
:week-starts-on="weekStartsOn" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</template>
|
|
||||||
</DateRangeNavigator>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
|
|||||||
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
|
||||||
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
|
||||||
import DateRangePicker from './Input/DateRangePicker.vue';
|
import DateRangePicker from './Input/DateRangePicker.vue';
|
||||||
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
|
|
||||||
import { Label } from './label/index';
|
import { Label } from './label/index';
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -212,7 +211,6 @@ export {
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
DateRangeNavigator,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user