import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { createBareTimeEntryViaApi, createTimeEntryWithTimestampsViaApi } from './utils/api'; async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); await expect(page.locator('.fc')).toBeVisible({ timeout: 10000 }); } async function openSettingsPopover(page: Page) { await page.getByRole('button', { name: 'Calendar settings' }).click(); await expect(page.getByText('Calendar Settings')).toBeVisible(); } async function clearCalendarSettings(page: Page) { await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings')); } function getCalendarTitle(page: Page) { return page.getByTestId('calendar-title'); } async function scrollCalendarToTime(page: Page, time: string) { await page.evaluate((t) => { const slot = document.querySelector(`.fc-timegrid-slot-lane[data-time="${t}"]`); if (slot) slot.scrollIntoView({ block: 'start' }); }, time); await page.waitForTimeout(300); } async function getSlotHeight(page: Page): Promise { return await page.evaluate(() => { const slots = Array.from(document.querySelectorAll('.fc-timegrid-slot-lane')); for (let i = 0; i < slots.length; i++) { const h = slots[i].getBoundingClientRect().height; if (h > 0) return h; } return 20; }); } test.describe('Calendar Settings', () => { test.beforeEach(async ({ page }) => { await clearCalendarSettings(page); }); test('settings popover shows all fields with correct defaults', async ({ page }) => { await goToCalendar(page); await openSettingsPopover(page); await expect(page.getByLabel('Snap Interval')).toContainText('15 min'); await expect(page.getByLabel('Start Time')).toContainText('12:00 AM'); await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)'); await expect(page.getByLabel('Grid Scale')).toContainText('15 min'); }); test('snap interval can be changed and persists across reload', async ({ page }) => { await goToCalendar(page); await openSettingsPopover(page); // Change snap interval to 30 min await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '30 min' }).click(); // Close the popover by pressing Escape await page.keyboard.press('Escape'); // Verify localStorage was updated const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}') ); expect(stored.snapMinutes).toBe(30); // Reload and verify persistence await page.reload(); await expect(page.locator('.fc')).toBeVisible(); await openSettingsPopover(page); await expect(page.getByLabel('Snap Interval')).toContainText('30 min'); }); test('start time change is applied to calendar and rejects invalid values', async ({ page, }) => { await goToCalendar(page); // Verify 7 AM slot exists with default start (00:00) await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0); await openSettingsPopover(page); // Set end time to 6 PM first await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '6:00 PM' }).click(); // Change start time to 8 AM (valid) await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); // Try to set start time to 6 PM (invalid: equals end time) — should be rejected await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '6:00 PM' }).click(); // Should be rejected — start time stays at 8 AM await expect(page.getByLabel('Start Time')).toContainText('8:00 AM'); // Close the popover await page.keyboard.press('Escape'); // Calendar should no longer show hours before 8 AM await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0); await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0); }); test('end time change is applied to calendar and rejects invalid values', async ({ page }) => { await goToCalendar(page); // Verify 19:00 slot exists with default end (24:00) await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0); await openSettingsPopover(page); // Set start time to 8 AM first await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); // Change end time to 6 PM (valid) await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '6:00 PM' }).click(); // Try to set end time to 8 AM (invalid: equals start time) — should be rejected await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); // Should be rejected — end time stays at 6 PM await expect(page.getByLabel('End Time')).toContainText('6:00 PM'); // Close the popover await page.keyboard.press('Escape'); // Calendar should no longer show hours at or after 6 PM await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0); await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0); }); test('grid scale affects number of calendar slots', async ({ page }) => { await goToCalendar(page); // Count slots with default 15-min scale const defaultSlotCount = await page.locator('.fc-timegrid-slot').count(); // Change to 30 min scale (should halve the slots) await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.keyboard.press('Escape'); // Wait for FullCalendar to re-render with new slot count await expect(async () => { const count = await page.locator('.fc-timegrid-slot').count(); expect(count).toBeLessThan(defaultSlotCount); }).toPass({ timeout: 5000 }); const largerSlotCount = await page.locator('.fc-timegrid-slot').count(); // Navigate away and back to get a clean calendar mount await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await goToCalendar(page); // Change to 5 min scale (many more slots) await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.keyboard.press('Escape'); // Wait for FullCalendar to re-render with new slot count await expect(async () => { const count = await page.locator('.fc-timegrid-slot').count(); expect(count).toBeGreaterThan(largerSlotCount); }).toPass({ timeout: 5000 }); }); test('all settings persist across navigation', async ({ page }) => { await goToCalendar(page); await openSettingsPopover(page); // Change every setting await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '6:00 AM' }).click(); await page.getByLabel('End Time').click(); await page.getByRole('option', { name: '10:00 PM' }).click(); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); // Close the popover await page.keyboard.press('Escape'); // Navigate away and back await page.goto(PLAYWRIGHT_BASE_URL + '/time'); await goToCalendar(page); // Verify all settings persisted await openSettingsPopover(page); await expect(page.getByLabel('Snap Interval')).toContainText('5 min'); await expect(page.getByLabel('Start Time')).toContainText('6:00 AM'); await expect(page.getByLabel('End Time')).toContainText('10:00 PM'); await expect(page.getByLabel('Grid Scale')).toContainText('30 min'); }); }); test.describe('Calendar Toolbar', () => { test('prev and next buttons navigate the calendar', async ({ page }) => { await goToCalendar(page); // Use column headers to detect navigation (title only shows month which may not change) const getHeaderTexts = async () => { const headers = page.locator('.fc-col-header-cell'); return headers.allTextContents(); }; const initialHeaders = await getHeaderTexts(); // Click next await page.getByRole('button', { name: 'Next', exact: true }).click(); await expect(page.locator('.fc')).toBeVisible(); const nextHeaders = await getHeaderTexts(); expect(nextHeaders).not.toEqual(initialHeaders); // Click prev — should go back to original await page.getByRole('button', { name: 'Previous', exact: true }).click(); await expect(page.locator('.fc')).toBeVisible(); const backHeaders = await getHeaderTexts(); expect(backHeaders).toEqual(initialHeaders); }); test('today button returns to current week', async ({ page }) => { await goToCalendar(page); // Use column headers to detect navigation (title only shows month which may not change) const getHeaderTexts = async () => { const headers = page.locator('.fc-col-header-cell'); return headers.allTextContents(); }; const initialHeaders = await getHeaderTexts(); // Navigate away await page.getByRole('button', { name: 'Next', exact: true }).click(); await page.getByRole('button', { name: 'Next', exact: true }).click(); const awayHeaders = await getHeaderTexts(); expect(awayHeaders).not.toEqual(initialHeaders); // Click today await page.getByRole('button', { name: 'today', exact: true }).click(); await expect(page.locator('.fc')).toBeVisible(); const todayHeaders = await getHeaderTexts(); expect(todayHeaders).toEqual(initialHeaders); }); test('view switcher toggles between week and day views', async ({ page }) => { await goToCalendar(page); // Default should be week view — verify multiple day columns exist await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1); // Switch to day view await page.getByRole('tab', { name: 'day', exact: true }).click(); await expect(page.locator('.fc')).toBeVisible(); // Day view should show exactly 1 day column await expect(page.locator('.fc-col-header-cell')).toHaveCount(1); // Switch back to week view await page.getByRole('tab', { name: 'week', exact: true }).click(); await expect(page.locator('.fc')).toBeVisible(); // Week view should show multiple day columns again await expect(page.locator('.fc-col-header-cell')).not.toHaveCount(1); }); }); test.describe('Visual Snapping', () => { test.beforeEach(async ({ page }) => { await clearCalendarSettings(page); }); test('snap interval of 1 minute allows fine-grained positioning', async ({ page, ctx }) => { await goToCalendar(page); await openSettingsPopover(page); // Set snap interval to 1 min await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '1 min' }).click(); await page.keyboard.press('Escape'); // Create a 1h time entry await createBareTimeEntryViaApi(ctx, 'Snap 1min test', '1h'); await goToCalendar(page); // Scroll the calendar so the 14:00 target area is visible await scrollCalendarToTime(page, '13:00:00'); const event = page.locator('.fc-event').first(); await expect(event).toBeVisible(); // Get target slot at a non-15-min boundary time const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); const targetBox = await targetSlot.boundingBox(); expect(targetBox).not.toBeNull(); // Drag event to a position offset from the 15-min boundary const putResponsePromise = page.waitForResponse( (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' ); await event.hover(); await page.mouse.down(); await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); await page.mouse.up(); const putResponse = await putResponsePromise; expect(putResponse.status()).toBe(200); const body = await putResponse.json(); const startDate = new Date(body.data.start); const minutes = startDate.getMinutes(); // With 1-min snap, any minute value is valid (0-59) expect(minutes).toBeGreaterThanOrEqual(0); expect(minutes).toBeLessThanOrEqual(59); }); test('snap interval of 60 minutes creates hour-aligned entries', async ({ page, ctx }) => { await goToCalendar(page); await openSettingsPopover(page); // Set snap interval to 60 min await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '1 hour' }).click(); await page.keyboard.press('Escape'); // Create a 1h time entry await createBareTimeEntryViaApi(ctx, 'Snap 60min test', '1h'); await goToCalendar(page); // Scroll the calendar so the 14:00 target area is visible await scrollCalendarToTime(page, '13:00:00'); const event = page.locator('.fc-event').first(); await expect(event).toBeVisible(); // Get target slot const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); const targetBox = await targetSlot.boundingBox(); expect(targetBox).not.toBeNull(); // Drag event const putResponsePromise = page.waitForResponse( (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' ); await event.hover(); await page.mouse.down(); await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); await page.mouse.up(); const putResponse = await putResponsePromise; expect(putResponse.status()).toBe(200); const body = await putResponse.json(); const startDate = new Date(body.data.start); const minutes = startDate.getMinutes(); // With 60-min snap, minutes should be 0 (on the hour) expect(minutes).toBe(0); }); test('changing snap interval mid-session affects next drag', async ({ page, ctx }) => { // Create a 1h time entry await createBareTimeEntryViaApi(ctx, 'Snap change test', '1h'); await goToCalendar(page); // Set snap to 15 min await openSettingsPopover(page); await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '15 min' }).click(); await page.keyboard.press('Escape'); // Scroll the calendar so the 14:00 target area is visible await scrollCalendarToTime(page, '13:00:00'); const event = page.locator('.fc-event').first(); await expect(event).toBeVisible(); // Drag event to 14:00 area const targetSlot14 = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); const targetBox14 = await targetSlot14.boundingBox(); expect(targetBox14).not.toBeNull(); const putResponsePromise1 = page.waitForResponse( (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' ); await event.hover(); await page.mouse.down(); await page.mouse.move(targetBox14!.x + targetBox14!.width / 2, targetBox14!.y + 5, { steps: 10, }); await page.mouse.up(); const putResponse1 = await putResponsePromise1; expect(putResponse1.status()).toBe(200); const body1 = await putResponse1.json(); const startDate1 = new Date(body1.data.start); expect(startDate1.getMinutes() % 15).toBe(0); // Wait for query re-fetch/re-renders to fully settle after drag await page.waitForTimeout(1500); // Change snap to 30 min // Use Escape first to ensure no stale popover is open, then re-open await page.keyboard.press('Escape'); await page.waitForTimeout(300); await openSettingsPopover(page); await page.waitForTimeout(300); await page.getByLabel('Snap Interval').click({ force: true }); await page.getByRole('option', { name: '30 min' }).click(); await page.keyboard.press('Escape'); // Scroll the calendar so the 10:00 target area is visible await scrollCalendarToTime(page, '09:00:00'); // Drag event to 10:00 area const targetSlot10 = page.locator('.fc-timegrid-slot-lane[data-time="10:00:00"]').first(); const targetBox10 = await targetSlot10.boundingBox(); expect(targetBox10).not.toBeNull(); const putResponsePromise2 = page.waitForResponse( (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' ); await event.hover(); await page.mouse.down(); await page.mouse.move(targetBox10!.x + targetBox10!.width / 2, targetBox10!.y + 5, { steps: 10, }); await page.mouse.up(); const putResponse2 = await putResponsePromise2; expect(putResponse2.status()).toBe(200); const body2 = await putResponse2.json(); const startDate2 = new Date(body2.data.start); expect(startDate2.getMinutes() % 30).toBe(0); }); test('snap with different grid scale (slot != snap)', async ({ page, ctx }) => { await goToCalendar(page); await openSettingsPopover(page); // Set grid scale to 30 min, snap to 5 min await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.keyboard.press('Escape'); // Wait for re-render with 30-min grid await expect(async () => { const slotCount = await page.locator('.fc-timegrid-slot-lane').count(); // 24 hours * 2 slots/hour = 48 slots for 30-min grid expect(slotCount).toBeLessThanOrEqual(48); }).toPass({ timeout: 5000 }); // Verify grid is 30-min (fewer slots than default 15-min) const slotCount = await page.locator('.fc-timegrid-slot-lane').count(); // Default 15-min grid has 96 slots; 30-min grid should have 48 expect(slotCount).toBeLessThanOrEqual(48); // Create a 1h time entry and go to calendar await createBareTimeEntryViaApi(ctx, 'Grid snap test', '1h'); await goToCalendar(page); // Re-apply settings since goToCalendar navigates await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '5 min', exact: true }).click(); await page.keyboard.press('Escape'); // Scroll so both the event (9:00) and target (14:00) are in viewport await scrollCalendarToTime(page, '08:00:00'); const event = page.locator('.fc-event').first(); await expect(event).toBeVisible(); // Capture target coordinates after scroll is settled const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); const targetBox = await targetSlot.boundingBox(); expect(targetBox).not.toBeNull(); const putResponsePromise = page.waitForResponse( (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' ); await event.hover(); await page.mouse.down(); await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); await page.mouse.up(); const putResponse = await putResponsePromise; expect(putResponse.status()).toBe(200); const body = await putResponse.json(); const startDate = new Date(body.data.start); // Snap is 5 min, so minutes should be divisible by 5 expect(startDate.getMinutes() % 5).toBe(0); }); }); test.describe('Calendar Settings Effects', () => { test.beforeEach(async ({ page }) => { await clearCalendarSettings(page); }); test('start/end time hides slots outside visible range', async ({ page, ctx }) => { // Create a time entry at 6 AM today const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 6, 0, 0); const end = new Date(start.getTime() + 3600 * 1000); // 7 AM await createTimeEntryWithTimestampsViaApi(ctx, { description: 'Early morning entry', start: start.toISOString().replace(/\.\d{3}Z$/, 'Z'), end: end.toISOString().replace(/\.\d{3}Z$/, 'Z'), }); await goToCalendar(page); // Verify 6 AM slot is visible with default settings await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).not.toHaveCount(0); // Set start time to 8 AM await openSettingsPopover(page); await page.getByLabel('Start Time').click(); await page.getByRole('option', { name: '8:00 AM' }).click(); await page.keyboard.press('Escape'); // 6 AM slot should be hidden await expect(page.locator('.fc-timegrid-slot[data-time="06:00:00"]')).toHaveCount(0); // 8 AM slot should be visible await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0); }); test('grid scale affects event visual height proportionally', async ({ page, ctx }) => { // Create a 1h time entry await createBareTimeEntryViaApi(ctx, 'Height test', '1h'); await goToCalendar(page); const event = page.locator('.fc-event').first(); await expect(event).toBeVisible(); await event.scrollIntoViewIfNeeded(); // Get event height with default 15-min grid scale const box15 = await event.boundingBox(); expect(box15).not.toBeNull(); const height15 = box15!.height; // Change grid scale to 60 min await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '1 hour' }).click(); await page.keyboard.press('Escape'); // Wait for re-render and scroll event into view await event.scrollIntoViewIfNeeded(); await expect(async () => { const box = await event.boundingBox(); expect(box).not.toBeNull(); expect(box!.height).not.toBe(height15); }).toPass({ timeout: 5000 }); const box60 = await event.boundingBox(); expect(box60).not.toBeNull(); const height60 = box60!.height; // Event should appear smaller with larger grid scale expect(height15).toBeGreaterThan(height60); }); test('snap interval affects drag granularity', async ({ page, ctx }) => { await goToCalendar(page); await openSettingsPopover(page); // Set snap to 30 min await page.getByLabel('Snap Interval').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.keyboard.press('Escape'); // Create a 1h time entry await createBareTimeEntryViaApi(ctx, 'Drag granularity test', '1h'); await goToCalendar(page); // Scroll the calendar so the 14:00 target area is visible await scrollCalendarToTime(page, '13:00:00'); const event = page.locator('.fc-event').first(); await expect(event).toBeVisible(); // Get target slot const targetSlot = page.locator('.fc-timegrid-slot-lane[data-time="14:00:00"]').first(); const targetBox = await targetSlot.boundingBox(); expect(targetBox).not.toBeNull(); // Drag event const putResponsePromise = page.waitForResponse( (resp) => resp.url().includes('/time-entries/') && resp.request().method() === 'PUT' ); await event.hover(); await page.mouse.down(); await page.mouse.move(targetBox!.x + targetBox!.width / 2, targetBox!.y + 5, { steps: 10 }); await page.mouse.up(); const putResponse = await putResponsePromise; expect(putResponse.status()).toBe(200); const body = await putResponse.json(); const startDate = new Date(body.data.start); const minutes = startDate.getMinutes(); // With 30-min snap, minutes should be 0 or 30 expect(minutes % 30).toBe(0); }); test('settings apply immediately without page reload', async ({ page }) => { await goToCalendar(page); // Count slots with default grid scale (15 min) const defaultSlotCount = await page.locator('.fc-timegrid-slot').count(); // Change grid scale to 30 min await openSettingsPopover(page); await page.getByLabel('Grid Scale').click(); await page.getByRole('option', { name: '30 min' }).click(); await page.keyboard.press('Escape'); // Verify slot count changed without navigation await expect(async () => { const count = await page.locator('.fc-timegrid-slot').count(); expect(count).toBeLessThan(defaultSlotCount); }).toPass({ timeout: 5000 }); // Wait for FullCalendar to fully stabilize after re-render await page.waitForTimeout(2000); await expect(page.locator('.fc')).toBeVisible(); // Change start time to 8 AM // FullCalendar re-render from grid scale change can make popover elements unstable. // Retry the open+click sequence if it fails. await expect(async () => { await page.keyboard.press('Escape'); await page.waitForTimeout(300); await page.getByRole('button', { name: 'Calendar settings' }).click(); await expect(page.getByText('Calendar Settings')).toBeVisible(); const startTimeBtn = page.getByLabel('Start Time'); await expect(startTimeBtn).toBeVisible(); await startTimeBtn.click({ timeout: 3000 }); }).toPass({ timeout: 10000 }); await page.getByRole('option', { name: '8:00 AM' }).click(); await page.keyboard.press('Escape'); // Verify 7 AM slot is hidden without reload await expect(async () => { const count = await page.locator('.fc-timegrid-slot[data-time="07:00:00"]').count(); expect(count).toBe(0); }).toPass({ timeout: 5000 }); }); });