Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
8f3c73f266 Bump the major-updates group across 1 directory with 16 updates
Bumps the major-updates group with 16 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@floating-ui/vue](https://github.com/floating-ui/floating-ui/tree/HEAD/packages/vue) | `1.1.11` | `2.0.1` |
| [@tanstack/vue-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/vue-query-devtools) | `5.91.0` | `6.1.48` |
| [@tanstack/vue-table](https://github.com/TanStack/table/tree/HEAD/packages/vue-table) | `8.21.3` | `9.2.4` |
| [pinia](https://github.com/vuejs/pinia) | `3.0.4` | `4.0.3` |
| [tailwind-merge](https://github.com/dcastil/tailwind-merge/tree/HEAD/packages/tailwind-merge) | `2.6.1` | `3.7.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.5` | `10.0.1` |
| [@inertiajs/vue3](https://github.com/inertiajs/inertia/tree/HEAD/packages/vue3) | `2.3.27` | `3.7.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.20.1` | `26.5.1` |
| [laravel-vite-plugin](https://github.com/laravel/vite-plugin) | `2.1.0` | `3.2.0` |
| [postcss-import](https://github.com/postcss/postcss-import) | `15.1.0` | `17.0.0` |
| [postcss-nesting](https://github.com/csstools/postcss-plugins/tree/HEAD/plugins/postcss-nesting) | `12.1.5` | `14.0.1` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.19` | `4.3.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` | `7.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.6` | `8.3.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.10` | `5.0.0` |
| [vite-plugin-dts](https://github.com/qmhc/unplugin-dts/tree/HEAD/packages/vite-plugin-dts) | `4.5.4` | `5.1.0` |



Updates `@floating-ui/vue` from 1.1.11 to 2.0.1
- [Release notes](https://github.com/floating-ui/floating-ui/releases)
- [Changelog](https://github.com/floating-ui/floating-ui/blob/master/packages/vue/CHANGELOG.md)
- [Commits](https://github.com/floating-ui/floating-ui/commits/@floating-ui/vue@2.0.1/packages/vue)

Updates `@tanstack/vue-query-devtools` from 5.91.0 to 6.1.48
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/vue-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/vue-query-devtools@6.1.48/packages/vue-query-devtools)

Updates `@tanstack/vue-table` from 8.21.3 to 9.2.4
- [Release notes](https://github.com/TanStack/table/releases)
- [Changelog](https://github.com/TanStack/table/blob/main/packages/vue-table/CHANGELOG.md)
- [Commits](https://github.com/TanStack/table/commits/@tanstack/vue-table@9.2.4/packages/vue-table)

Updates `pinia` from 3.0.4 to 4.0.3
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/v3.0.4...v4.0.3)

Updates `tailwind-merge` from 2.6.1 to 3.7.0
- [Release notes](https://github.com/dcastil/tailwind-merge/releases)
- [Commits](https://github.com/dcastil/tailwind-merge/commits/tailwind-merge@3.7.0/packages/tailwind-merge)

Updates `@eslint/js` from 9.39.5 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@inertiajs/vue3` from 2.3.27 to 3.7.1
- [Release notes](https://github.com/inertiajs/inertia/releases)
- [Changelog](https://github.com/inertiajs/inertia/blob/3.x/CHANGELOG.md)
- [Commits](https://github.com/inertiajs/inertia/commits/v3.7.1/packages/vue3)

Updates `@types/node` from 22.20.1 to 26.5.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `laravel-vite-plugin` from 2.1.0 to 3.2.0
- [Release notes](https://github.com/laravel/vite-plugin/releases)
- [Changelog](https://github.com/laravel/vite-plugin/blob/3.x/CHANGELOG.md)
- [Upgrade guide](https://github.com/laravel/vite-plugin/blob/3.x/UPGRADE.md)
- [Commits](https://github.com/laravel/vite-plugin/compare/v2.1.0...v3.2.0)

Updates `postcss-import` from 15.1.0 to 17.0.0
- [Release notes](https://github.com/postcss/postcss-import/releases)
- [Changelog](https://github.com/postcss/postcss-import/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-import/compare/15.1.0...17.0.0)

Updates `postcss-nesting` from 12.1.5 to 14.0.1
- [Changelog](https://github.com/csstools/postcss-plugins/blob/main/plugins/postcss-nesting/CHANGELOG.md)
- [Commits](https://github.com/csstools/postcss-plugins/commits/HEAD/plugins/postcss-nesting)

Updates `tailwindcss` from 3.4.19 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `typescript` from 5.9.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

Updates `vite` from 7.3.6 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.3.0/packages/vite)

Updates `vitest` from 4.1.10 to 5.0.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.0/packages/vitest)

Updates `vite-plugin-dts` from 4.5.4 to 5.1.0
- [Release notes](https://github.com/qmhc/unplugin-dts/releases)
- [Changelog](https://github.com/qmhc/unplugin-dts/blob/main/packages/vite-plugin-dts/CHANGELOG.md)
- [Commits](https://github.com/qmhc/unplugin-dts/commits/vite-plugin-dts@5.1.0/packages/vite-plugin-dts)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@floating-ui/vue"
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@inertiajs/vue3"
  dependency-version: 3.7.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@tanstack/vue-query-devtools"
  dependency-version: 6.1.48
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@tanstack/vue-table"
  dependency-version: 9.2.4
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.4.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: laravel-vite-plugin
  dependency-version: 3.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: pinia
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-import
  dependency-version: 17.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: postcss-nesting
  dependency-version: 14.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwind-merge
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vite-plugin-dts
  dependency-version: 5.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: vitest
  dependency-version: 5.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-21 08:27:43 +00:00
8 changed files with 1964 additions and 2783 deletions

View File

@@ -53,47 +53,6 @@ function todayAt(hour: number, minute: number = 0): string {
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
* updates the billable status to match the new project's is_billable setting.
@@ -1003,9 +962,14 @@ test.describe('Drag-to-Move Events', () => {
page,
ctx,
}) => {
// Create entry: Thursday 00:30 → Thursday 01:30 (1 hour, near midnight)
const start = toApiTimestamp(thursdayAt(0, 30));
const end = toApiTimestamp(thursdayAt(1, 30));
const now = new Date();
const dayOfWeek = now.getDay();
// Need today to have a previous day visible in the week view (skip Sunday with Monday week start)
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, {
description: 'Drag up past midnight test',
start,
@@ -1014,10 +978,11 @@ test.describe('Drag-to-Move Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
const event = thursdayCol
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
const event = todayCol
.locator('.fc-event')
.filter({ hasText: 'Drag up past midnight test' });
await expect(event).toBeVisible({ timeout: 10000 });
@@ -1053,7 +1018,10 @@ test.describe('Drag-to-Move Events', () => {
expect(newDurationMs).toBe(3600000);
// The event should have moved to the previous day
expect(toDateStr(newStart)).toBe(toDateStr(wednesdayAt(0)));
const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
.toISOString()
.split('T')[0];
expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr);
});
});
@@ -1293,8 +1261,8 @@ test.describe('Resize Events', () => {
});
test('resize bottom edge across day boundary changes end date', async ({ page, ctx }) => {
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(wednesdayAt(11));
const start = todayAt(10);
const end = todayAt(11);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test',
start,
@@ -1303,8 +1271,26 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Resize into Thursday's column (for end resize, end must be > start)
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
// Find a column AFTER today (for end resize, end must be > start)
const headers = page.locator('.fc-col-header-cell');
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
.locator('.fc-event')
@@ -1332,7 +1318,7 @@ test.describe('Resize Events', () => {
// First drag down vertically to engage resize (like test 3.1)
await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 });
// 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();
})(),
]);
@@ -1354,8 +1340,8 @@ test.describe('Resize Events', () => {
page,
ctx,
}) => {
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(wednesdayAt(14));
const start = todayAt(10);
const end = todayAt(14);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'Cross resize end test',
start,
@@ -1364,8 +1350,30 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Resize into Thursday's column (later day needed for end-edge resize)
const targetX = await columnHeaderCenterX(page, thursdayAt(0));
// Find a non-today column header that is AFTER today (later day needed for end-edge resize)
const headers = page.locator('.fc-col-header-cell');
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
.locator('.fc-event')
@@ -1390,7 +1398,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100);
await page.mouse.down();
// 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();
})(),
]);
@@ -1456,9 +1464,22 @@ test.describe('Resize Events', () => {
});
test('multi-day event end resize on last day works correctly', async ({ page, ctx }) => {
// Create entry spanning Wednesday evening → Thursday morning
const start = toApiTimestamp(wednesdayAt(20));
const end = toApiTimestamp(thursdayAt(10));
// Create entry spanning today evening → tomorrow morning
const start = todayAt(20);
const tomorrow = new Date();
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, {
description: 'Multi-day end resize',
@@ -1467,14 +1488,14 @@ test.describe('Resize Events', () => {
});
await goToCalendar(page);
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
// Check if tomorrow column is visible
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
await scrollCalendarToTime(page, '09:00:00');
// Find the event segment on Thursday's column
const event = thursdayCol
// Find the event segment on tomorrow's column
const event = tomorrowCol
.locator('.fc-event')
.filter({ hasText: 'Multi-day end resize' })
.first();
@@ -1520,9 +1541,22 @@ test.describe('Resize Events', () => {
page,
ctx,
}) => {
// Create entry spanning Wednesday → Thursday
const start = toApiTimestamp(wednesdayAt(10));
const end = toApiTimestamp(thursdayAt(14));
// Create entry spanning today → tomorrow
const start = todayAt(10);
const tomorrow = new Date();
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, {
description: 'Backward end resize multi',
@@ -1531,16 +1565,29 @@ test.describe('Resize Events', () => {
});
await goToCalendar(page);
const thursdayCol = page.locator(
`.fc-timegrid-col[data-date="${toDateStr(thursdayAt(0))}"]`
);
// Check if tomorrow column is visible
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
test.skip((await tomorrowCol.count()) === 0, 'Tomorrow not visible in current view');
await scrollCalendarToTime(page, '12:00:00');
const wednesdayX = await columnHeaderCenterX(page, wednesdayAt(0));
// Find today's column header to get its X center
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 Thursday's column and resize end backward to Wednesday
const event = thursdayCol
// Find event segment on tomorrow's column and resize end backward to today
const event = tomorrowCol
.locator('.fc-event')
.filter({ hasText: 'Backward end resize multi' })
.first();
@@ -1562,9 +1609,9 @@ test.describe('Resize Events', () => {
await page.mouse.move(centerX, bottomY - 3);
await page.waitForTimeout(100);
await page.mouse.down();
// Drag down a bit first, then move to Wednesday's column at a Y after the start
// Drag down a bit first, then move to today's column at a Y after the start
await page.mouse.move(centerX, bottomY + slotHeight, { steps: 5 });
await page.mouse.move(wednesdayX, bottomY + slotHeight, { steps: 10 });
await page.mouse.move(todayX!, bottomY + slotHeight, { steps: 10 });
await page.mouse.up();
})(),
]);
@@ -1580,8 +1627,8 @@ test.describe('Resize Events', () => {
});
test('resize end to earlier column prevents end before start', async ({ page, ctx }) => {
const start = toApiTimestamp(thursdayAt(10));
const end = toApiTimestamp(thursdayAt(14));
const start = todayAt(10);
const end = todayAt(14);
await createTimeEntryWithTimestampsViaApi(ctx, {
description: 'End before start test',
start,
@@ -1590,8 +1637,25 @@ test.describe('Resize Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '09:00:00');
// Target Wednesday's column, the day before the entry
const targetX = await columnHeaderCenterX(page, wednesdayAt(0));
// Find a column BEFORE today
const headers = page.locator('.fc-col-header-cell');
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
.locator('.fc-event')
@@ -1619,7 +1683,7 @@ test.describe('Resize Events', () => {
await page.waitForTimeout(100);
await page.mouse.down();
// 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();
// Wait for any potential API call
@@ -1668,21 +1732,30 @@ test.describe('Click-Drag Selection to Create', () => {
test('drag-to-create spanning two days opens create modal with correct cross-day times', async ({
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 expect(page.locator('.fc')).toBeVisible();
// Use mid-day times so both start and end slots are visible in the viewport
await scrollCalendarToTime(page, '10:00:00');
// Find Wednesday's and Thursday's columns
const wednesdayStr = toDateStr(wednesdayAt(0));
const thursdayStr = toDateStr(thursdayAt(0));
// Find today's and tomorrow's columns
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.toISOString()
.split('T')[0];
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const wednesdayCol = page.locator(`.fc-timegrid-col[data-date="${wednesdayStr}"]`);
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${thursdayStr}"]`);
await expect(wednesdayCol).toBeVisible();
await expect(thursdayCol).toBeVisible();
const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayStr}"]`);
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
await expect(todayCol).toBeVisible();
await expect(tomorrowCol).toBeVisible();
// Find the 11:00 slot (start) and 13:00 slot (end on Thursday)
// Find the 11:00 slot (start) and 13:00 slot (end on tomorrow)
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();
await expect(startSlot).toBeVisible();
@@ -1690,18 +1763,18 @@ test.describe('Click-Drag Selection to Create', () => {
const startSlotBox = await startSlot.boundingBox();
const endSlotBox = await endSlot.boundingBox();
const wednesdayColBox = await wednesdayCol.boundingBox();
const thursdayColBox = await thursdayCol.boundingBox();
const todayColBox = await todayCol.boundingBox();
const tomorrowColBox = await tomorrowCol.boundingBox();
// Start drag at 11:00 on Wednesday's column
const startX = wednesdayColBox!.x + wednesdayColBox!.width / 2;
// Start drag at 11:00 on today's column
const startX = todayColBox!.x + todayColBox!.width / 2;
const startY = startSlotBox!.y + 2;
// End drag at 13:00 on Thursday's column
const endX = thursdayColBox!.x + thursdayColBox!.width / 2;
// End drag at 13:00 on tomorrow's column
const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2;
const endY = endSlotBox!.y + 2;
// Drag from Wednesday to Thursday, moving down first, then across
// Drag from today to tomorrow — move down first, then across
const slotHeight = await getSlotHeight(page);
await page.mouse.move(startX, startY);
await page.mouse.down();
@@ -1719,9 +1792,9 @@ test.describe('Click-Drag Selection to Create', () => {
await expect(dialog.getByText('Start')).toBeVisible();
await expect(dialog.getByText('End')).toBeVisible();
// Start date should be Wednesday, end date should be Thursday
await expect(dialog.getByText(wednesdayStr)).toBeVisible();
await expect(dialog.getByText(thursdayStr)).toBeVisible();
// Start date should be today, end date should be tomorrow
await expect(dialog.getByText(todayStr)).toBeVisible();
await expect(dialog.getByText(tomorrowStr)).toBeVisible();
});
});
@@ -1803,9 +1876,17 @@ test.describe('Timezone & Localization', () => {
test.describe('Multi-Day Events', () => {
test('event spanning 2 days renders and is visible', async ({ page, ctx }) => {
// Create entry that spans from Wednesday 22:00 to Thursday 02:00
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
// Create entry that spans from today 22:00 to tomorrow 02:00
const now = new Date();
const dayOfWeek = now.getDay();
// 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, {
description: 'Multi day entry',
@@ -1820,8 +1901,14 @@ test.describe('Multi-Day Events', () => {
});
test('multi-day event can be edited via click', async ({ page, ctx }) => {
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
const now = new Date();
test.skip(now.getDay() === 6, 'Skip on Saturday');
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, {
description: 'Multi day edit test',
@@ -1840,8 +1927,14 @@ test.describe('Multi-Day Events', () => {
});
test('multi-day event context menu works', async ({ page, ctx }) => {
const start = toApiTimestamp(wednesdayAt(22));
const end = toApiTimestamp(thursdayAt(2));
const now = new Date();
test.skip(now.getDay() === 6, 'Skip on Saturday');
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, {
description: 'Multi day ctx test',
@@ -1861,11 +1954,16 @@ test.describe('Multi-Day Events', () => {
page,
ctx,
}) => {
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const startDate = wednesdayAt(22);
const endDate = thursdayAt(2);
const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
// 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, {
description: 'Multi day drag test',
@@ -1875,9 +1973,12 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on Thursday's column (00:00-02:00)
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
const event = thursdayCol.locator('.fc-event').filter({ hasText: 'Multi day drag test' });
// Find the clipped segment on tomorrow's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.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 });
const eventBox = await event.boundingBox();
@@ -1912,19 +2013,27 @@ test.describe('Multi-Day Events', () => {
// Duration must be preserved (4 hours)
expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000);
// The start should still be on Wednesday (not jumped to Thursday)
expect(toDateStr(newStart)).toBe(toDateStr(startDate));
// The start should still be on today (not jumped to tomorrow)
const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate())
.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 ({
page,
ctx,
}) => {
// Create entry: Wednesday 22:00 → Thursday 02:00 (4 hours, spanning 2 days)
const startDate = wednesdayAt(22);
const endDate = thursdayAt(2);
const start = toApiTimestamp(startDate);
const end = toApiTimestamp(endDate);
const now = new Date();
const dayOfWeek = now.getDay();
// Need today and tomorrow both visible (skip Saturday)
test.skip(dayOfWeek === 6, 'Skipping on Saturday — multi-day would span to next week');
// 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, {
description: 'Multi day drag up test',
@@ -1934,9 +2043,12 @@ test.describe('Multi-Day Events', () => {
await goToCalendar(page);
await scrollCalendarToTime(page, '00:00:00');
// Find the clipped segment on Thursday's column (00:00-02:00)
const thursdayCol = page.locator(`.fc-timegrid-col[data-date="${toDateStr(endDate)}"]`);
const event = thursdayCol
// Find the clipped segment on tomorrow's column (00:00-02:00)
const tomorrowStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
.toISOString()
.split('T')[0];
const tomorrowCol = page.locator(`.fc-timegrid-col[data-date="${tomorrowStr}"]`);
const event = tomorrowCol
.locator('.fc-event')
.filter({ hasText: 'Multi day drag up test' });
await expect(event).toBeVisible({ timeout: 10000 });

4303
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,13 +26,13 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.39.4",
"@inertiajs/vue3": "^2.3.23",
"@eslint/js": "^10.0.1",
"@inertiajs/vue3": "^3.7.1",
"@playwright/test": "^1.60.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/chroma-js": "^3.1.2",
"@types/node": "^22.19.19",
"@types/node": "^26.5.1",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
@@ -40,30 +40,30 @@
"axios": "^1.16.0",
"eslint-plugin-unused-imports": "^4.4.1",
"happy-dom": "^20.8.9",
"laravel-vite-plugin": "^2.1.0",
"laravel-vite-plugin": "^3.2.0",
"openapi-zod-client": "^1.18.3",
"postcss": "^8.5.14",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.1.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"postcss-import": "^17.0.0",
"postcss-nesting": "^14.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.3.0",
"vite-plugin-checker": "^0.12.0",
"vitest": "^4.1.4",
"vitest": "^5.0.0",
"vue": "^3.5.34",
"vue-tsc": "^3.2.8"
},
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/vue": "^1.1.11",
"@floating-ui/vue": "^2.0.1",
"@heroicons/vue": "^2.2.0",
"@lucide/vue": "^1.28.0",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/vue-form": "^1.32.0",
"@tanstack/vue-query": "^5.100.10",
"@tanstack/vue-query-devtools": "^5.91.0",
"@tanstack/vue-table": "^8.21.3",
"@tanstack/vue-query-devtools": "^6.1.48",
"@tanstack/vue-table": "^9.2.4",
"@tanstack/vue-virtual": "^3.13.24",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.7.0",
@@ -77,10 +77,10 @@
"echarts": "^6.0.0",
"focus-trap": "^8.2.0",
"parse-duration": "^2.1.6",
"pinia": "^3.0.4",
"pinia": "^4.0.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.10.1",
"tailwind-merge": "^2.6.1",
"tailwind-merge": "^3.7.0",
"tailwindcss-animate": "^1.0.7",
"vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1",

View File

@@ -10,21 +10,13 @@ import Pagination from '@/packages/ui/src/Pagination.vue';
import LoadingSpinner from '@/packages/ui/src/LoadingSpinner.vue';
export type SortColumn =
| 'name'
| 'tasks'
| 'client_name'
| 'spent_time'
| 'progress'
| 'billable_rate'
| 'status'
| 'visibility';
'name' | 'client_name' | 'spent_time' | 'progress' | 'billable_rate' | 'status' | 'visibility';
export type { SortDirection } from '@/utils/useSortableTable';
import { canCreateProjects } from '@/utils/permissions';
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
import { useProjectsStore } from '@/utils/useProjects';
import { useClientsStore } from '@/utils/useClients';
import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTasksQuery } from '@/utils/useTasksQuery';
import { getOrganizationCurrencyString } from '@/utils/money';
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
@@ -64,16 +56,6 @@ const clientNameMap = computed(() => {
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.
// 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).
@@ -82,11 +64,6 @@ const columns = computed<SortableColumnDef<Project, SortColumn>[]>(() => [
id: 'name',
accessorFn: (row: Project) => row.name.toLowerCase(),
},
{
id: 'tasks',
sortDescFirst: true,
accessorFn: (row: Project) => taskCountMap.value.get(row.id) ?? 0,
},
{
id: 'client_name',
accessorFn: (row: Project) => {
@@ -184,7 +161,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
}
const gridTemplate = computed(() => {
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;`;
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;`;
});
</script>

View File

@@ -36,9 +36,6 @@ function handleSort(column: SortColumn) {
@sort="handleSort">
Name
</SortableTableHeaderCell>
<SortableTableHeaderCell column="tasks" v-bind="sortState" @sort="handleSort">
Tasks
</SortableTableHeaderCell>
<SortableTableHeaderCell column="client_name" v-bind="sortState" @sort="handleSort">
Client
</SortableTableHeaderCell>

View File

@@ -88,19 +88,17 @@ const showEditProjectModal = ref(false);
<ContextMenuTrigger as-child>
<TableRow :href="route('projects.show', { project: project.id })">
<div
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">
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">
<div
:style="{ backgroundColor: project.color }"
class="w-2.5 h-2.5 ml-1 rounded-full"></div>
:style="{
backgroundColor: project.color,
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">
{{ project.name }}
</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>
<span class="text-text-secondary"> {{ projectTasksCount }} Tasks </span>
</div>
<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">

View File

@@ -29,7 +29,7 @@
"author": "solidtime",
"license": "AGPL-3.0",
"devDependencies": {
"vite-plugin-dts": "^4.5.4"
"vite-plugin-dts": "^5.1.0"
},
"peerDependencies": {
"@zodios/core": "^10.9.6",

View File

@@ -50,7 +50,7 @@
"devDependencies": {
"@types/chroma-js": "^3.1.2",
"@zodios/core": "^10.9.6",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-dts": "^5.1.0",
"zod": "^3.25.76"
},
"peerDependencies": {