Files
solidtime/e2e/calendar.spec.ts
2026-06-09 16:24:52 +02:00

2877 lines
116 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number> {
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:0011: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:0710: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:0711: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 });
});
});