···11+import { test, expect } from '@playwright/test';
22+import { createNewCalendar } from './helpers';
33+44+// ---------------------------------------------------------------------------
55+// Calendar - Page Load
66+// ---------------------------------------------------------------------------
77+88+test.describe('Calendar - Page Load', () => {
99+ test.beforeEach(async ({ page }) => {
1010+ await createNewCalendar(page);
1111+ });
1212+1313+ test('page loads with calendar grid visible', async ({ page }) => {
1414+ const grid = page.locator('#calendar-grid');
1515+ await expect(grid).toBeVisible();
1616+ });
1717+1818+ test('toolbar is visible', async ({ page }) => {
1919+ await expect(page.locator('#calendar-toolbar')).toBeVisible();
2020+ });
2121+2222+ test('date label shows current month and year', async ({ page }) => {
2323+ const label = page.locator('#current-label');
2424+ await expect(label).toBeVisible();
2525+ // Default view is month, so label should contain the current year
2626+ const now = new Date();
2727+ const year = now.getFullYear().toString();
2828+ await expect(label).toContainText(year);
2929+ });
3030+3131+ test('title shows Untitled Calendar', async ({ page }) => {
3232+ await expect(page.locator('#calendar-title')).toHaveValue('Untitled Calendar');
3333+ });
3434+3535+ test('view buttons are present', async ({ page }) => {
3636+ await expect(page.locator('[data-view="month"]')).toBeVisible();
3737+ await expect(page.locator('[data-view="week"]')).toBeVisible();
3838+ await expect(page.locator('[data-view="day"]')).toBeVisible();
3939+ await expect(page.locator('[data-view="agenda"]')).toBeVisible();
4040+ });
4141+4242+ test('URL matches /calendar/{id}#{key} pattern', async ({ page }) => {
4343+ const url = page.url();
4444+ expect(url).toMatch(/\/calendar\/[^/]+#.+/);
4545+ });
4646+4747+ test('month view is the default view on desktop', async ({ page }) => {
4848+ // Month view button should have active class
4949+ await expect(page.locator('[data-view="month"]')).toHaveClass(/active/);
5050+ // The month grid should be rendered
5151+ await expect(page.locator('.cal-month-grid')).toBeVisible();
5252+ });
5353+5454+ test('navigation buttons are present', async ({ page }) => {
5555+ await expect(page.locator('#btn-today')).toBeVisible();
5656+ await expect(page.locator('#btn-prev')).toBeVisible();
5757+ await expect(page.locator('#btn-next')).toBeVisible();
5858+ });
5959+});
6060+6161+// ---------------------------------------------------------------------------
6262+// Calendar - View Switching
6363+// ---------------------------------------------------------------------------
6464+6565+test.describe('Calendar - View Switching', () => {
6666+ test.beforeEach(async ({ page }) => {
6767+ await createNewCalendar(page);
6868+ });
6969+7070+ test('clicking Month button renders month grid', async ({ page }) => {
7171+ // Switch away first
7272+ await page.click('[data-view="week"]');
7373+ await expect(page.locator('.cal-week-grid')).toBeVisible();
7474+7575+ // Switch back to month
7676+ await page.click('[data-view="month"]');
7777+ await expect(page.locator('.cal-month-grid')).toBeVisible();
7878+ await expect(page.locator('[data-view="month"]')).toHaveClass(/active/);
7979+ await expect(page.locator('[data-view="week"]')).not.toHaveClass(/active/);
8080+ });
8181+8282+ test('clicking Week button renders week grid', async ({ page }) => {
8383+ await page.click('[data-view="week"]');
8484+ await expect(page.locator('.cal-week-grid')).toBeVisible();
8585+ await expect(page.locator('[data-view="week"]')).toHaveClass(/active/);
8686+ await expect(page.locator('[data-view="month"]')).not.toHaveClass(/active/);
8787+ });
8888+8989+ test('clicking Day button renders day grid', async ({ page }) => {
9090+ await page.click('[data-view="day"]');
9191+ await expect(page.locator('.cal-day-grid')).toBeVisible();
9292+ await expect(page.locator('[data-view="day"]')).toHaveClass(/active/);
9393+ await expect(page.locator('[data-view="month"]')).not.toHaveClass(/active/);
9494+ });
9595+9696+ test('clicking Agenda button renders agenda view', async ({ page }) => {
9797+ await page.click('[data-view="agenda"]');
9898+ await expect(page.locator('.cal-agenda')).toBeVisible();
9999+ await expect(page.locator('[data-view="agenda"]')).toHaveClass(/active/);
100100+ await expect(page.locator('[data-view="month"]')).not.toHaveClass(/active/);
101101+ });
102102+});
103103+104104+// ---------------------------------------------------------------------------
105105+// Calendar - Navigation
106106+// ---------------------------------------------------------------------------
107107+108108+test.describe('Calendar - Navigation', () => {
109109+ test.beforeEach(async ({ page }) => {
110110+ await createNewCalendar(page);
111111+ });
112112+113113+ test('clicking next button changes the date label', async ({ page }) => {
114114+ const initialLabel = await page.locator('#current-label').textContent();
115115+ await page.click('#btn-next');
116116+ const nextLabel = await page.locator('#current-label').textContent();
117117+ expect(nextLabel).not.toBe(initialLabel);
118118+ });
119119+120120+ test('clicking prev button changes the date label', async ({ page }) => {
121121+ const initialLabel = await page.locator('#current-label').textContent();
122122+ await page.click('#btn-prev');
123123+ const prevLabel = await page.locator('#current-label').textContent();
124124+ expect(prevLabel).not.toBe(initialLabel);
125125+ });
126126+127127+ test('clicking today button returns to current month', async ({ page }) => {
128128+ const initialLabel = await page.locator('#current-label').textContent();
129129+130130+ // Navigate away from current month
131131+ await page.click('#btn-next');
132132+ await page.click('#btn-next');
133133+ const awayLabel = await page.locator('#current-label').textContent();
134134+ expect(awayLabel).not.toBe(initialLabel);
135135+136136+ // Click today to return
137137+ await page.click('#btn-today');
138138+ const todayLabel = await page.locator('#current-label').textContent();
139139+ expect(todayLabel).toBe(initialLabel);
140140+ });
141141+142142+ test('navigating forward then backward returns to the same label', async ({ page }) => {
143143+ const initialLabel = await page.locator('#current-label').textContent();
144144+ await page.click('#btn-next');
145145+ await page.click('#btn-prev');
146146+ const returnedLabel = await page.locator('#current-label').textContent();
147147+ expect(returnedLabel).toBe(initialLabel);
148148+ });
149149+});
150150+151151+// ---------------------------------------------------------------------------
152152+// Calendar - Create Event via Month Cell Click
153153+// ---------------------------------------------------------------------------
154154+155155+test.describe('Calendar - Create Event', () => {
156156+ test.beforeEach(async ({ page }) => {
157157+ await createNewCalendar(page);
158158+ });
159159+160160+ test('clicking a day cell in month view opens the event modal', async ({ page }) => {
161161+ // Ensure month view
162162+ await expect(page.locator('.cal-month-grid')).toBeVisible();
163163+164164+ // Click the first in-month day cell
165165+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
166166+ await dayCell.click();
167167+168168+ // Modal should appear
169169+ const backdrop = page.locator('#event-modal-backdrop');
170170+ await expect(backdrop).toBeVisible();
171171+172172+ // Modal title should say "New Event"
173173+ await expect(page.locator('#event-modal-title')).toHaveText('New Event');
174174+ });
175175+176176+ test('filling in title and saving creates an event pill', async ({ page }) => {
177177+ // Click a day cell to open modal
178178+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
179179+ await dayCell.click();
180180+181181+ // Fill in the title
182182+ const titleInput = page.locator('#event-title');
183183+ await titleInput.fill('Team Standup');
184184+185185+ // Save the event
186186+ await page.click('#btn-event-save');
187187+188188+ // Modal should close
189189+ await expect(page.locator('#event-modal-backdrop')).toBeHidden();
190190+191191+ // An event pill should be visible in the grid
192192+ const pill = page.locator('.cal-event-pill');
193193+ await expect(pill).toHaveCount(1);
194194+ await expect(pill).toContainText('Team Standup');
195195+ });
196196+197197+ test('saving event with empty title creates Untitled event', async ({ page }) => {
198198+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
199199+ await dayCell.click();
200200+201201+ // Leave title blank and save
202202+ await page.click('#btn-event-save');
203203+204204+ // Should show 'Untitled' as fallback
205205+ const pill = page.locator('.cal-event-pill');
206206+ await expect(pill).toContainText('Untitled');
207207+ });
208208+209209+ test('cancel button closes modal without creating event', async ({ page }) => {
210210+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
211211+ await dayCell.click();
212212+ await expect(page.locator('#event-modal-backdrop')).toBeVisible();
213213+214214+ await page.click('#btn-event-cancel');
215215+216216+ await expect(page.locator('#event-modal-backdrop')).toBeHidden();
217217+ await expect(page.locator('.cal-event-pill')).toHaveCount(0);
218218+ });
219219+});
220220+221221+// ---------------------------------------------------------------------------
222222+// Calendar - Edit Event
223223+// ---------------------------------------------------------------------------
224224+225225+test.describe('Calendar - Edit Event', () => {
226226+ test.beforeEach(async ({ page }) => {
227227+ await createNewCalendar(page);
228228+229229+ // Create an event first
230230+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
231231+ await dayCell.click();
232232+ await page.locator('#event-title').fill('Original Title');
233233+ await page.click('#btn-event-save');
234234+ await expect(page.locator('.cal-event-pill')).toHaveCount(1);
235235+ });
236236+237237+ test('clicking an event pill opens modal with existing data', async ({ page }) => {
238238+ await page.locator('.cal-event-pill').click();
239239+240240+ // Modal should open in edit mode
241241+ await expect(page.locator('#event-modal-backdrop')).toBeVisible();
242242+ await expect(page.locator('#event-modal-title')).toHaveText('Edit Event');
243243+ await expect(page.locator('#event-title')).toHaveValue('Original Title');
244244+ });
245245+246246+ test('editing event title and saving updates the pill text', async ({ page }) => {
247247+ await page.locator('.cal-event-pill').click();
248248+249249+ // Change the title
250250+ await page.locator('#event-title').fill('Updated Title');
251251+ await page.click('#btn-event-save');
252252+253253+ // Modal closes and pill text is updated
254254+ await expect(page.locator('#event-modal-backdrop')).toBeHidden();
255255+ await expect(page.locator('.cal-event-pill')).toContainText('Updated Title');
256256+ });
257257+258258+ test('delete button is visible when editing an existing event', async ({ page }) => {
259259+ await page.locator('.cal-event-pill').click();
260260+ await expect(page.locator('#btn-event-delete')).toBeVisible();
261261+ });
262262+263263+ test('delete button is hidden when creating a new event', async ({ page }) => {
264264+ // Open a new event modal by clicking a different cell
265265+ const cells = page.locator('.cal-day-cell:not(.cal-day-other-month)');
266266+ await cells.nth(1).click();
267267+268268+ await expect(page.locator('#btn-event-delete')).toBeHidden();
269269+ });
270270+});
271271+272272+// ---------------------------------------------------------------------------
273273+// Calendar - Delete Event
274274+// ---------------------------------------------------------------------------
275275+276276+test.describe('Calendar - Delete Event', () => {
277277+ test.beforeEach(async ({ page }) => {
278278+ await createNewCalendar(page);
279279+280280+ // Create an event
281281+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
282282+ await dayCell.click();
283283+ await page.locator('#event-title').fill('Deletable Event');
284284+ await page.click('#btn-event-save');
285285+ await expect(page.locator('.cal-event-pill')).toHaveCount(1);
286286+ });
287287+288288+ test('clicking delete in modal removes the event', async ({ page }) => {
289289+ // Open the event for editing
290290+ await page.locator('.cal-event-pill').click();
291291+ await expect(page.locator('#event-modal-backdrop')).toBeVisible();
292292+293293+ // Handle the confirm dialog by auto-accepting
294294+ page.on('dialog', dialog => dialog.accept());
295295+296296+ // Click delete
297297+ await page.click('#btn-event-delete');
298298+299299+ // Modal should close and event should be gone
300300+ await expect(page.locator('#event-modal-backdrop')).toBeHidden();
301301+ await expect(page.locator('.cal-event-pill')).toHaveCount(0);
302302+ });
303303+304304+ test('dismissing delete confirmation keeps the event', async ({ page }) => {
305305+ await page.locator('.cal-event-pill').click();
306306+307307+ // Dismiss the confirm dialog
308308+ page.on('dialog', dialog => dialog.dismiss());
309309+310310+ await page.click('#btn-event-delete');
311311+312312+ // Modal should still be open, event should remain
313313+ // Close modal manually
314314+ await page.click('#btn-event-cancel');
315315+ await expect(page.locator('.cal-event-pill')).toHaveCount(1);
316316+ });
317317+});
318318+319319+// ---------------------------------------------------------------------------
320320+// Calendar - Keyboard Shortcuts
321321+// ---------------------------------------------------------------------------
322322+323323+test.describe('Calendar - Keyboard Shortcuts', () => {
324324+ test.beforeEach(async ({ page }) => {
325325+ await createNewCalendar(page);
326326+ });
327327+328328+ test('pressing m switches to month view', async ({ page }) => {
329329+ // Switch away first
330330+ await page.click('[data-view="week"]');
331331+ await expect(page.locator('.cal-week-grid')).toBeVisible();
332332+333333+ // Press m
334334+ await page.keyboard.press('m');
335335+ await expect(page.locator('.cal-month-grid')).toBeVisible();
336336+ await expect(page.locator('[data-view="month"]')).toHaveClass(/active/);
337337+ });
338338+339339+ test('pressing w switches to week view', async ({ page }) => {
340340+ await page.keyboard.press('w');
341341+ await expect(page.locator('.cal-week-grid')).toBeVisible();
342342+ await expect(page.locator('[data-view="week"]')).toHaveClass(/active/);
343343+ });
344344+345345+ test('pressing d switches to day view', async ({ page }) => {
346346+ await page.keyboard.press('d');
347347+ await expect(page.locator('.cal-day-grid')).toBeVisible();
348348+ await expect(page.locator('[data-view="day"]')).toHaveClass(/active/);
349349+ });
350350+351351+ test('pressing a switches to agenda view', async ({ page }) => {
352352+ await page.keyboard.press('a');
353353+ await expect(page.locator('.cal-agenda')).toBeVisible();
354354+ await expect(page.locator('[data-view="agenda"]')).toHaveClass(/active/);
355355+ });
356356+357357+ test('pressing t navigates to today', async ({ page }) => {
358358+ // Navigate away
359359+ await page.click('#btn-next');
360360+ await page.click('#btn-next');
361361+ const awayLabel = await page.locator('#current-label').textContent();
362362+363363+ // Press t
364364+ await page.keyboard.press('t');
365365+ const todayLabel = await page.locator('#current-label').textContent();
366366+367367+ // Should be back at the current month (which likely differs from the forward-navigated label)
368368+ const now = new Date();
369369+ expect(todayLabel).toContain(now.getFullYear().toString());
370370+ });
371371+372372+ test('pressing n opens the new event modal', async ({ page }) => {
373373+ await page.keyboard.press('n');
374374+ await expect(page.locator('#event-modal-backdrop')).toBeVisible();
375375+ await expect(page.locator('#event-modal-title')).toHaveText('New Event');
376376+ });
377377+378378+ test('pressing Escape closes the modal', async ({ page }) => {
379379+ // Open modal first
380380+ await page.keyboard.press('n');
381381+ await expect(page.locator('#event-modal-backdrop')).toBeVisible();
382382+383383+ // Press Escape
384384+ await page.keyboard.press('Escape');
385385+ await expect(page.locator('#event-modal-backdrop')).toBeHidden();
386386+ });
387387+388388+ test('pressing ArrowLeft navigates to previous period', async ({ page }) => {
389389+ const initialLabel = await page.locator('#current-label').textContent();
390390+ await page.keyboard.press('ArrowLeft');
391391+ const prevLabel = await page.locator('#current-label').textContent();
392392+ expect(prevLabel).not.toBe(initialLabel);
393393+ });
394394+395395+ test('pressing ArrowRight navigates to next period', async ({ page }) => {
396396+ const initialLabel = await page.locator('#current-label').textContent();
397397+ await page.keyboard.press('ArrowRight');
398398+ const nextLabel = await page.locator('#current-label').textContent();
399399+ expect(nextLabel).not.toBe(initialLabel);
400400+ });
401401+402402+ test('shortcuts are ignored when typing in title input', async ({ page }) => {
403403+ const titleInput = page.locator('#calendar-title');
404404+ await titleInput.click();
405405+ await titleInput.fill('');
406406+407407+ // Type 'm' -- should go into the input, not switch view
408408+ await page.keyboard.type('m');
409409+ await expect(page.locator('[data-view="month"]')).toHaveClass(/active/);
410410+ // The month grid should still be showing (not changed from typing)
411411+ await expect(page.locator('.cal-month-grid')).toBeVisible();
412412+ });
413413+});
414414+415415+// ---------------------------------------------------------------------------
416416+// Calendar - Color Swatches
417417+// ---------------------------------------------------------------------------
418418+419419+test.describe('Calendar - Color Swatches', () => {
420420+ test.beforeEach(async ({ page }) => {
421421+ await createNewCalendar(page);
422422+ });
423423+424424+ test('color swatches are visible in the event modal', async ({ page }) => {
425425+ // Open modal
426426+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
427427+ await dayCell.click();
428428+429429+ const swatches = page.locator('.event-color-swatch');
430430+ await expect(swatches).toHaveCount(6);
431431+ });
432432+433433+ test('first swatch is active by default', async ({ page }) => {
434434+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
435435+ await dayCell.click();
436436+437437+ const firstSwatch = page.locator('.event-color-swatch').first();
438438+ await expect(firstSwatch).toHaveClass(/active/);
439439+ });
440440+441441+ test('clicking a different swatch moves the active state', async ({ page }) => {
442442+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
443443+ await dayCell.click();
444444+445445+ const swatches = page.locator('.event-color-swatch');
446446+ const firstSwatch = swatches.nth(0);
447447+ const thirdSwatch = swatches.nth(2);
448448+449449+ // Initially first is active
450450+ await expect(firstSwatch).toHaveClass(/active/);
451451+ await expect(thirdSwatch).not.toHaveClass(/active/);
452452+453453+ // Click third swatch
454454+ await thirdSwatch.click();
455455+456456+ // Third should now be active, first should not
457457+ await expect(thirdSwatch).toHaveClass(/active/);
458458+ await expect(firstSwatch).not.toHaveClass(/active/);
459459+ });
460460+461461+ test('selected color is applied to the created event pill', async ({ page }) => {
462462+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
463463+ await dayCell.click();
464464+465465+ // Select the blue swatch (4th, 0-indexed 3)
466466+ const blueSwatch = page.locator('.event-color-swatch').nth(3);
467467+ await blueSwatch.click();
468468+ const blueColor = await blueSwatch.getAttribute('data-color');
469469+470470+ await page.locator('#event-title').fill('Blue Event');
471471+ await page.click('#btn-event-save');
472472+473473+ // The pill should use the selected color via CSS custom property
474474+ const pill = page.locator('.cal-event-pill');
475475+ const pillStyle = await pill.getAttribute('style');
476476+ expect(pillStyle).toContain(blueColor!);
477477+ });
478478+});
479479+480480+// ---------------------------------------------------------------------------
481481+// Calendar - All-Day Toggle
482482+// ---------------------------------------------------------------------------
483483+484484+test.describe('Calendar - All-Day Toggle', () => {
485485+ test.beforeEach(async ({ page }) => {
486486+ await createNewCalendar(page);
487487+ });
488488+489489+ test('time fields are visible by default (not all-day)', async ({ page }) => {
490490+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
491491+ await dayCell.click();
492492+493493+ // Time inputs should be visible
494494+ await expect(page.locator('#event-start-time')).toBeVisible();
495495+ await expect(page.locator('#event-end-time')).toBeVisible();
496496+ // All-day checkbox should be unchecked
497497+ await expect(page.locator('#event-all-day')).not.toBeChecked();
498498+ });
499499+500500+ test('checking all-day hides time fields', async ({ page }) => {
501501+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
502502+ await dayCell.click();
503503+504504+ // Check the all-day checkbox
505505+ await page.locator('#event-all-day').check();
506506+507507+ // Time field containers should be hidden
508508+ // The parent .event-modal-field of the time inputs is hidden
509509+ const startTimeParent = page.locator('#event-start-time').locator('..');
510510+ const endTimeParent = page.locator('#event-end-time').locator('..');
511511+ await expect(startTimeParent).toBeHidden();
512512+ await expect(endTimeParent).toBeHidden();
513513+ });
514514+515515+ test('unchecking all-day shows time fields again', async ({ page }) => {
516516+ const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first();
517517+ await dayCell.click();
518518+519519+ // Check then uncheck
520520+ await page.locator('#event-all-day').check();
521521+ await page.locator('#event-all-day').uncheck();
522522+523523+ // Time inputs should be visible again
524524+ await expect(page.locator('#event-start-time')).toBeVisible();
525525+ await expect(page.locator('#event-end-time')).toBeVisible();
526526+ });
527527+});
528528+529529+// ---------------------------------------------------------------------------
530530+// Calendar - Theme Toggle
531531+// ---------------------------------------------------------------------------
532532+533533+test.describe('Calendar - Theme Toggle', () => {
534534+ test.beforeEach(async ({ page }) => {
535535+ await createNewCalendar(page);
536536+ });
537537+538538+ test('theme toggle button exists', async ({ page }) => {
539539+ await expect(page.locator('#btn-theme-toggle')).toBeVisible();
540540+ });
541541+542542+ test('clicking theme toggle changes data-theme attribute', async ({ page }) => {
543543+ const html = page.locator('html');
544544+ const initialTheme = await html.getAttribute('data-theme');
545545+546546+ await page.click('#btn-theme-toggle');
547547+548548+ const newTheme = await html.getAttribute('data-theme');
549549+ expect(newTheme).not.toBe(initialTheme);
550550+551551+ // Should be either 'light' or 'dark'
552552+ expect(['light', 'dark']).toContain(newTheme);
553553+ });
554554+555555+ test('clicking theme toggle twice returns to original theme', async ({ page }) => {
556556+ const html = page.locator('html');
557557+ const initialTheme = await html.getAttribute('data-theme');
558558+559559+ await page.click('#btn-theme-toggle');
560560+ await page.click('#btn-theme-toggle');
561561+562562+ const restoredTheme = await html.getAttribute('data-theme');
563563+ expect(restoredTheme).toBe(initialTheme);
564564+ });
565565+});
+12
e2e/helpers.ts
···9898}
9999100100/**
101101+ * Create a new calendar via the landing page and wait for the grid to load.
102102+ * Returns the full URL of the new calendar.
103103+ */
104104+export async function createNewCalendar(page: Page): Promise<string> {
105105+ await goToLanding(page);
106106+ await page.click('#new-calendar');
107107+ await page.waitForURL(/\/calendar\//, { timeout: 30000 });
108108+ await page.waitForSelector('#calendar-grid', { timeout: 15000 });
109109+ return page.url();
110110+}
111111+112112+/**
101113 * Click a cell in the spreadsheet by its cell ID (e.g. "A1", "B3").
102114 */
103115export async function clickCell(page: Page, cellId: string): Promise<void> {