From 189682cfaf26e8b1b4be553899b06a2d11f01406 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Wed, 11 Mar 2026 13:35:53 +0100 Subject: [PATCH] Replace FullCalendar with custom calendar UI --- e2e/calendar-settings.spec.ts | 421 ++- e2e/calendar.spec.ts | 2353 +++++++++++++++++ package-lock.json | 134 +- package.json | 9 +- resources/js/Pages/Calendar.vue | 29 +- .../ui/src/FullCalendar/CalendarDayColumn.vue | 283 ++ .../FullCalendar/FullCalendarDayHeader.vue | 12 +- .../FullCalendar/FullCalendarEventContent.vue | 2 +- .../ui/src/FullCalendar/TimeEntryCalendar.vue | 1247 +++------ .../ui/src/FullCalendar/activityTypes.ts | 13 + .../ui/src/FullCalendar/calendarTypes.ts | 40 + .../ui/src/FullCalendar/idleStatusPlugin.ts | 393 --- .../ui/src/FullCalendar/useActivityBoxes.ts | 103 + .../ui/src/FullCalendar/useCalendarEvents.ts | 300 +++ .../ui/src/FullCalendar/useCalendarGrid.ts | 137 + .../src/FullCalendar/useCalendarNavigation.ts | 118 + .../ui/src/FullCalendar/useContextMenu.ts | 172 ++ .../ui/src/FullCalendar/useEventDrag.ts | 295 +++ .../ui/src/FullCalendar/useEventResize.ts | 363 +++ .../ui/src/FullCalendar/useSlotSelection.ts | 205 ++ .../ui/src/FullCalendar/useVisualSnap.ts | 210 -- 21 files changed, 5325 insertions(+), 1514 deletions(-) create mode 100644 resources/js/packages/ui/src/FullCalendar/CalendarDayColumn.vue create mode 100644 resources/js/packages/ui/src/FullCalendar/activityTypes.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/calendarTypes.ts delete mode 100644 resources/js/packages/ui/src/FullCalendar/idleStatusPlugin.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useActivityBoxes.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useCalendarEvents.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useCalendarGrid.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useCalendarNavigation.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useContextMenu.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useEventDrag.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useEventResize.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useSlotSelection.ts delete mode 100644 resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts diff --git a/e2e/calendar-settings.spec.ts b/e2e/calendar-settings.spec.ts index d7cfd3b4..0f0895eb 100644 --- a/e2e/calendar-settings.spec.ts +++ b/e2e/calendar-settings.spec.ts @@ -2,10 +2,11 @@ import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; +import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api'; async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); - await expect(page.locator('.fc')).toBeVisible(); + await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 }); } async function openSettingsPopover(page: Page) { @@ -21,6 +22,25 @@ function getCalendarTitle(page: Page) { return page.getByTestId('calendar-title'); } +async function scrollCalendarToTime(page: Page, time: string) { + await page.evaluate((t) => { + const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`); + if (slot) slot.scrollIntoView({ block: 'start' }); + }, time); + await page.waitForTimeout(300); +} + +async function getSlotHeight(page: Page): Promise { + return await page.evaluate(() => { + const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane')); + for (let i = 0; i < slots.length; i++) { + const h = slots[i].getBoundingClientRect().height; + if (h > 0) return h; + } + return 20; + }); +} + test.describe('Calendar Settings', () => { test.beforeEach(async ({ page }) => { await clearCalendarSettings(page); @@ -253,3 +273,402 @@ test.describe('Calendar Toolbar', () => { await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1); }); }); + +test.describe('Visual Snapping', () => { + test.beforeEach(async ({ page }) => { + await clearCalendarSettings(page); + }); + + test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + // Set snap interval to 1 min + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '1 min' }).click(); + await page.keyboard.press('Escape'); + + // Create a 1h time entry + await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h'); + await goToCalendar(page); + + // Scroll the calendar so the 14:00 target area is visible + await scrollCalendarToTime(page, '13:00:00'); + + const event = page.locator('.fc-event').first(); + await expect(event).toBeVisible(); + + // Get target slot at a non-15-min boundary time + const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); + const targetBox = await targetSlot.boundingBox(); + expect(targetBox).not.toBeNull(); + + // Drag event to a position offset from the 15-min boundary + const putResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' + ); + + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); + await page.mouse.up(); + + const putResponse = await putResponsePromise; + expect(putResponse.status()).toBe(200); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + const minutes = startDate.getMinutes(); + + // With 1-min snap, any minute value is valid (0-59) + expect(minutes).toBeGreaterThanOrEqual(0); + expect(minutes).toBeLessThanOrEqual(59); + }); + + test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + // Set snap interval to 60 min + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '1 hour' }).click(); + await page.keyboard.press('Escape'); + + // Create a 1h time entry + await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h'); + await goToCalendar(page); + + // Scroll the calendar so the 14:00 target area is visible + await scrollCalendarToTime(page, '13:00:00'); + + const event = page.locator('.fc-event').first(); + await expect(event).toBeVisible(); + + // Get target slot + const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); + const targetBox = await targetSlot.boundingBox(); + expect(targetBox).not.toBeNull(); + + // Drag event + const putResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' + ); + + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); + await page.mouse.up(); + + const putResponse = await putResponsePromise; + expect(putResponse.status()).toBe(200); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + const minutes = startDate.getMinutes(); + + // With 60-min snap, minutes should be 0 (on the hour) + expect(minutes).toBe(0); + }); + + test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => { + // Create a 1h time entry + await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h'); + await goToCalendar(page); + + // Set snap to 15 min + await openSettingsPopover(page); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '15 min' }).click(); + await page.keyboard.press('Escape'); + + // Scroll the calendar so the 14:00 target area is visible + await scrollCalendarToTime(page, '13:00:00'); + + const event = page.locator('.fc-event').first(); + await expect(event).toBeVisible(); + + // Drag event to 14:00 area + const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); + const targetBox14 = await targetSlot14.boundingBox(); + expect(targetBox14).not.toBeNull(); + + const putResponsePromise1 = page.waitForResponse( + (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' + ); + + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, { + steps: 10, + }); + await page.mouse.up(); + + const putResponse1 = await putResponsePromise1; + expect(putResponse1.status()).toBe(200); + + const body1 = await putResponse1.json(); + const startDate1 = new Date(body1.data.start); + expect(startDate1.getMinutes() % 15).toBe(0); + + // Wait for query re-fetch/re-renders to fully settle after drag + await page.waitForTimeout(1500); + + // Change snap to 30 min + // Use Escape first to ensure no stale popover is open, then re-open + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await openSettingsPopover(page); + await page.waitForTimeout(300); + await page.getByLabel('Snap Interval').click({ force: true }); + await page.getByRole('option', { name: '30 min' }).click(); + await page.keyboard.press('Escape'); + + // Scroll the calendar so the 10:00 target area is visible + await scrollCalendarToTime(page, '09:00:00'); + + // Drag event to 10:00 area + const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first(); + const targetBox10 = await targetSlot10.boundingBox(); + expect(targetBox10).not.toBeNull(); + + const putResponsePromise2 = page.waitForResponse( + (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' + ); + + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, { + steps: 10, + }); + await page.mouse.up(); + + const putResponse2 = await putResponsePromise2; + expect(putResponse2.status()).toBe(200); + + const body2 = await putResponse2.json(); + const startDate2 = new Date(body2.data.start); + expect(startDate2.getMinutes() % 30).toBe(0); + }); + + test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + // Set grid scale to 30 min, snap to 5 min + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '5 min', exact: true }).click(); + await page.keyboard.press('Escape'); + + // Wait for re-render with 30-min grid + await expect(async () => { + const slotCount = await page.locator('.fc-timegrid-slot-lane').count(); + // 24 hours * 2 slots/hour = 48 slots for 30-min grid + expect(slotCount).toBeLessThanOrEqual(48); + }).toPass({ timeout: 5000 }); + + // Verify grid is 30-min (fewer slots than default 15-min) + const slotCount = await page.locator('.fc-timegrid-slot-lane').count(); + // Default 15-min grid has 96 slots; 30-min grid should have 48 + expect(slotCount).toBeLessThanOrEqual(48); + + // Create a 1h time entry and go to calendar + await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h'); + await goToCalendar(page); + + // Re-apply settings since goToCalendar navigates + await openSettingsPopover(page); + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '5 min', exact: true }).click(); + await page.keyboard.press('Escape'); + + const event = page.locator('.fc-event').first(); + await expect(event).toBeVisible(); + + // Drag event + const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); + const targetBox = await targetSlot.boundingBox(); + expect(targetBox).not.toBeNull(); + + const putResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' + ); + + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); + await page.mouse.up(); + + const putResponse = await putResponsePromise; + expect(putResponse.status()).toBe(200); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + // Snap is 5 min, so minutes should be divisible by 5 + expect(startDate.getMinutes() % 5).toBe(0); + }); +}); + +test.describe('Calendar Settings Effects', () => { + test.beforeEach(async ({ page }) => { + await clearCalendarSettings(page); + }); + + test('start/end time hides slots outside visible range', async ({ page, ctx }) => { + // Create a time entry at 6 AM today + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0); + const end = new Date(start.getTime() + 3600 * 1000); // 7 AM + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Early morning entry', + start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'), + end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'), + }); + + await goToCalendar(page); + + // Verify 6 AM slot is visible with default settings + await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0); + + // Set start time to 8 AM + await openSettingsPopover(page); + await page.getByLabel('Start Time').click(); + await page.getByRole('option', { name: '8:00 AM' }).click(); + await page.keyboard.press('Escape'); + + // 6 AM slot should be hidden + await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0); + + // 8 AM slot should be visible + await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0); + }); + + test('grid scale affects event visual height proportionally', async ({ page, ctx }) => { + // Create a 1h time entry + await createBareTimeEntryViaApi(ctx, 'Height test', '1h'); + await goToCalendar(page); + + const event = page.locator('.fc-event').first(); + await expect(event).toBeVisible(); + await event.scrollIntoViewIfNeeded(); + + // Get event height with default 15-min grid scale + const box15 = await event.boundingBox(); + expect(box15).not.toBeNull(); + const height15 = box15!.height; + + // Change grid scale to 60 min + await openSettingsPopover(page); + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '1 hour' }).click(); + await page.keyboard.press('Escape'); + + // Wait for re-render and scroll event into view + await event.scrollIntoViewIfNeeded(); + await expect(async () => { + const box = await event.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).not.toBe(height15); + }).toPass({ timeout: 5000 }); + + const box60 = await event.boundingBox(); + expect(box60).not.toBeNull(); + const height60 = box60!.height; + + // Event should appear smaller with larger grid scale + expect(height15).toBeGreaterThan(height60); + }); + + test('snap interval affects drag granularity', async ({ page, ctx }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + // Set snap to 30 min + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.keyboard.press('Escape'); + + // Create a 1h time entry + await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h'); + await goToCalendar(page); + + // Scroll the calendar so the 14:00 target area is visible + await scrollCalendarToTime(page, '13:00:00'); + + const event = page.locator('.fc-event').first(); + await expect(event).toBeVisible(); + + // Get target slot + const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); + const targetBox = await targetSlot.boundingBox(); + expect(targetBox).not.toBeNull(); + + // Drag event + const putResponsePromise = page.waitForResponse( + (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' + ); + + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); + await page.mouse.up(); + + const putResponse = await putResponsePromise; + expect(putResponse.status()).toBe(200); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + const minutes = startDate.getMinutes(); + + // With 30-min snap, minutes should be 0 or 30 + expect(minutes % 30).toBe(0); + }); + + test('settings apply immediately without page reload', async ({ page }) => { + await goToCalendar(page); + + // Count slots with default grid scale (15 min) + const defaultSlotCount = await page.locator('.fc-timegrid-slot').count(); + + // Change grid scale to 30 min + await openSettingsPopover(page); + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.keyboard.press('Escape'); + + // Verify slot count changed without navigation + await expect(async () => { + const count = await page.locator('.fc-timegrid-slot').count(); + expect(count).toBeLessThan(defaultSlotCount); + }).toPass({ timeout: 5000 }); + + // Wait for FullCalendar to fully stabilize after re-render + await page.waitForTimeout(2000); + await expect(page.locator('.fc')).toBeVisible(); + + // Change start time to 8 AM + // FullCalendar re-render from grid scale change can make popover elements unstable. + // Retry the open+click sequence if it fails. + await expect(async () => { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await page.getByRole('button', { name: 'Calendar settings' }).click(); + await expect(page.getByText('Calendar Settings')).toBeVisible(); + const startTimeBtn = page.getByLabel('Start Time'); + await expect(startTimeBtn).toBeVisible(); + await startTimeBtn.click({ timeout: 3000 }); + }).toPass({ timeout: 10000 }); + + await page.getByRole('option', { name: '8:00 AM' }).click(); + await page.keyboard.press('Escape'); + + // Verify 7 AM slot is hidden without reload + await expect(async () => { + const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count(); + expect(count).toBe(0); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 1494f025..bbd1db9c 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -8,10 +8,38 @@ import { createBareTimeEntryViaApi, createTimeEntryViaApi, createRunningTimeEntryViaApi, + createTimeEntryWithTimestampsViaApi, + createRunningTimeEntryWithStartViaApi, + createClientViaApi, + createTaskViaApi, + createProjectWithClientViaApi, + updateUserProfileViaWeb, + updateOrganizationSettingViaApi, } from './utils/api'; +import type { TestContext } from '../playwright/fixtures'; async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); + await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 }); +} + +async function scrollCalendarToTime(page: Page, time: string) { + await page.evaluate((t) => { + const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`); + if (slot) slot.scrollIntoView({ block: 'start' }); + }, time); + await page.waitForTimeout(300); +} + +async function getSlotHeight(page: Page): Promise { + return await page.evaluate(() => { + const slots = document.querySelectorAll('.fc-timegrid-slot-lane'); + for (const slot of slots) { + const h = slot.getBoundingClientRect().height; + if (h > 0) return h; + } + return 20; + }); } async function openContextMenu(page: Page, description: string) { @@ -21,6 +49,12 @@ async function openContextMenu(page: Page, description: string) { await expect(page.getByRole('menu')).toBeVisible(); } +function todayAt(hour: number, minute: number = 0): string { + const now = new Date(); + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0); + return d.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + /** * 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. @@ -510,3 +544,2322 @@ test.describe('Employee Calendar Isolation', () => { ).not.toBeVisible(); }); }); + +// ============================================= +// Section 1: Event Rendering & Display +// ============================================= + +test.describe('Event Rendering & Display', () => { + test('1.1 event shows description, project name, and duration', async ({ page, ctx }) => { + const projectName = 'Render Project ' + Math.floor(Math.random() * 10000); + const project = await createProjectViaApi(ctx, { name: projectName }); + await createTimeEntryViaApi(ctx, { + description: 'Render test entry', + duration: '1h', + projectId: project.id, + }); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'Render test entry' }).first(); + await expect(event).toBeVisible(); + await expect(event).toContainText(projectName); + await expect(event).toContainText('1h 00min'); + }); + + test('1.2 event shows task and client name', async ({ page, ctx }) => { + const clientName = 'Render Client ' + Math.floor(Math.random() * 10000); + const projectName = 'Render Task Project ' + Math.floor(Math.random() * 10000); + const taskName = 'Render Task ' + Math.floor(Math.random() * 10000); + const { project } = await createProjectWithClientViaApi(ctx, projectName, clientName); + const task = await createTaskViaApi(ctx, { name: taskName, project_id: project.id }); + await createTimeEntryViaApi(ctx, { + description: 'Task display entry', + duration: '1h', + projectId: project.id, + taskId: task.id, + }); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'Task display entry' }).first(); + await expect(event).toBeVisible(); + await expect(event).toContainText(taskName); + await expect(event).toContainText(clientName); + }); + + test('1.3 event color uses project color blended with background', async ({ page, ctx }) => { + const project = await createProjectViaApi(ctx, { + name: 'Color Project ' + Math.floor(Math.random() * 10000), + color: '#ef5350', + }); + await createTimeEntryViaApi(ctx, { + description: 'Color test', + duration: '1h', + projectId: project.id, + }); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'Color test' }).first(); + await expect(event).toBeVisible(); + // The event should have a background color that is NOT the raw project color + // but a blended version. Just verify it has a background-color style set. + const bgColor = await event.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(bgColor).not.toBe(''); + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); + // The raw #ef5350 = rgb(239, 83, 80). The blended color should differ. + expect(bgColor).not.toBe('rgb(239, 83, 80)'); + }); + + test('1.4 event without project uses default gray color', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'No project entry', '1h'); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'No project entry' }).first(); + await expect(event).toBeVisible(); + const bgColor = await event.evaluate((el) => getComputedStyle(el).backgroundColor); + expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); + }); + + test('1.5 overlapping events render side by side', async ({ page, ctx }) => { + // Create 2 overlapping entries using explicit timestamps + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Overlap A', start, end }); + await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Overlap B', start, end }); + await goToCalendar(page); + const eventA = page.locator('.fc-event').filter({ hasText: 'Overlap A' }).first(); + const eventB = page.locator('.fc-event').filter({ hasText: 'Overlap B' }).first(); + await expect(eventA).toBeVisible(); + await expect(eventB).toBeVisible(); + // They should not fully overlap — check they have different x positions or widths + const boxA = await eventA.boundingBox(); + const boxB = await eventB.boundingBox(); + expect(boxA).not.toBeNull(); + expect(boxB).not.toBeNull(); + // FullCalendar places overlapping events side by side, so widths should be less than full column + // or x positions should differ + const xDiff = Math.abs(boxA!.x - boxB!.x); + const combinedWidth = boxA!.width + boxB!.width; + // Either they're at different positions or they're both narrower + expect(xDiff > 5 || combinedWidth < boxA!.width * 3).toBeTruthy(); + }); + + test('1.6 very short event still renders visibly', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(10, 5); // 5 minutes + await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Short event', start, end }); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'Short event' }).first(); + await expect(event).toBeVisible(); + const box = await event.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThan(0); + }); + + test('1.7 running entry has distinct visual style', async ({ page, ctx }) => { + await createRunningTimeEntryViaApi(ctx, 'Running style test'); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'Running style test' }).first(); + await expect(event).toBeVisible(); + // Running entries should have the running-entry class + await expect(event).toHaveClass(/running-entry/); + }); + + test('1.8 entry with no description shows fallback text', async ({ page, ctx }) => { + await createTimeEntryViaApi(ctx, { description: '', duration: '1h' }); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'No description' }).first(); + await expect(event).toBeVisible(); + }); +}); + +// ============================================= +// Section 2: Drag-to-Move Events +// ============================================= + +test.describe('Drag-to-Move Events', () => { + test('2.1 drag event to different time slot on same day', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Drag time test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + const event = page.locator('.fc-event').filter({ hasText: 'Drag time test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + + // Drag the event down by 2 hours (8 x 15-min slots) + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await event.hover(); + await page.mouse.down(); + await page.mouse.move( + eventBox!.x + eventBox!.width / 2, + eventBox!.y + slotHeight * 8, + { + steps: 15, + } + ); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + // Start should have changed from 10:00 + expect(body.data.start).not.toContain('T10:00:00'); + // Duration should be preserved (1 hour) + const startDate = new Date(body.data.start); + const endDate = new Date(body.data.end); + const durationMs = endDate.getTime() - startDate.getTime(); + expect(durationMs).toBe(3600000); // 1 hour + }); + + test('2.2 drag event to different day', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Drag day test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // Get all column headers to find a different day (headers are always visible) + const headers = page.locator('.fc-col-header-cell'); + const headerCount = await headers.count(); + let targetX: number | undefined; + // Find a column that's not today + for (let i = 0; i < headerCount; i++) { + const header = headers.nth(i); + const hasToday = await header.evaluate((el) => el.classList.contains('fc-day-today')); + if (!hasToday) { + const box = await header.boundingBox(); + targetX = box!.x + box!.width / 2; + break; + } + } + + const event = page.locator('.fc-event').filter({ hasText: 'Drag day test' }).first(); + await expect(event).toBeVisible(); + const eventBox = await event.boundingBox(); + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetX!, eventBox!.y + eventBox!.height / 2, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + // The date should have changed + const originalDate = new Date(start).toISOString().split('T')[0]; + const newDate = new Date(body.data.start).toISOString().split('T')[0]; + expect(newDate).not.toBe(originalDate); + }); + + test('2.4 drag preserves original event duration', async ({ page, ctx }) => { + const start = todayAt(9); + const end = todayAt(11); // 2 hours + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Duration preserve test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '08:00:00'); + const event = page + .locator('.fc-event') + .filter({ hasText: 'Duration preserve test' }) + .first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + + // Drag the event down by 2 hours (8 x 15-min slots) + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await event.hover(); + await page.mouse.down(); + await page.mouse.move( + eventBox!.x + eventBox!.width / 2, + eventBox!.y + slotHeight * 8, + { + steps: 15, + } + ); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + const endDate = new Date(body.data.end); + const durationMs = endDate.getTime() - startDate.getTime(); + expect(durationMs).toBe(7200000); // 2 hours preserved + }); + + test('2.5 running entry cannot be dragged', async ({ page, ctx }) => { + await createRunningTimeEntryViaApi(ctx, 'No drag running'); + await goToCalendar(page); + // Scroll to make the running entry visible (it started ~10min ago) + const nowHour = new Date().getHours(); + const scrollTime = `${String(Math.max(0, nowHour - 1)).padStart(2, '0')}:00:00`; + await scrollCalendarToTime(page, scrollTime); + const event = page.locator('.fc-event').filter({ hasText: 'No drag running' }).first(); + await expect(event).toBeVisible(); + + const eventBox = await event.boundingBox(); + const originalY = eventBox!.y; + + // Try to drag + await event.hover(); + await page.mouse.down(); + await page.mouse.move(eventBox!.x, eventBox!.y + 100, { steps: 10 }); + await page.mouse.up(); + + // Wait a bit for any potential update + await page.waitForTimeout(500); + + // Event should still be at original position (approximately) + const newBox = await event.boundingBox(); + expect(Math.abs(newBox!.y - originalY)).toBeLessThan(26); + }); + + test('2.6 cross-day drag preserves time of day and duration', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Cross day preserve test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // Find a non-today column header to get the target X coordinate + const headers = page.locator('.fc-col-header-cell'); + const headerCount = await headers.count(); + let targetX: 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(); + targetX = box!.x + box!.width / 2; + break; + } + } + + const event = page + .locator('.fc-event') + .filter({ hasText: 'Cross day preserve test' }) + .first(); + await expect(event).toBeVisible(); + const eventBox = await event.boundingBox(); + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await event.hover(); + await page.mouse.down(); + // Move to different day, same Y (preserves time of day) + await page.mouse.move(targetX!, eventBox!.y + eventBox!.height / 2, { + steps: 15, + }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + const origStart = new Date(start); + + // Date should have changed + expect(newStart.toISOString().split('T')[0]).not.toBe( + origStart.toISOString().split('T')[0] + ); + // Duration should be preserved (1 hour = 3600000ms) + expect(newEnd.getTime() - newStart.getTime()).toBe(3600000); + }); + + test('2.7 cross-day drag shows faded ghost in original column', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(12); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Ghost preview test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // Find a non-today column header + const headers = page.locator('.fc-col-header-cell'); + const headerCount = await headers.count(); + let targetX: 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(); + targetX = box!.x + box!.width / 2; + break; + } + } + + const event = page.locator('.fc-event').filter({ hasText: 'Ghost preview test' }).first(); + await expect(event).toBeVisible(); + const eventBox = await event.boundingBox(); + + // Start dragging to another day but don't release + await event.hover(); + await page.mouse.down(); + await page.mouse.move(targetX!, eventBox!.y + eventBox!.height / 2, { + steps: 15, + }); + + // While dragging across days, the original event should be faded (opacity ~0.3) + const opacity = await event.evaluate((el) => + parseFloat(window.getComputedStyle(el).opacity) + ); + expect(opacity).toBeLessThanOrEqual(0.4); + + // A cross-day preview should appear in the target column + const preview = page.locator('.fc-cross-day-preview'); + await expect(preview).toBeVisible(); + + await page.mouse.up(); + }); + + test('2.8 dragging single-day event upward past midnight spills to previous day', async ({ + page, + ctx, + }) => { + 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, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '00:00:00'); + + 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 }); + + const eventBox = await event.boundingBox(); + const slotHeight = await getSlotHeight(page); + const startX = eventBox!.x + eventBox!.width / 2; + const startY = eventBox!.y + eventBox!.height / 2; + + // Drag up by ~3 slots (45 minutes) — should push the event past midnight to the previous day + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(startX, startY); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(startX, startY - slotHeight * 3, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + const newDurationMs = newEnd.getTime() - newStart.getTime(); + + // Duration must be preserved (1 hour) + expect(newDurationMs).toBe(3600000); + + // The event should have moved to the previous day + const yesterdayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1) + .toISOString() + .split('T')[0]; + expect(newStart.toISOString().split('T')[0]).toBe(yesterdayStr); + }); +}); + +// ============================================= +// Section 3: Resize Events +// ============================================= + +test.describe('Resize Events', () => { + test('3.1 resize event from bottom edge extends duration', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Resize bottom test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + const event = page.locator('.fc-event').filter({ hasText: 'Resize bottom test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + // Bottom edge of the event + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + // Hover to show resize handle, then drag from bottom + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + // Drag down by ~1 hour worth of pixels (4 slots of 15 min) + await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + const endDate = new Date(body.data.end); + const durationMs = endDate.getTime() - startDate.getTime(); + // Should be longer than 1 hour now + expect(durationMs).toBeGreaterThan(3600000); + }); + + test('3.2 resize event from top edge changes start time', async ({ page, ctx }) => { + const start = todayAt(12); + const end = todayAt(14); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Resize top test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '10:00:00'); + const event = page.locator('.fc-event').filter({ hasText: 'Resize top test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const topY = eventBox!.y; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(centerX, topY + 3); + await page.waitForTimeout(100); + await page.mouse.down(); + const oneHourPx = slotHeight * 4; + await page.mouse.move(centerX, topY - oneHourPx, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + // End should be preserved + const endDate = new Date(body.data.end); + expect(endDate.getUTCHours()).toBe(new Date(end).getUTCHours()); + // Start should have moved earlier + const startDate = new Date(body.data.start); + expect(startDate.getTime()).toBeLessThan(new Date(start).getTime()); + }); + + test('3.4 resize preserves the non-resized edge', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(12); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Preserve edge test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + const event = page.locator('.fc-event').filter({ hasText: 'Preserve edge test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(centerX, bottomY + slotHeight * 4, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + // Start should be unchanged + const startDate = new Date(body.data.start); + const origStart = new Date(start); + expect(Math.abs(startDate.getTime() - origStart.getTime())).toBeLessThan(60000); + }); + + test('3.5 running entry cannot be resized from bottom', async ({ page, ctx }) => { + await createRunningTimeEntryViaApi(ctx, 'No bottom resize'); + await goToCalendar(page); + const event = page.locator('.fc-event').filter({ hasText: 'No bottom resize' }).first(); + await expect(event).toBeVisible(); + + // The bottom resize handle should be hidden via CSS + const endResizer = event.locator('.fc-event-resizer-end'); + // It might exist in DOM but be display:none + if ((await endResizer.count()) > 0) { + await expect(endResizer).toBeHidden(); + } + }); + + test('3.6 running entry start can be changed via top-edge resize', async ({ page, ctx }) => { + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 2); + const startStr = startTime.toISOString().replace(/\.\d{3}Z$/, 'Z'); + await createRunningTimeEntryWithStartViaApi(ctx, 'Resize running start', startStr); + await goToCalendar(page); + // Scroll to make the running entry's top edge visible + const scrollHour = Math.max(0, startTime.getHours() - 1); + await scrollCalendarToTime(page, `${String(scrollHour).padStart(2, '0')}:00:00`); + const event = page.locator('.fc-event').filter({ hasText: 'Resize running start' }).first(); + await expect(event).toBeVisible(); + + const eventBox = await event.boundingBox(); + const topY = eventBox!.y; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(centerX, topY + 3); + await page.waitForTimeout(100); + await page.mouse.down(); + const slotLane = page.locator('.fc-timegrid-slot-lane').first(); + const slotHeight = (await slotLane.boundingBox())!.height; + // Move down (make it start later) + await page.mouse.move(centerX, topY + slotHeight * 4, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + // End should remain null for running entries + expect(body.data.end).toBeNull(); + // Start should have moved later + expect(new Date(body.data.start).getTime()).toBeGreaterThan(startTime.getTime()); + }); + + test('3.8 running entry resize preserves end:null in API response', async ({ page, ctx }) => { + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const startStr = startTime.toISOString().replace(/\.\d{3}Z$/, 'Z'); + await createRunningTimeEntryWithStartViaApi(ctx, 'End null preserve', startStr); + await goToCalendar(page); + // Scroll to make the running entry's top edge visible + const scrollHour = Math.max(0, startTime.getHours() - 1); + await scrollCalendarToTime(page, `${String(scrollHour).padStart(2, '0')}:00:00`); + const event = page.locator('.fc-event').filter({ hasText: 'End null preserve' }).first(); + await expect(event).toBeVisible(); + + const eventBox = await event.boundingBox(); + const topY = eventBox!.y; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Intercept the PUT request to check what's sent + const [putRequest] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('/time-entries/') && r.method() === 'PUT'), + (async () => { + await page.mouse.move(centerX, topY + 3); + await page.waitForTimeout(100); + await page.mouse.down(); + const slotLane = page.locator('.fc-timegrid-slot-lane').first(); + const slotHeight = (await slotLane.boundingBox())!.height; + await page.mouse.move(centerX, topY + slotHeight * 2, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const requestBody = putRequest.postDataJSON(); + expect(requestBody.end).toBeNull(); + }); + + test('3.9 resize bottom edge across day boundary changes end date', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Cross resize end test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // 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') + .filter({ hasText: 'Cross resize end test' }) + .first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + // Same approach as working test 3.1: hover bottom, drag down + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + // 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.up(); + })(), + ]); + + const body = await putResponse.json(); + const origStart = new Date(start); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + + // Start should be preserved + expect(Math.abs(newStart.getTime() - origStart.getTime())).toBeLessThan(60000); + // End date should have changed to a different day + expect(newEnd.toISOString().split('T')[0]).not.toBe( + new Date(end).toISOString().split('T')[0] + ); + }); + + test('3.10 resize bottom edge across day boundary changes end date', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(14); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Cross resize end test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // 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') + .filter({ hasText: 'Cross resize end test' }) + .first(); + await expect(event).toBeVisible(); + + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + // Hover bottom edge to show resize handle + await page.mouse.move(centerX, bottomY - 3); + 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.up(); + })(), + ]); + + const body = await putResponse.json(); + const origStart = new Date(start); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + + // Start should be preserved + expect(Math.abs(newStart.getTime() - origStart.getTime())).toBeLessThan(60000); + // End date should have changed to a different day + expect(newEnd.toISOString().split('T')[0]).not.toBe( + new Date(end).toISOString().split('T')[0] + ); + }); + + test('3.11 cross-day resize shows preview in target column', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(14); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Resize preview test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // Find any non-today column header (preview only, direction doesn't matter) + const headers = page.locator('.fc-col-header-cell'); + const headerCount = await headers.count(); + let targetX: 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(); + targetX = box!.x + box!.width / 2; + break; + } + } + + const event = page.locator('.fc-event').filter({ hasText: 'Resize preview test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Start resize from bottom edge (drag down first, then sideways) + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(centerX, bottomY + slotHeight * 2, { steps: 5 }); + await page.mouse.move(targetX!, bottomY + slotHeight * 2, { steps: 15 }); + + // Cross-day preview should be visible + const preview = page.locator('.fc-cross-day-preview'); + await expect(preview).toBeVisible(); + + await page.mouse.up(); + }); + + test('3.12 multi-day event end resize on last day works correctly', async ({ page, ctx }) => { + // 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', + start, + end, + }); + await goToCalendar(page); + + // 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 tomorrow's column + const event = tomorrowCol + .locator('.fc-event') + .filter({ hasText: 'Multi-day end resize' }) + .first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Resize bottom edge down by 2 slots + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(centerX, bottomY + slotHeight * 2, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + const origStart = new Date(start); + const origEnd = new Date(end); + + // Start must be preserved + expect(Math.abs(newStart.getTime() - origStart.getTime())).toBeLessThan(60000); + // End should be later than original + expect(newEnd.getTime()).toBeGreaterThan(origEnd.getTime()); + // End must be after start + expect(newEnd.getTime()).toBeGreaterThan(newStart.getTime()); + }); + + test('3.13 multi-day event end resize backward to start day produces valid entry', async ({ + page, + ctx, + }) => { + // 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', + start, + end, + }); + await goToCalendar(page); + + // 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'); + + // 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 tomorrow's column and resize end backward to today + const event = tomorrowCol + .locator('.fc-event') + .filter({ hasText: 'Backward end resize multi' }) + .first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + // 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(todayX!, bottomY + slotHeight, { steps: 10 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + + // End must be after start (the core invariant) + expect(newEnd.getTime()).toBeGreaterThan(newStart.getTime()); + // Start should be preserved + expect(Math.abs(newStart.getTime() - new Date(start).getTime())).toBeLessThan(60000); + }); + + test('3.14 resize end to earlier column prevents end before start', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(14); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'End before start test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // 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') + .filter({ hasText: 'End before start test' }) + .first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Collect PUT request bodies to verify none has end < start + const putBodies: any[] = []; + page.on('request', (req) => { + if (req.url().includes('/time-entries/') && req.method() === 'PUT') { + try { + putBodies.push(req.postDataJSON()); + } catch {} + } + }); + + // Resize end toward earlier column at a Y that would place end before start + await page.mouse.move(centerX, bottomY - 3); + 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.up(); + + // Wait for any potential API call + await page.waitForTimeout(1000); + + // If any PUT was made, end must be after start + for (const body of putBodies) { + if (body.end !== null) { + const putStart = new Date(body.start); + const putEnd = new Date(body.end); + expect(putEnd.getTime()).toBeGreaterThan(putStart.getTime()); + } + } + }); +}); + +// ============================================= +// Section 4: Click-Drag Selection to Create +// ============================================= + +test.describe('Click-Drag Selection to Create', () => { + test('4.2 completing selection opens create modal with correct times', async ({ page }) => { + await goToCalendar(page); + await expect(page.locator('.fc')).toBeVisible(); + await scrollCalendarToTime(page, '09:00:00'); + + // Find the 10:00 slot + const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first(); + await expect(startSlot).toBeVisible(); + const startBox = await startSlot.boundingBox(); + + // Find the 11:00 slot (to select 1 hour) + const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); + const endBox = await endSlot.boundingBox(); + + // Click-drag from 10:00 to 11:00 + await page.mouse.move(startBox!.x + startBox!.width / 2, startBox!.y + 2); + await page.mouse.down(); + await page.mouse.move(endBox!.x + endBox!.width / 2, endBox!.y + 2, { steps: 10 }); + await page.mouse.up(); + + // Create modal should appear + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + }); + + test('4.3 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(); + await scrollCalendarToTime(page, '22:00:00'); + + // 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 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 23:00 slot on today's column + const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="23:00:00"]').first(); + await expect(startSlot).toBeVisible(); + const startSlotBox = await startSlot.boundingBox(); + const todayColBox = await todayCol.boundingBox(); + const tomorrowColBox = await tomorrowCol.boundingBox(); + + // Start drag at 23:00 on today's column + const startX = todayColBox!.x + todayColBox!.width / 2; + const startY = startSlotBox!.y + 2; + + // End drag at ~01:00 on tomorrow's column + const slotHeight = await getSlotHeight(page); + const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2; + // 01:00 = 4 slots from the top of the grid + const slot0100 = page.locator('.fc-timegrid-slot-lane[data-time="01:00:00"]').first(); + const slot0100Box = await slot0100.boundingBox(); + const endY = slot0100Box!.y + 2; + + // Drag from 23:00 today to 01:00 tomorrow + await page.mouse.move(startX, startY); + await page.mouse.down(); + // Move down to bottom of today's column first, then across to tomorrow + await page.mouse.move(startX, startY + slotHeight * 2, { steps: 5 }); + await page.mouse.move(endX, endY, { steps: 15 }); + await page.mouse.up(); + + // Create modal should appear + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Verify the dialog spans across two days + await expect(dialog.getByText('Start')).toBeVisible(); + await expect(dialog.getByText('End')).toBeVisible(); + + // Start date should be today, end date should be tomorrow + const todayFormatted = `${todayStr}`; + const tomorrowFormatted = `${tomorrowStr}`; + await expect(dialog.getByText(todayFormatted)).toBeVisible(); + await expect(dialog.getByText(tomorrowFormatted)).toBeVisible(); + }); +}); + +// ============================================= +// Section 6: Timezone & Localization +// ============================================= + +test.describe('Timezone & Localization', () => { + test('week start day: monday shows Mon as first column', async ({ page }) => { + // Navigate to calendar first to load Inertia page props + await goToCalendar(page); + await updateUserProfileViaWeb(page, { week_start: 'monday' }); + await page.reload(); + await expect(page.locator('.fc')).toBeVisible(); + + const firstHeader = page.locator('.fc-col-header-cell').first(); + await expect(firstHeader).toContainText('Mon'); + }); + + test('week start day: sunday shows Sun as first column', async ({ page }) => { + await goToCalendar(page); + await updateUserProfileViaWeb(page, { week_start: 'sunday' }); + await page.reload(); + await expect(page.locator('.fc')).toBeVisible(); + + const firstHeader = page.locator('.fc-col-header-cell').first(); + await expect(firstHeader).toContainText('Sun'); + + // Reset to monday for other tests + await updateUserProfileViaWeb(page, { week_start: 'monday' }); + }); + + test('12-hour time format shows AM/PM on slot labels', async ({ page, ctx }) => { + await updateOrganizationSettingViaApi(ctx, { time_format: '12-hours' }); + await page.reload(); + await goToCalendar(page); + + // Look for AM/PM in slot labels + const slotLabels = page.locator('.fc-timegrid-slot-label-cushion'); + const allText = await slotLabels.allTextContents(); + const hasAmPm = allText.some((t) => t.includes('AM') || t.includes('PM')); + expect(hasAmPm).toBeTruthy(); + + // Reset + await updateOrganizationSettingViaApi(ctx, { time_format: '24-hours' }); + }); + + test('24-hour time format does not show AM/PM on slot labels', async ({ page, ctx }) => { + await updateOrganizationSettingViaApi(ctx, { time_format: '24-hours' }); + await page.reload(); + await goToCalendar(page); + + const slotLabels = page.locator('.fc-timegrid-slot-label-cushion'); + const allText = await slotLabels.allTextContents(); + // Should NOT contain AM/PM + const hasAmPm = allText.some((t) => t.includes('AM') || t.includes('PM')); + expect(hasAmPm).toBeFalsy(); + // Should contain 24h format like "08:00" or "14:00" + const has24h = allText.some((t) => /^\d{2}:\d{2}$/.test(t.trim())); + expect(has24h).toBeTruthy(); + }); + + test('interval format reflected in event duration display', async ({ page, ctx }) => { + await updateOrganizationSettingViaApi(ctx, { + interval_format: 'hours-minutes-colon-separated', + }); + await createBareTimeEntryViaApi(ctx, 'Duration format test', '1h 30min'); + await page.reload(); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: 'Duration format test' }).first(); + await expect(event).toBeVisible(); + // Should show "1:30" instead of "1h 30min" + const durationEl = event.locator('[data-duration]'); + await expect(durationEl).toContainText('1:30'); + + // Reset + await updateOrganizationSettingViaApi(ctx, { interval_format: 'hours-minutes' }); + }); +}); + +// ============================================= +// Section 7: Multi-Day Events +// ============================================= + +test.describe('Multi-Day Events', () => { + test('event spanning 2 days renders and is visible', async ({ page, ctx }) => { + // 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', + start, + end, + }); + await goToCalendar(page); + + // Wait for the event to appear with retrying + const event = page.locator('.fc-event').filter({ hasText: 'Multi day entry' }).first(); + await expect(event).toBeVisible({ timeout: 10000 }); + }); + + test('multi-day event can be edited via click', async ({ page, ctx }) => { + 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', + start, + end, + }); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: 'Multi day edit test' }).first(); + await expect(event).toBeVisible(); + await event.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect( + page.getByRole('dialog').getByPlaceholder('What did you work on?') + ).toHaveValue('Multi day edit test'); + }); + + test('multi-day event context menu works', async ({ page, ctx }) => { + 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', + start, + end, + }); + await goToCalendar(page); + + await openContextMenu(page, 'Multi day ctx test'); + await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Split' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); + }); + + test('dragging clipped segment of multi-day event preserves cross-day span', async ({ + page, + ctx, + }) => { + 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', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '00:00:00'); + + // 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(); + const slotHeight = await getSlotHeight(page); + + // Drag the clipped segment down by ~1 slot (15 minutes) + const startX = eventBox!.x + eventBox!.width / 2; + const startY = eventBox!.y + eventBox!.height / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(startX, startY); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(startX, startY + slotHeight, { steps: 10 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + const newDurationMs = newEnd.getTime() - newStart.getTime(); + const origDurationMs = endDate.getTime() - startDate.getTime(); + + // Duration must be preserved (4 hours) + expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000); + + // 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, + }) => { + 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', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '00:00:00'); + + // 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 }); + + const eventBox = await event.boundingBox(); + const slotHeight = await getSlotHeight(page); + + // Drag the clipped segment UP by ~1 slot (15 minutes) + const startX = eventBox!.x + eventBox!.width / 2; + const startY = eventBox!.y + eventBox!.height / 2; + + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(startX, startY); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(startX, startY - slotHeight, { steps: 10 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const newStart = new Date(body.data.start); + const newEnd = new Date(body.data.end); + const newDurationMs = newEnd.getTime() - newStart.getTime(); + const origDurationMs = endDate.getTime() - startDate.getTime(); + + // Duration must be preserved (4 hours) + expect(Math.abs(newDurationMs - origDurationMs)).toBeLessThan(60000); + + // The event should have shifted earlier by ~15 minutes + // Original start was 22:00, new start should be ~21:45 + const shiftMs = startDate.getTime() - newStart.getTime(); + expect(shiftMs).toBeGreaterThan(10 * 60 * 1000); // shifted at least 10 minutes earlier + expect(shiftMs).toBeLessThan(20 * 60 * 1000); // but not more than 20 minutes + }); +}); + +// ============================================= +// Section 9: Now Indicator +// ============================================= + +test.describe('Now Indicator', () => { + test('now indicator is visible on current day', async ({ page }) => { + await goToCalendar(page); + // FullCalendar v6 uses fc-timegrid-now-indicator-line for the now indicator + await expect(async () => { + const count = await page.locator('.fc-timegrid-now-indicator-line').count(); + expect(count).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + }); + + test('now indicator is not visible on past weeks', async ({ page }) => { + await goToCalendar(page); + // Navigate to two weeks ago + await page.getByRole('button', { name: 'Previous' }).click(); + await page.getByRole('button', { name: 'Previous' }).click(); + await expect(page.locator('.fc')).toBeVisible(); + // Now indicator line should not be present in past weeks + await expect(page.locator('.fc-timegrid-now-indicator-line')).toHaveCount(0); + }); +}); + +// ============================================= +// Section 10: Day Header & Totals +// ============================================= + +test.describe('Day Header & Totals', () => { + test('day header shows correct day name for today', async ({ page }) => { + await goToCalendar(page); + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const todayName = days[new Date().getDay()]; + // The today column should contain the day name + const todayHeader = page.locator('.fc-day-today.fc-col-header-cell'); + await expect(todayHeader).toContainText(todayName); + }); + + test('day header shows daily total duration', async ({ page, ctx }) => { + // Create 2 entries: 1h + 30min = 1h 30min total + const start1 = todayAt(9); + const end1 = todayAt(10); + const start2 = todayAt(11); + const end2 = todayAt(11, 30); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Total A', + start: start1, + end: end1, + }); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Total B', + start: start2, + end: end2, + }); + await goToCalendar(page); + + // Wait for entries to appear + await expect( + page.locator('.fc-event').filter({ hasText: 'Total A' }).first() + ).toBeVisible(); + await expect( + page.locator('.fc-event').filter({ hasText: 'Total B' }).first() + ).toBeVisible(); + + // The today header should show the total (default format: "1h 30min") + const todayHeader = page.locator('.fc-day-today.fc-col-header-cell'); + await expect(todayHeader).toContainText('1h 30min'); + }); + + test('daily total updates after entry deletion', async ({ page, ctx }) => { + const start = todayAt(9); + const end = todayAt(10); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Delete total test', + start, + end, + }); + await goToCalendar(page); + + const todayHeader = page.locator('.fc-day-today.fc-col-header-cell'); + // Should show 1h initially + await expect(todayHeader).toContainText('1h 00min'); + + // Delete the entry via context menu + await openContextMenu(page, 'Delete total test'); + await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'DELETE' && + r.status() === 204 + ), + page.getByRole('menuitem', { name: 'Delete' }).click(), + ]); + + // Total should update to 0 + await expect(todayHeader).toContainText('0h 00min'); + }); +}); + +// ============================================= +// Section 11: Activity Plugin Overlays +// ============================================= + +test.describe('Activity Plugin Overlays', () => { + test('activity periods render as colored bars on calendar', async ({ page }) => { + await goToCalendar(page); + // Wait for FullCalendar to fully render its time grid + await page.waitForTimeout(1000); + + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + // Inject activity data via the exposed setter function + await page.evaluate((dateStr: string) => { + const setter = (window as any).__TEST_SET_ACTIVITY_PERIODS__; + if (setter) { + setter([ + { + start: `${dateStr}T09:00:00Z`, + end: `${dateStr}T09:30:00Z`, + isIdle: false, + windowActivities: [{ appName: 'VSCode', url: null, count: 20 }], + }, + { + start: `${dateStr}T09:30:00Z`, + end: `${dateStr}T10:00:00Z`, + isIdle: true, + }, + ]); + } + }, todayStr); + + // Activity boxes should appear + const activityBoxes = page.locator('.activity-status-box'); + await expect(activityBoxes.first()).toBeVisible({ timeout: 10000 }); + const count = await activityBoxes.count(); + expect(count).toBeGreaterThanOrEqual(2); + }); + + test('idle and active periods have different styles', async ({ page }) => { + await goToCalendar(page); + await page.waitForTimeout(1000); + + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + await page.evaluate((dateStr: string) => { + const setter = (window as any).__TEST_SET_ACTIVITY_PERIODS__; + if (setter) { + setter([ + { + start: `${dateStr}T09:00:00Z`, + end: `${dateStr}T09:30:00Z`, + isIdle: false, + }, + { + start: `${dateStr}T09:30:00Z`, + end: `${dateStr}T10:00:00Z`, + isIdle: true, + }, + ]); + } + }, todayStr); + + const activeBox = page.locator('.activity-status-box.active'); + const idleBox = page.locator('.activity-status-box.idle'); + await expect(activeBox.first()).toBeVisible({ timeout: 10000 }); + await expect(idleBox.first()).toBeVisible({ timeout: 10000 }); + }); +}); + +// ============================================= +// Section 12: Running Entry Behavior +// ============================================= + +test.describe('Running Entry Behavior', () => { + test('running entry extends to approximately current time', async ({ page, ctx }) => { + const startTime = new Date(); + startTime.setHours(startTime.getHours() - 1); + const startStr = startTime.toISOString().replace(/\.\d{3}Z$/, 'Z'); + await createRunningTimeEntryWithStartViaApi(ctx, 'Running extends test', startStr); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: 'Running extends test' }).first(); + await expect(event).toBeVisible(); + + // Event should have significant height (extends to now, ~1 hour) + const box = await event.boundingBox(); + expect(box).not.toBeNull(); + // A 1-hour event with default slot settings should be at least 30px tall + expect(box!.height).toBeGreaterThan(30); + }); + + test('running entry has distinct running-entry class', async ({ page, ctx }) => { + await createRunningTimeEntryViaApi(ctx, 'Single running test'); + await goToCalendar(page); + + // Should have exactly one running-entry element + const runningEvents = page.locator('.fc-event.running-entry'); + await expect(runningEvents).toHaveCount(1); + }); + + test('stopping running entry removes running-entry styling', async ({ page, ctx }) => { + await createRunningTimeEntryViaApi(ctx, 'Stop style test'); + await goToCalendar(page); + + // Verify it has running-entry class + const event = page.locator('.fc-event').filter({ hasText: 'Stop style test' }).first(); + await expect(event).toHaveClass(/running-entry/); + + // Stop it via context menu + await openContextMenu(page, 'Stop style test'); + await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + page.getByRole('menuitem', { name: 'Stop' }).click(), + ]); + + // After stopping, event should no longer have running-entry class + // Wait for the calendar to re-render after the mutation + await page.waitForTimeout(1000); + const stoppedEvent = page + .locator('.fc-event') + .filter({ hasText: 'Stop style test' }) + .first(); + await expect(stoppedEvent).toBeVisible(); + await expect(stoppedEvent).not.toHaveClass(/running-entry/); + }); +}); + +// ============================================= +// Section 13: Data Loading & Navigation +// ============================================= + +test.describe('Data Loading & Navigation', () => { + test('calendar scroller renders with correct grid height', async ({ page }) => { + await goToCalendar(page); + await page.waitForSelector('.fc-timegrid-slot-lane', { timeout: 5000 }); + + const info = await page.evaluate(() => { + const scroller = document.querySelector('.fc-scroller'); + if (!scroller) return { exists: false, scrollHeight: 0, slotCount: 0 }; + const slots = scroller.querySelectorAll('.fc-timegrid-slot-lane'); + return { + exists: true, + scrollHeight: scroller.scrollHeight, + slotCount: slots.length, + }; + }); + + expect(info.exists).toBe(true); + // Default settings: 24h with 15-min slots = 96 slots + expect(info.slotCount).toBe(96); + // Grid height should be 96 * 25px = 2400px + expect(info.scrollHeight).toBe(2400); + }); +}); + +// ============================================= +// Section 14: Keyboard & Accessibility +// ============================================= + +test.describe('Keyboard & Accessibility', () => { + test('edit modal can be closed with Escape', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Escape close test', '1h'); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: 'Escape close test' }).first(); + await event.click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); + + test('pressing Enter on a focused event opens the edit modal', async ({ page, ctx }) => { + const description = 'Enter key test ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: description }).first(); + await expect(event).toBeVisible(); + + // Focus the event and press Enter + await event.focus(); + await page.keyboard.press('Enter'); + + // Edit modal should open + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await expect( + page.getByRole('dialog').getByPlaceholder('What did you work on?') + ).toHaveValue(description); + }); + + test('events have role="button" and are focusable', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Focusable test', '1h'); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: 'Focusable test' }).first(); + await expect(event).toBeVisible(); + await expect(event).toHaveAttribute('role', 'button'); + await expect(event).toHaveAttribute('tabindex', '0'); + }); +}); + +// ============================================= +// Section 15: Click-to-Edit (Drag Threshold) +// ============================================= + +test.describe('Click-to-Edit (Drag Threshold)', () => { + test('clicking an event without dragging opens the edit modal', async ({ page, ctx }) => { + const description = 'Click edit test ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: description }).first(); + await expect(event).toBeVisible(); + + // Simple click (no drag movement) + await event.click(); + + // Edit modal should open with the correct description + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + await expect( + page.getByRole('dialog').getByPlaceholder('What did you work on?') + ).toHaveValue(description); + }); + + test('clicking a running entry does NOT open edit modal', async ({ page, ctx }) => { + const description = 'Running no edit test ' + Math.floor(1 + Math.random() * 10000); + await createRunningTimeEntryViaApi(ctx, description); + await goToCalendar(page); + + const event = page.locator('.fc-event').filter({ hasText: description }).first(); + await expect(event).toBeVisible(); + + // Click on the running entry + await event.click(); + + // Wait briefly to ensure no dialog appears + await page.waitForTimeout(500); + await expect(page.getByRole('dialog')).not.toBeVisible(); + }); +}); + +// ============================================= +// Section 16: Selection & Drag-to-Create Details +// ============================================= + +test.describe('Selection & Drag-to-Create Details', () => { + test('single click on empty slot opens create modal', async ({ page }) => { + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + // Click on an empty time slot (not on an event) + const slot = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first(); + await expect(slot).toBeVisible(); + const slotBox = await slot.boundingBox(); + + // Click in the middle of the slot + await page.mouse.click(slotBox!.x + slotBox!.width / 2, slotBox!.y + 2); + + // Create modal should appear + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + }); + + test('drag-to-create shows selection highlight during drag', async ({ page }) => { + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first(); + await expect(startSlot).toBeVisible(); + const startBox = await startSlot.boundingBox(); + + const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); + const endBox = await endSlot.boundingBox(); + + // Start dragging without releasing + await page.mouse.move(startBox!.x + startBox!.width / 2, startBox!.y + 2); + await page.mouse.down(); + await page.mouse.move(endBox!.x + endBox!.width / 2, endBox!.y + 2, { steps: 10 }); + + // Selection highlight should be visible (bg-accent border-primary class) + const selectionMirror = page.locator('.bg-accent.border-primary'); + await expect(selectionMirror.first()).toBeVisible(); + + // Release mouse + await page.mouse.up(); + }); + + test('snap interval affects drag-to-create times', async ({ page }) => { + await goToCalendar(page); + + // Set snap interval to 30 min + await page.getByRole('button', { name: 'Calendar settings' }).click(); + await expect(page.getByText('Calendar Settings')).toBeVisible(); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.keyboard.press('Escape'); + + await scrollCalendarToTime(page, '09:00:00'); + + // Drag from ~10:10 (slightly offset) to ~11:20 + const slot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first(); + const slot11 = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); + const slotHeight = await getSlotHeight(page); + const box10 = await slot10.boundingBox(); + const box11 = await slot11.boundingBox(); + + // Start at 10:10 (slightly into the slot) + const startX = box10!.x + box10!.width / 2; + const startY = box10!.y + slotHeight * 0.7; // ~10 minutes into the slot + // End at 11:20 (past the 11:00 slot) + const endY = box11!.y + slotHeight * 1.3; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX, endY, { steps: 10 }); + await page.mouse.up(); + + // Create modal should appear + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Submit the form and check the API request for 30-min-aligned times + const [createResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + + const body = await createResponse.json(); + const startDate = new Date(body.data.start); + const endDate = new Date(body.data.end); + + // Both start and end minutes should be divisible by 30 + expect(startDate.getMinutes() % 30).toBe(0); + expect(endDate.getMinutes() % 30).toBe(0); + }); +}); + +// ============================================= +// Section 17: Resize Snap Interval +// ============================================= + +test.describe('Resize Snap Interval', () => { + test('resize bottom edge respects snap interval', async ({ page, ctx }) => { + // Set snap interval to 30 min + await goToCalendar(page); + await page.getByRole('button', { name: 'Calendar settings' }).click(); + await expect(page.getByText('Calendar Settings')).toBeVisible(); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.keyboard.press('Escape'); + + // Create entry at 10:00–11:00 + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Resize snap test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + const event = page.locator('.fc-event').filter({ hasText: 'Resize snap test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Resize by dragging bottom edge down ~3 slots (45 min at 15-min grid) + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + (async () => { + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(centerX, bottomY + slotHeight * 3, { steps: 15 }); + await page.mouse.up(); + })(), + ]); + + const body = await putResponse.json(); + const endDate = new Date(body.data.end); + // With 30-min snap, end minutes should be divisible by 30 + expect(endDate.getMinutes() % 30).toBe(0); + }); + + test('resize preview of off-grid event snaps to absolute grid positions', async ({ + page, + ctx, + }) => { + // Set snap interval to 15 min (default grid scale is also 15 min) + await goToCalendar(page); + await page.getByRole('button', { name: 'Calendar settings' }).click(); + await expect(page.getByText('Calendar Settings')).toBeVisible(); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '15 min' }).click(); + await page.keyboard.press('Escape'); + + // Create entry at 10:07–10:37 — deliberately off the 15-min grid + const start = todayAt(10, 7); + const end = todayAt(10, 37); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Off-grid snap test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + const event = page.locator('.fc-event').filter({ hasText: 'Off-grid snap test' }).first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const bottomY = eventBox!.y + eventBox!.height; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Resize bottom edge down by ~2 slots (30 min at 15-min grid) + await page.mouse.move(centerX, bottomY - 3); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(centerX, bottomY + slotHeight * 2, { steps: 15 }); + + // While still dragging, capture the preview's inline style (top + height). + // The resizing event is positioned via inline styles by the composable. + const previewStyle = await page.evaluate(() => { + // Find all fc-event elements and pick the one being resized + // (it will have inline top/height from the resize composable) + const events = document.querySelectorAll('.fc-event'); + for (const ev of events) { + const el = ev as HTMLElement; + if (el.style.top && el.style.height) { + return { + top: parseFloat(el.style.top), + height: parseFloat(el.style.height), + }; + } + } + return null; + }); + expect(previewStyle).not.toBeNull(); + + // With snap = 15 min and grid scale = 15 min, snapPx = slotHeight. + // The end edge (top + height) should be at a grid-aligned position + // (divisible by slotHeight), even though the event starts at 10:07 + // which is NOT grid-aligned. + const previewEnd = previewStyle!.top + previewStyle!.height; + const endOffGrid = previewEnd % slotHeight; + expect(endOffGrid).toBeLessThanOrEqual(1); // allow sub-pixel rounding + + // Release the mouse to complete the resize + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + page.mouse.up(), + ]); + + const body = await putResponse.json(); + const endDate = new Date(body.data.end); + // The saved end time should be snapped to the 15-min grid + expect(endDate.getMinutes() % 15).toBe(0); + + // After the API update, the rendered position should match the preview. + // The event top stays the same (only end edge was resized), so + // compare the final rendered height to the preview height. + await expect(async () => { + const updatedEvent = page + .locator('.fc-event') + .filter({ hasText: 'Off-grid snap test' }) + .first(); + const updatedBox = await updatedEvent.boundingBox(); + expect(updatedBox).not.toBeNull(); + // Rendered height should match preview height within 2px + expect(Math.abs(updatedBox!.height - previewStyle!.height)).toBeLessThanOrEqual(2); + }).toPass({ timeout: 5000 }); + }); + + test('resize top edge of off-grid event snaps to absolute grid positions', async ({ + page, + ctx, + }) => { + // Set snap interval to 15 min + await goToCalendar(page); + await page.getByRole('button', { name: 'Calendar settings' }).click(); + await expect(page.getByText('Calendar Settings')).toBeVisible(); + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '15 min' }).click(); + await page.keyboard.press('Escape'); + + // Create entry at 10:07–11:07 — deliberately off the 15-min grid + const start = todayAt(10, 7); + const end = todayAt(11, 7); + await createTimeEntryWithTimestampsViaApi(ctx, { + description: 'Off-grid top snap test', + start, + end, + }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + const event = page + .locator('.fc-event') + .filter({ hasText: 'Off-grid top snap test' }) + .first(); + await expect(event).toBeVisible(); + + const slotHeight = await getSlotHeight(page); + const eventBox = await event.boundingBox(); + const topY = eventBox!.y; + const centerX = eventBox!.x + eventBox!.width / 2; + + // Resize top edge up by ~2 slots (30 min) + await page.mouse.move(centerX, topY + 3); + await page.waitForTimeout(100); + await page.mouse.down(); + await page.mouse.move(centerX, topY - slotHeight * 2, { steps: 15 }); + + // Capture preview position during drag + const previewStyle = await page.evaluate(() => { + const events = document.querySelectorAll('.fc-event'); + for (const ev of events) { + const el = ev as HTMLElement; + if (el.style.top && el.style.height) { + return { + top: parseFloat(el.style.top), + height: parseFloat(el.style.height), + }; + } + } + return null; + }); + expect(previewStyle).not.toBeNull(); + + // The start edge (top) should be at a grid-aligned position + const topOffGrid = previewStyle!.top % slotHeight; + expect(topOffGrid).toBeLessThanOrEqual(1); + + // Release the mouse + const [putResponse] = await Promise.all([ + page.waitForResponse( + (r) => + r.url().includes('/time-entries/') && + r.request().method() === 'PUT' && + r.status() === 200 + ), + page.mouse.up(), + ]); + + const body = await putResponse.json(); + const startDate = new Date(body.data.start); + // The saved start time should be snapped to the 15-min grid + expect(startDate.getMinutes() % 15).toBe(0); + }); +}); + +// ============================================= +// Section 18: Advanced Overlap Layout +// ============================================= + +test.describe('Advanced Overlap Layout', () => { + test('three overlapping events render in separate columns', async ({ page, ctx }) => { + const start = todayAt(10); + const end = todayAt(11); + await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Triple A', start, end }); + await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Triple B', start, end }); + await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Triple C', start, end }); + await goToCalendar(page); + await scrollCalendarToTime(page, '09:00:00'); + + const eventA = page.locator('.fc-event').filter({ hasText: 'Triple A' }).first(); + const eventB = page.locator('.fc-event').filter({ hasText: 'Triple B' }).first(); + const eventC = page.locator('.fc-event').filter({ hasText: 'Triple C' }).first(); + await expect(eventA).toBeVisible(); + await expect(eventB).toBeVisible(); + await expect(eventC).toBeVisible(); + + const boxA = await eventA.boundingBox(); + const boxB = await eventB.boundingBox(); + const boxC = await eventC.boundingBox(); + expect(boxA).not.toBeNull(); + expect(boxB).not.toBeNull(); + expect(boxC).not.toBeNull(); + + // All three should have similar widths (each ~1/3 of column) + const widths = [boxA!.width, boxB!.width, boxC!.width].sort((a, b) => a - b); + // Smallest should be at least 60% of largest (they're roughly equal thirds) + expect(widths[0]).toBeGreaterThan(widths[2]! * 0.6); + + // All three should be at distinct x positions + const xs = [boxA!.x, boxB!.x, boxC!.x].sort((a, b) => a - b); + // First and second should differ + expect(xs[1]! - xs[0]!).toBeGreaterThan(5); + // Second and third should differ + expect(xs[2]! - xs[1]!).toBeGreaterThan(5); + }); +}); + +// ============================================= +// Section 19: Daily Total Updates After Create +// ============================================= + +test.describe('Daily Total After Create', () => { + test('daily total updates after creating entry via drag-to-create', async ({ page }) => { + await goToCalendar(page); + + const todayHeader = page.locator('.fc-day-today.fc-col-header-cell'); + + // Store initial total text + const initialText = await todayHeader.innerText(); + + await scrollCalendarToTime(page, '13:00:00'); + + // Get today's column X position to ensure drag is on the right day + const todayDate = await todayHeader.getAttribute('data-date'); + const todayCol = page.locator(`.fc-timegrid-col[data-date="${todayDate}"]`); + const colBox = await todayCol.first().boundingBox(); + const colCenterX = colBox!.x + colBox!.width / 2; + + // Drag to create a 1-hour entry from 14:00 to 15:00 + const slot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); + const slot15 = page.locator('.fc-timegrid-slot-lane[data-time="15:00:00"]').first(); + const box14 = await slot14.boundingBox(); + const box15 = await slot15.boundingBox(); + + await page.mouse.move(colCenterX, box14!.y + 2); + await page.mouse.down(); + await page.mouse.move(colCenterX, box15!.y + 2, { steps: 10 }); + await page.mouse.up(); + + // Create modal should appear + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 5000 }); + + // Submit the create form + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries') && + response.request().method() === 'POST' && + response.status() === 201 + ), + page.getByRole('button', { name: 'Create Time Entry' }).click(), + ]); + + // Wait for the header total to update after refresh + await expect(async () => { + const updatedText = await todayHeader.innerText(); + expect(updatedText).not.toBe(initialText); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 7ca8bffd..7d761f81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,6 @@ "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/vue": "^1.0.6", - "@fullcalendar/core": "^6.1.18", - "@fullcalendar/daygrid": "^6.1.18", - "@fullcalendar/interaction": "^6.1.18", - "@fullcalendar/timegrid": "^6.1.18", - "@fullcalendar/vue3": "^6.1.18", "@heroicons/vue": "^2.1.1", "@rushstack/eslint-patch": "^1.10.5", "@tailwindcss/container-queries": "^0.1.1", @@ -25,7 +20,7 @@ "@tanstack/vue-table": "^8.21.2", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.3.0", - "@vueuse/core": "^14.2.0", + "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.0.0", "@zodios/core": "^10.9.6", "chroma-js": "3.1.2", @@ -38,7 +33,7 @@ "parse-duration": "^2.0.1", "pinia": "^3.0.0", "radix-vue": "^1.9.6", - "reka-ui": "^2.8.0", + "reka-ui": "^2.8.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vue-echarts": "^8.0.0", @@ -1037,55 +1032,6 @@ } } }, - "node_modules/@fullcalendar/core": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", - "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", - "license": "MIT", - "dependencies": { - "preact": "~10.12.1" - } - }, - "node_modules/@fullcalendar/daygrid": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", - "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20" - } - }, - "node_modules/@fullcalendar/interaction": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", - "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20" - } - }, - "node_modules/@fullcalendar/timegrid": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", - "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", - "license": "MIT", - "dependencies": { - "@fullcalendar/daygrid": "~6.1.20" - }, - "peerDependencies": { - "@fullcalendar/core": "~6.1.20" - } - }, - "node_modules/@fullcalendar/vue3": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz", - "integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20", - "vue": "^3.0.11" - } - }, "node_modules/@heroicons/vue": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", @@ -2996,14 +2942,14 @@ } }, "node_modules/@vueuse/core": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz", - "integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==", + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "14.2.0", - "@vueuse/shared": "14.2.0" + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -3012,6 +2958,18 @@ "vue": "^3.5.0" } }, + "node_modules/@vueuse/core/node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@vueuse/integrations": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.0.tgz", @@ -3078,7 +3036,24 @@ } } }, - "node_modules/@vueuse/metadata": { + "node_modules/@vueuse/integrations/node_modules/@vueuse/core": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.0.tgz", + "integrity": "sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.0", + "@vueuse/shared": "14.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.0.tgz", "integrity": "sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==", @@ -3087,6 +3062,15 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@vueuse/shared": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.0.tgz", @@ -5846,16 +5830,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/preact": { - "version": "10.12.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", - "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6118,9 +6092,9 @@ } }, "node_modules/reka-ui": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.0.tgz", - "integrity": "sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.2.tgz", + "integrity": "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", @@ -6134,6 +6108,10 @@ "defu": "^6.1.4", "ohash": "^2.0.11" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/zernonia" + }, "peerDependencies": { "vue": ">= 3.2.0" } @@ -7442,12 +7420,18 @@ "peerDependencies": { "@floating-ui/vue": "^1.1.4", "@heroicons/vue": "^2.1.5", + "@internationalized/date": "^3.0.0", "@vitejs/plugin-vue": "^5.1.2 || ^6.0.0", "@vueuse/core": "^12.5.0 || ^14.0.0", + "@vueuse/integrations": "^12.5.0 || ^14.0.0", + "chroma-js": "^3.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", + "focus-trap": "^7.0.0 || ^8.0.0", + "lucide-vue-next": ">=0.453.0", "parse-duration": "^2.0.1", + "radix-vue": "^1.9.0", "reka-ui": "^2.2.0", "tailwind-merge": "^2.5.2", "tailwindcss": "^3.1.0", diff --git a/package.json b/package.json index 70be7ffb..3d2aa640 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,6 @@ "dependencies": { "@floating-ui/core": "^1.6.0", "@floating-ui/vue": "^1.0.6", - "@fullcalendar/core": "^6.1.18", - "@fullcalendar/daygrid": "^6.1.18", - "@fullcalendar/interaction": "^6.1.18", - "@fullcalendar/timegrid": "^6.1.18", - "@fullcalendar/vue3": "^6.1.18", "@heroicons/vue": "^2.1.1", "@rushstack/eslint-patch": "^1.10.5", "@tailwindcss/container-queries": "^0.1.1", @@ -59,7 +54,7 @@ "@tanstack/vue-table": "^8.21.2", "@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-typescript": "^14.3.0", - "@vueuse/core": "^14.2.0", + "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.0.0", "@zodios/core": "^10.9.6", "chroma-js": "3.1.2", @@ -72,7 +67,7 @@ "parse-duration": "^2.0.1", "pinia": "^3.0.0", "radix-vue": "^1.9.6", - "reka-ui": "^2.8.0", + "reka-ui": "^2.8.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vue-echarts": "^8.0.0", diff --git a/resources/js/Pages/Calendar.vue b/resources/js/Pages/Calendar.vue index afe39f76..919ba5ad 100644 --- a/resources/js/Pages/Calendar.vue +++ b/resources/js/Pages/Calendar.vue @@ -2,7 +2,7 @@ import AppLayout from '@/Layouts/AppLayout.vue'; import { useTimeEntriesCalendarQuery } from '@/utils/useTimeEntriesCalendarQuery'; import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations'; -import { computed, ref } from 'vue'; +import { computed, ref, onMounted } from 'vue'; import { useQueryClient } from '@tanstack/vue-query'; import { type Client, @@ -11,6 +11,7 @@ import { type Project, } from '@/packages/api/src'; import { TimeEntryCalendar } from '@/packages/ui/src'; +import type { ActivityPeriod } from '@/packages/ui/src/FullCalendar/activityTypes'; import { isAllowedToPerformPremiumAction } from '@/utils/billing'; import { useTagsStore } from '@/utils/useTags'; import { useProjectsQuery } from '@/utils/useProjectsQuery'; @@ -26,6 +27,26 @@ import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry'; const calendarStart = ref(undefined); const calendarEnd = ref(undefined); +// Test-injectable activity periods (for E2E testing). +// These hooks are no-ops in production — they only take effect when test code +// explicitly sets window globals, so they are safe to ship. +const testActivityPeriods = ref([]); + +onMounted(() => { + (window as Record).__TEST_SET_ACTIVITY_PERIODS__ = ( + data: ActivityPeriod[] + ) => { + testActivityPeriods.value = data; + }; + + const windowData = (window as Record).__TEST_ACTIVITY_PERIODS__; + if (Array.isArray(windowData)) { + setTimeout(() => { + testActivityPeriods.value = windowData; + }, 2000); + } +}); + const { data: timeEntryResponse, isLoading: timeEntriesLoading } = useTimeEntriesCalendarQuery( calendarStart, calendarEnd @@ -89,7 +110,10 @@ function onRefresh() {