import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { createBillableProjectViaApi, createProjectViaApi, createBareTimeEntryViaApi, createTimeEntryViaApi, createRunningTimeEntryViaApi, createTimeEntryWithTimestampsViaApi, createRunningTimeEntryWithStartViaApi, createTaskViaApi, createProjectWithClientViaApi, updateUserProfileViaApi, updateOrganizationSettingViaApi, } from './utils/api'; 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) { const event = page.locator('.fc-event').filter({ hasText: description }).first(); await expect(event).toBeVisible(); await event.click({ button: 'right' }); 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. * * Issue: https://github.com/solidtime-io/solidtime/issues/981 */ test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({ page, ctx, }) => { const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000); await createBillableProjectViaApi(ctx, { name: billableProjectName }); await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h'); await goToCalendar(page); // Click on the time entry event in the calendar await page.locator('.fc-event').filter({ hasText: 'Test billable calendar' }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); // Verify initially non-billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Select the billable project await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); await page.getByRole('option', { name: billableProjectName }).click(); // Verify the billable dropdown updated to Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) ).toBeVisible(); // Save and verify const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(true); }); test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({ page, ctx, }) => { const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); const nonBillableProjectName = 'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); await createBillableProjectViaApi(ctx, { name: billableProjectName }); await createProjectViaApi(ctx, { name: nonBillableProjectName }); await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h'); await goToCalendar(page); // Click on the time entry event in the calendar await page .locator('.fc-event') .filter({ hasText: 'Test billable cal reverse' }) .first() .click(); await expect(page.getByRole('dialog')).toBeVisible(); // First assign the billable project await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); await page.getByRole('option', { name: billableProjectName }).click(); // Verify billable status flipped to Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) ).toBeVisible(); // Now switch to the non-billable project await page.getByRole('dialog').getByRole('button', { name: billableProjectName }).click(); await page.getByRole('option', { name: nonBillableProjectName }).click(); // Verify billable status reverted to Non-Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Save and verify const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(false); }); test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({ page, ctx, }) => { const billableProjectName = 'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000); await createBillableProjectViaApi(ctx, { name: billableProjectName }); await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h'); await goToCalendar(page); // Click on the time entry event in the calendar await page .locator('.fc-event') .filter({ hasText: 'Test cal persist override' }) .first() .click(); await expect(page.getByRole('dialog')).toBeVisible(); // Assign the billable project await page.getByRole('dialog').getByRole('button', { name: 'No Project' }).click(); await page.getByRole('option', { name: billableProjectName }).click(); // Verify it auto-set to Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }) ).toBeVisible(); // Now manually override billable to Non-Billable via the dropdown await page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Billable' }).click(); await page.getByRole('option', { name: 'Non Billable' }).click(); // Verify it shows Non-Billable now await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Save const [firstSaveResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const firstBody = await firstSaveResponse.json(); expect(firstBody.data.billable).toBe(false); // Re-open the edit modal from the calendar — the project_id watcher should NOT override billable await page .locator('.fc-event') .filter({ hasText: 'Test cal persist override' }) .first() .click(); await expect(page.getByRole('dialog')).toBeVisible(); // The billable dropdown should still show Non-Billable await expect( page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Non-Billable' }) ).toBeVisible(); // Save without changes and verify the response still has billable=false const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(false); }); test('test that calendar page loads and displays time entries', async ({ page, ctx }) => { await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h'); await goToCalendar(page); // Calendar container should be visible await expect(page.locator('.fc')).toBeVisible(); // The time entry should appear as a calendar event await expect( page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first() ).toBeVisible(); }); test('test that calendar navigation buttons work', async ({ page }) => { await goToCalendar(page); await expect(page.locator('.fc')).toBeVisible(); // Click the "next" button to navigate forward await page.getByRole('button', { name: 'Next' }).click(); await expect(page.locator('.fc')).toBeVisible(); // Click the "prev" button to navigate back await page.getByRole('button', { name: 'Previous' }).click(); await expect(page.locator('.fc')).toBeVisible(); // Navigate forward first, then click today await page.getByRole('button', { name: 'Next' }).click(); await page.getByRole('button', { name: 'today' }).click(); await expect(page.locator('.fc')).toBeVisible(); }); test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000); const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, originalDescription, '1h'); await goToCalendar(page); // Click on the time entry event await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); // Update the description (edit modal uses placeholder, not data-testid) const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?'); await descriptionInput.fill(updatedDescription); // Save and verify const [editResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('button', { name: 'Update Time Entry' }).click(), ]); const editBody = await editResponse.json(); expect(editBody.data.description).toBe(updatedDescription); // Verify the updated description is shown in the calendar UI await expect( page.locator('.fc-event').filter({ hasText: updatedDescription }).first() ).toBeVisible(); // Verify the old description is no longer shown await expect( page.locator('.fc-event').filter({ hasText: originalDescription }) ).not.toBeVisible(); }); test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => { const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, description, '1h'); await goToCalendar(page); // Click on the time entry event await page.locator('.fc-event').filter({ hasText: description }).first().click(); await expect(page.getByRole('dialog')).toBeVisible(); // Click the delete button await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(), ]); // Verify the event is removed from the calendar await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); }); // ============================================= // Context Menu Tests // ============================================= test('test that context menu edit opens the edit modal', async ({ page, ctx }) => { const description = 'Context edit test ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, description, '1h'); await goToCalendar(page); await openContextMenu(page, description); await page.getByRole('menuitem', { name: 'Edit' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await expect(page.getByRole('dialog').getByPlaceholder('What did you work on?')).toHaveValue( description ); }); test('test that context menu duplicate preserves project and billable status', async ({ page, ctx, }) => { const description = 'Context dup test ' + Math.floor(1 + Math.random() * 10000); const project = await createProjectViaApi(ctx, { name: 'Dup Project ' + Math.floor(1 + Math.random() * 10000), is_billable: true, }); await createTimeEntryViaApi(ctx, { description, duration: '1h', projectId: project.id, billable: true, }); await goToCalendar(page); await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(1); await openContextMenu(page, description); const [createResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries') && response.request().method() === 'POST' && response.status() === 201 ), page.getByRole('menuitem', { name: 'Duplicate' }).click(), ]); const body = await createResponse.json(); expect(body.data.description).toBe(description); expect(body.data.project_id).toBe(project.id); expect(body.data.billable).toBe(true); await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(2); }); test('test that context menu delete removes the time entry', async ({ page, ctx }) => { const description = 'Context delete test ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, description, '1h'); await goToCalendar(page); await openContextMenu(page, description); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('menuitem', { name: 'Delete' }).click(), ]); await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); }); test('test that context menu split divides time entry into two', async ({ page, ctx }) => { const description = 'Context split test ' + Math.floor(1 + Math.random() * 10000); await createBareTimeEntryViaApi(ctx, description, '2h'); await goToCalendar(page); await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(1); await openContextMenu(page, description); const [updateResponse, createResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.waitForResponse( (response) => response.url().includes('/time-entries') && response.request().method() === 'POST' && response.status() === 201 ), page.getByRole('menuitem', { name: 'Split' }).click(), ]); const updateBody = await updateResponse.json(); const createBody = await createResponse.json(); expect(updateBody.data.end).toBe(createBody.data.start); await expect(page.locator('.fc-event').filter({ hasText: description })).toHaveCount(2); }); test('test that context menu create time entry opens the create modal', async ({ page }) => { await goToCalendar(page); await expect(page.locator('.fc')).toBeVisible(); const slotLane = page.locator('.fc-timegrid-slot-lane').first(); await expect(slotLane).toBeVisible(); await slotLane.click({ button: 'right' }); await expect(page.getByRole('menu')).toBeVisible(); await page.getByRole('menuitem', { name: 'Create Time Entry' }).click(); await expect(page.getByRole('dialog')).toBeVisible(); }); test('test that context menu for running entry shows stop and discard options', async ({ page, ctx, }) => { const description = 'Running ctx menu test ' + Math.floor(1 + Math.random() * 10000); await createRunningTimeEntryViaApi(ctx, description); await goToCalendar(page); await openContextMenu(page, description); // Running entry should show Stop and Discard, not Edit/Duplicate/Split/Delete await expect(page.getByRole('menuitem', { name: 'Stop' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Discard' })).toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Edit' })).not.toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Duplicate' })).not.toBeVisible(); await expect(page.getByRole('menuitem', { name: 'Split' })).not.toBeVisible(); }); test('test that context menu stop on running entry sets end time', async ({ page, ctx }) => { const description = 'Running stop test ' + Math.floor(1 + Math.random() * 10000); await createRunningTimeEntryViaApi(ctx, description); await goToCalendar(page); await openContextMenu(page, description); const [updateResponse] = await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'PUT' && response.status() === 200 ), page.getByRole('menuitem', { name: 'Stop' }).click(), ]); const body = await updateResponse.json(); expect(body.data.end).not.toBeNull(); expect(body.data.description).toBe(description); }); test('test that context menu discard on running entry deletes it', async ({ page, ctx }) => { const description = 'Running discard test ' + Math.floor(1 + Math.random() * 10000); await createRunningTimeEntryViaApi(ctx, description); await goToCalendar(page); await openContextMenu(page, description); await Promise.all([ page.waitForResponse( (response) => response.url().includes('/time-entries/') && response.request().method() === 'DELETE' && response.status() === 204 ), page.getByRole('menuitem', { name: 'Discard' }).click(), ]); await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); }); // ============================================= // Employee Permission Tests // ============================================= test.describe('Employee Calendar Isolation', () => { test('employee can only see their own time entries on the calendar', async ({ ctx, employee, }) => { // Owner creates a time entry for today const ownerDescription = 'OwnerCalEntry ' + Math.floor(Math.random() * 10000); await createBareTimeEntryViaApi(ctx, ownerDescription, '1h'); // Create a time entry for the employee for today const employeeDescription = 'EmpCalEntry ' + Math.floor(Math.random() * 10000); await createTimeEntryViaApi( { ...ctx, memberId: employee.memberId }, { description: employeeDescription, duration: '30min' } ); await employee.page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); await expect(employee.page.locator('.fc')).toBeVisible({ timeout: 10000 }); // Employee's event IS visible await expect( employee.page.locator('.fc-event').filter({ hasText: employeeDescription }).first() ).toBeVisible({ timeout: 10000 }); // Owner's event is NOT visible await expect( employee.page.locator('.fc-event').filter({ hasText: ownerDescription }) ).not.toBeVisible(); }); }); // ============================================= // Section 1: Event Rendering & Display // ============================================= test.describe('Event Rendering & Display', () => { test('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('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('running entry start can be changed via top-edge resize', async ({ page, ctx }) => { const now = new Date(); const startTime = new Date(now.getTime() - 2 * 60 * 60 * 1000); // Skip if start would be on a different day (near midnight UTC) test.skip( startTime.getUTCDate() !== now.getUTCDate(), 'Skipping near midnight UTC to avoid cross-day issues' ); 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(); // Use the start resizer element directly for more reliable interaction const startResizer = event.locator('.fc-event-resizer-start'); const resizerBox = await startResizer.boundingBox(); const centerX = resizerBox!.x + resizerBox!.width / 2; const resizerY = resizerBox!.y + resizerBox!.height / 2; const [putResponse] = await Promise.all([ page.waitForResponse( (r) => r.url().includes('/time-entries/') && r.request().method() === 'PUT' ), (async () => { await page.mouse.move(centerX, resizerY); 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, resizerY + slotHeight * 4, { steps: 15 }); await page.mouse.up(); })(), ]); expect(putResponse.status()).toBe(200); 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('running entry resize preserves end:null in API response', async ({ page, ctx }) => { const now = new Date(); const startTime = new Date(now.getTime() - 1 * 60 * 60 * 1000); // Skip if start would be on a different day (near midnight UTC) test.skip( startTime.getUTCDate() !== now.getUTCDate(), 'Skipping near midnight UTC to avoid cross-day issues' ); 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('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('resize bottom edge across day boundary changes end date backward', 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('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 (may appear in multiple columns) const preview = page.locator('.fc-cross-day-preview').first(); await expect(preview).toBeVisible(); await page.mouse.up(); }); test('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('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('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('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('drag-to-create spanning two days opens create modal with correct cross-day times', async ({ page, }) => { const now = new Date(); const dayOfWeek = now.getDay(); // Need today and tomorrow both visible (skip Saturday with Monday week start) test.skip(dayOfWeek === 6, 'Skipping on Saturday — tomorrow not visible in week view'); await goToCalendar(page); await expect(page.locator('.fc')).toBeVisible(); // Use mid-day times so both start and end slots are visible in the viewport await scrollCalendarToTime(page, '10:00:00'); // Find 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 11:00 slot (start) and 13:00 slot (end on tomorrow) const startSlot = page.locator('.fc-timegrid-slot-lane[data-time="11:00:00"]').first(); const endSlot = page.locator('.fc-timegrid-slot-lane[data-time="13:00:00"]').first(); await expect(startSlot).toBeVisible(); await expect(endSlot).toBeVisible(); const startSlotBox = await startSlot.boundingBox(); const endSlotBox = await endSlot.boundingBox(); const todayColBox = await todayCol.boundingBox(); const tomorrowColBox = await tomorrowCol.boundingBox(); // Start drag at 11:00 on today's column const startX = todayColBox!.x + todayColBox!.width / 2; const startY = startSlotBox!.y + 2; // End drag at 13:00 on tomorrow's column const endX = tomorrowColBox!.x + tomorrowColBox!.width / 2; const endY = endSlotBox!.y + 2; // Drag from today to tomorrow — move down first, then across const slotHeight = await getSlotHeight(page); await page.mouse.move(startX, startY); await page.mouse.down(); await page.waitForTimeout(100); await page.mouse.move(startX, startY + slotHeight * 2, { steps: 5 }); await page.mouse.move(endX, startY + slotHeight * 2, { steps: 10 }); await page.mouse.move(endX, endY, { steps: 10 }); 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 await expect(dialog.getByText(todayStr)).toBeVisible(); await expect(dialog.getByText(tomorrowStr)).toBeVisible(); }); }); // ============================================= // Section 6: Timezone & Localization // ============================================= test.describe('Timezone & Localization', () => { test('week start day: monday shows Mon as first column', async ({ page, ctx }) => { await updateUserProfileViaApi(ctx, { week_start: 'monday' }); await goToCalendar(page); 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, ctx }) => { await updateUserProfileViaApi(ctx, { week_start: 'sunday' }); await goToCalendar(page); await expect(page.locator('.fc')).toBeVisible(); const firstHeader = page.locator('.fc-col-header-cell').first(); await expect(firstHeader).toContainText('Sun'); }); 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 now = new Date(); const startTime = new Date(now.getTime() - 1 * 60 * 60 * 1000); // Skip if start would be on a different day (near midnight UTC) test.skip( startTime.getUTCDate() !== now.getUTCDate(), 'Skipping near midnight UTC to avoid cross-day issues' ); 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 }); }); });