Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(calendar): scope conflicting CSS, add 40 unit + 38 e2e tests

Visual fixes:
- Scope old mini-calendar `.calendar-grid` styles under `.calendar-view`
to prevent them from breaking the new calendar app's layout
- Add `.calendar-app .calendar-grid` container sizing (flex: 1) so the
calendar fills available space below the toolbar
- Add `.cal-week-day-name` styles for week view header labels

Tests:
- 40 new unit tests for calendar helpers (edge cases: time display,
month boundaries, year boundaries, leap years, event sorting)
- 38 new E2E tests covering: page load, view switching, navigation,
event CRUD, keyboard shortcuts, color swatches, all-day toggle,
theme toggle
- Add `createNewCalendar` helper to e2e/helpers.ts

Closes #482

+832 -9
+565
e2e/calendar.spec.ts
··· 1 + import { test, expect } from '@playwright/test'; 2 + import { createNewCalendar } from './helpers'; 3 + 4 + // --------------------------------------------------------------------------- 5 + // Calendar - Page Load 6 + // --------------------------------------------------------------------------- 7 + 8 + test.describe('Calendar - Page Load', () => { 9 + test.beforeEach(async ({ page }) => { 10 + await createNewCalendar(page); 11 + }); 12 + 13 + test('page loads with calendar grid visible', async ({ page }) => { 14 + const grid = page.locator('#calendar-grid'); 15 + await expect(grid).toBeVisible(); 16 + }); 17 + 18 + test('toolbar is visible', async ({ page }) => { 19 + await expect(page.locator('#calendar-toolbar')).toBeVisible(); 20 + }); 21 + 22 + test('date label shows current month and year', async ({ page }) => { 23 + const label = page.locator('#current-label'); 24 + await expect(label).toBeVisible(); 25 + // Default view is month, so label should contain the current year 26 + const now = new Date(); 27 + const year = now.getFullYear().toString(); 28 + await expect(label).toContainText(year); 29 + }); 30 + 31 + test('title shows Untitled Calendar', async ({ page }) => { 32 + await expect(page.locator('#calendar-title')).toHaveValue('Untitled Calendar'); 33 + }); 34 + 35 + test('view buttons are present', async ({ page }) => { 36 + await expect(page.locator('[data-view="month"]')).toBeVisible(); 37 + await expect(page.locator('[data-view="week"]')).toBeVisible(); 38 + await expect(page.locator('[data-view="day"]')).toBeVisible(); 39 + await expect(page.locator('[data-view="agenda"]')).toBeVisible(); 40 + }); 41 + 42 + test('URL matches /calendar/{id}#{key} pattern', async ({ page }) => { 43 + const url = page.url(); 44 + expect(url).toMatch(/\/calendar\/[^/]+#.+/); 45 + }); 46 + 47 + test('month view is the default view on desktop', async ({ page }) => { 48 + // Month view button should have active class 49 + await expect(page.locator('[data-view="month"]')).toHaveClass(/active/); 50 + // The month grid should be rendered 51 + await expect(page.locator('.cal-month-grid')).toBeVisible(); 52 + }); 53 + 54 + test('navigation buttons are present', async ({ page }) => { 55 + await expect(page.locator('#btn-today')).toBeVisible(); 56 + await expect(page.locator('#btn-prev')).toBeVisible(); 57 + await expect(page.locator('#btn-next')).toBeVisible(); 58 + }); 59 + }); 60 + 61 + // --------------------------------------------------------------------------- 62 + // Calendar - View Switching 63 + // --------------------------------------------------------------------------- 64 + 65 + test.describe('Calendar - View Switching', () => { 66 + test.beforeEach(async ({ page }) => { 67 + await createNewCalendar(page); 68 + }); 69 + 70 + test('clicking Month button renders month grid', async ({ page }) => { 71 + // Switch away first 72 + await page.click('[data-view="week"]'); 73 + await expect(page.locator('.cal-week-grid')).toBeVisible(); 74 + 75 + // Switch back to month 76 + await page.click('[data-view="month"]'); 77 + await expect(page.locator('.cal-month-grid')).toBeVisible(); 78 + await expect(page.locator('[data-view="month"]')).toHaveClass(/active/); 79 + await expect(page.locator('[data-view="week"]')).not.toHaveClass(/active/); 80 + }); 81 + 82 + test('clicking Week button renders week grid', async ({ page }) => { 83 + await page.click('[data-view="week"]'); 84 + await expect(page.locator('.cal-week-grid')).toBeVisible(); 85 + await expect(page.locator('[data-view="week"]')).toHaveClass(/active/); 86 + await expect(page.locator('[data-view="month"]')).not.toHaveClass(/active/); 87 + }); 88 + 89 + test('clicking Day button renders day grid', async ({ page }) => { 90 + await page.click('[data-view="day"]'); 91 + await expect(page.locator('.cal-day-grid')).toBeVisible(); 92 + await expect(page.locator('[data-view="day"]')).toHaveClass(/active/); 93 + await expect(page.locator('[data-view="month"]')).not.toHaveClass(/active/); 94 + }); 95 + 96 + test('clicking Agenda button renders agenda view', async ({ page }) => { 97 + await page.click('[data-view="agenda"]'); 98 + await expect(page.locator('.cal-agenda')).toBeVisible(); 99 + await expect(page.locator('[data-view="agenda"]')).toHaveClass(/active/); 100 + await expect(page.locator('[data-view="month"]')).not.toHaveClass(/active/); 101 + }); 102 + }); 103 + 104 + // --------------------------------------------------------------------------- 105 + // Calendar - Navigation 106 + // --------------------------------------------------------------------------- 107 + 108 + test.describe('Calendar - Navigation', () => { 109 + test.beforeEach(async ({ page }) => { 110 + await createNewCalendar(page); 111 + }); 112 + 113 + test('clicking next button changes the date label', async ({ page }) => { 114 + const initialLabel = await page.locator('#current-label').textContent(); 115 + await page.click('#btn-next'); 116 + const nextLabel = await page.locator('#current-label').textContent(); 117 + expect(nextLabel).not.toBe(initialLabel); 118 + }); 119 + 120 + test('clicking prev button changes the date label', async ({ page }) => { 121 + const initialLabel = await page.locator('#current-label').textContent(); 122 + await page.click('#btn-prev'); 123 + const prevLabel = await page.locator('#current-label').textContent(); 124 + expect(prevLabel).not.toBe(initialLabel); 125 + }); 126 + 127 + test('clicking today button returns to current month', async ({ page }) => { 128 + const initialLabel = await page.locator('#current-label').textContent(); 129 + 130 + // Navigate away from current month 131 + await page.click('#btn-next'); 132 + await page.click('#btn-next'); 133 + const awayLabel = await page.locator('#current-label').textContent(); 134 + expect(awayLabel).not.toBe(initialLabel); 135 + 136 + // Click today to return 137 + await page.click('#btn-today'); 138 + const todayLabel = await page.locator('#current-label').textContent(); 139 + expect(todayLabel).toBe(initialLabel); 140 + }); 141 + 142 + test('navigating forward then backward returns to the same label', async ({ page }) => { 143 + const initialLabel = await page.locator('#current-label').textContent(); 144 + await page.click('#btn-next'); 145 + await page.click('#btn-prev'); 146 + const returnedLabel = await page.locator('#current-label').textContent(); 147 + expect(returnedLabel).toBe(initialLabel); 148 + }); 149 + }); 150 + 151 + // --------------------------------------------------------------------------- 152 + // Calendar - Create Event via Month Cell Click 153 + // --------------------------------------------------------------------------- 154 + 155 + test.describe('Calendar - Create Event', () => { 156 + test.beforeEach(async ({ page }) => { 157 + await createNewCalendar(page); 158 + }); 159 + 160 + test('clicking a day cell in month view opens the event modal', async ({ page }) => { 161 + // Ensure month view 162 + await expect(page.locator('.cal-month-grid')).toBeVisible(); 163 + 164 + // Click the first in-month day cell 165 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 166 + await dayCell.click(); 167 + 168 + // Modal should appear 169 + const backdrop = page.locator('#event-modal-backdrop'); 170 + await expect(backdrop).toBeVisible(); 171 + 172 + // Modal title should say "New Event" 173 + await expect(page.locator('#event-modal-title')).toHaveText('New Event'); 174 + }); 175 + 176 + test('filling in title and saving creates an event pill', async ({ page }) => { 177 + // Click a day cell to open modal 178 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 179 + await dayCell.click(); 180 + 181 + // Fill in the title 182 + const titleInput = page.locator('#event-title'); 183 + await titleInput.fill('Team Standup'); 184 + 185 + // Save the event 186 + await page.click('#btn-event-save'); 187 + 188 + // Modal should close 189 + await expect(page.locator('#event-modal-backdrop')).toBeHidden(); 190 + 191 + // An event pill should be visible in the grid 192 + const pill = page.locator('.cal-event-pill'); 193 + await expect(pill).toHaveCount(1); 194 + await expect(pill).toContainText('Team Standup'); 195 + }); 196 + 197 + test('saving event with empty title creates Untitled event', async ({ page }) => { 198 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 199 + await dayCell.click(); 200 + 201 + // Leave title blank and save 202 + await page.click('#btn-event-save'); 203 + 204 + // Should show 'Untitled' as fallback 205 + const pill = page.locator('.cal-event-pill'); 206 + await expect(pill).toContainText('Untitled'); 207 + }); 208 + 209 + test('cancel button closes modal without creating event', async ({ page }) => { 210 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 211 + await dayCell.click(); 212 + await expect(page.locator('#event-modal-backdrop')).toBeVisible(); 213 + 214 + await page.click('#btn-event-cancel'); 215 + 216 + await expect(page.locator('#event-modal-backdrop')).toBeHidden(); 217 + await expect(page.locator('.cal-event-pill')).toHaveCount(0); 218 + }); 219 + }); 220 + 221 + // --------------------------------------------------------------------------- 222 + // Calendar - Edit Event 223 + // --------------------------------------------------------------------------- 224 + 225 + test.describe('Calendar - Edit Event', () => { 226 + test.beforeEach(async ({ page }) => { 227 + await createNewCalendar(page); 228 + 229 + // Create an event first 230 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 231 + await dayCell.click(); 232 + await page.locator('#event-title').fill('Original Title'); 233 + await page.click('#btn-event-save'); 234 + await expect(page.locator('.cal-event-pill')).toHaveCount(1); 235 + }); 236 + 237 + test('clicking an event pill opens modal with existing data', async ({ page }) => { 238 + await page.locator('.cal-event-pill').click(); 239 + 240 + // Modal should open in edit mode 241 + await expect(page.locator('#event-modal-backdrop')).toBeVisible(); 242 + await expect(page.locator('#event-modal-title')).toHaveText('Edit Event'); 243 + await expect(page.locator('#event-title')).toHaveValue('Original Title'); 244 + }); 245 + 246 + test('editing event title and saving updates the pill text', async ({ page }) => { 247 + await page.locator('.cal-event-pill').click(); 248 + 249 + // Change the title 250 + await page.locator('#event-title').fill('Updated Title'); 251 + await page.click('#btn-event-save'); 252 + 253 + // Modal closes and pill text is updated 254 + await expect(page.locator('#event-modal-backdrop')).toBeHidden(); 255 + await expect(page.locator('.cal-event-pill')).toContainText('Updated Title'); 256 + }); 257 + 258 + test('delete button is visible when editing an existing event', async ({ page }) => { 259 + await page.locator('.cal-event-pill').click(); 260 + await expect(page.locator('#btn-event-delete')).toBeVisible(); 261 + }); 262 + 263 + test('delete button is hidden when creating a new event', async ({ page }) => { 264 + // Open a new event modal by clicking a different cell 265 + const cells = page.locator('.cal-day-cell:not(.cal-day-other-month)'); 266 + await cells.nth(1).click(); 267 + 268 + await expect(page.locator('#btn-event-delete')).toBeHidden(); 269 + }); 270 + }); 271 + 272 + // --------------------------------------------------------------------------- 273 + // Calendar - Delete Event 274 + // --------------------------------------------------------------------------- 275 + 276 + test.describe('Calendar - Delete Event', () => { 277 + test.beforeEach(async ({ page }) => { 278 + await createNewCalendar(page); 279 + 280 + // Create an event 281 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 282 + await dayCell.click(); 283 + await page.locator('#event-title').fill('Deletable Event'); 284 + await page.click('#btn-event-save'); 285 + await expect(page.locator('.cal-event-pill')).toHaveCount(1); 286 + }); 287 + 288 + test('clicking delete in modal removes the event', async ({ page }) => { 289 + // Open the event for editing 290 + await page.locator('.cal-event-pill').click(); 291 + await expect(page.locator('#event-modal-backdrop')).toBeVisible(); 292 + 293 + // Handle the confirm dialog by auto-accepting 294 + page.on('dialog', dialog => dialog.accept()); 295 + 296 + // Click delete 297 + await page.click('#btn-event-delete'); 298 + 299 + // Modal should close and event should be gone 300 + await expect(page.locator('#event-modal-backdrop')).toBeHidden(); 301 + await expect(page.locator('.cal-event-pill')).toHaveCount(0); 302 + }); 303 + 304 + test('dismissing delete confirmation keeps the event', async ({ page }) => { 305 + await page.locator('.cal-event-pill').click(); 306 + 307 + // Dismiss the confirm dialog 308 + page.on('dialog', dialog => dialog.dismiss()); 309 + 310 + await page.click('#btn-event-delete'); 311 + 312 + // Modal should still be open, event should remain 313 + // Close modal manually 314 + await page.click('#btn-event-cancel'); 315 + await expect(page.locator('.cal-event-pill')).toHaveCount(1); 316 + }); 317 + }); 318 + 319 + // --------------------------------------------------------------------------- 320 + // Calendar - Keyboard Shortcuts 321 + // --------------------------------------------------------------------------- 322 + 323 + test.describe('Calendar - Keyboard Shortcuts', () => { 324 + test.beforeEach(async ({ page }) => { 325 + await createNewCalendar(page); 326 + }); 327 + 328 + test('pressing m switches to month view', async ({ page }) => { 329 + // Switch away first 330 + await page.click('[data-view="week"]'); 331 + await expect(page.locator('.cal-week-grid')).toBeVisible(); 332 + 333 + // Press m 334 + await page.keyboard.press('m'); 335 + await expect(page.locator('.cal-month-grid')).toBeVisible(); 336 + await expect(page.locator('[data-view="month"]')).toHaveClass(/active/); 337 + }); 338 + 339 + test('pressing w switches to week view', async ({ page }) => { 340 + await page.keyboard.press('w'); 341 + await expect(page.locator('.cal-week-grid')).toBeVisible(); 342 + await expect(page.locator('[data-view="week"]')).toHaveClass(/active/); 343 + }); 344 + 345 + test('pressing d switches to day view', async ({ page }) => { 346 + await page.keyboard.press('d'); 347 + await expect(page.locator('.cal-day-grid')).toBeVisible(); 348 + await expect(page.locator('[data-view="day"]')).toHaveClass(/active/); 349 + }); 350 + 351 + test('pressing a switches to agenda view', async ({ page }) => { 352 + await page.keyboard.press('a'); 353 + await expect(page.locator('.cal-agenda')).toBeVisible(); 354 + await expect(page.locator('[data-view="agenda"]')).toHaveClass(/active/); 355 + }); 356 + 357 + test('pressing t navigates to today', async ({ page }) => { 358 + // Navigate away 359 + await page.click('#btn-next'); 360 + await page.click('#btn-next'); 361 + const awayLabel = await page.locator('#current-label').textContent(); 362 + 363 + // Press t 364 + await page.keyboard.press('t'); 365 + const todayLabel = await page.locator('#current-label').textContent(); 366 + 367 + // Should be back at the current month (which likely differs from the forward-navigated label) 368 + const now = new Date(); 369 + expect(todayLabel).toContain(now.getFullYear().toString()); 370 + }); 371 + 372 + test('pressing n opens the new event modal', async ({ page }) => { 373 + await page.keyboard.press('n'); 374 + await expect(page.locator('#event-modal-backdrop')).toBeVisible(); 375 + await expect(page.locator('#event-modal-title')).toHaveText('New Event'); 376 + }); 377 + 378 + test('pressing Escape closes the modal', async ({ page }) => { 379 + // Open modal first 380 + await page.keyboard.press('n'); 381 + await expect(page.locator('#event-modal-backdrop')).toBeVisible(); 382 + 383 + // Press Escape 384 + await page.keyboard.press('Escape'); 385 + await expect(page.locator('#event-modal-backdrop')).toBeHidden(); 386 + }); 387 + 388 + test('pressing ArrowLeft navigates to previous period', async ({ page }) => { 389 + const initialLabel = await page.locator('#current-label').textContent(); 390 + await page.keyboard.press('ArrowLeft'); 391 + const prevLabel = await page.locator('#current-label').textContent(); 392 + expect(prevLabel).not.toBe(initialLabel); 393 + }); 394 + 395 + test('pressing ArrowRight navigates to next period', async ({ page }) => { 396 + const initialLabel = await page.locator('#current-label').textContent(); 397 + await page.keyboard.press('ArrowRight'); 398 + const nextLabel = await page.locator('#current-label').textContent(); 399 + expect(nextLabel).not.toBe(initialLabel); 400 + }); 401 + 402 + test('shortcuts are ignored when typing in title input', async ({ page }) => { 403 + const titleInput = page.locator('#calendar-title'); 404 + await titleInput.click(); 405 + await titleInput.fill(''); 406 + 407 + // Type 'm' -- should go into the input, not switch view 408 + await page.keyboard.type('m'); 409 + await expect(page.locator('[data-view="month"]')).toHaveClass(/active/); 410 + // The month grid should still be showing (not changed from typing) 411 + await expect(page.locator('.cal-month-grid')).toBeVisible(); 412 + }); 413 + }); 414 + 415 + // --------------------------------------------------------------------------- 416 + // Calendar - Color Swatches 417 + // --------------------------------------------------------------------------- 418 + 419 + test.describe('Calendar - Color Swatches', () => { 420 + test.beforeEach(async ({ page }) => { 421 + await createNewCalendar(page); 422 + }); 423 + 424 + test('color swatches are visible in the event modal', async ({ page }) => { 425 + // Open modal 426 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 427 + await dayCell.click(); 428 + 429 + const swatches = page.locator('.event-color-swatch'); 430 + await expect(swatches).toHaveCount(6); 431 + }); 432 + 433 + test('first swatch is active by default', async ({ page }) => { 434 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 435 + await dayCell.click(); 436 + 437 + const firstSwatch = page.locator('.event-color-swatch').first(); 438 + await expect(firstSwatch).toHaveClass(/active/); 439 + }); 440 + 441 + test('clicking a different swatch moves the active state', async ({ page }) => { 442 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 443 + await dayCell.click(); 444 + 445 + const swatches = page.locator('.event-color-swatch'); 446 + const firstSwatch = swatches.nth(0); 447 + const thirdSwatch = swatches.nth(2); 448 + 449 + // Initially first is active 450 + await expect(firstSwatch).toHaveClass(/active/); 451 + await expect(thirdSwatch).not.toHaveClass(/active/); 452 + 453 + // Click third swatch 454 + await thirdSwatch.click(); 455 + 456 + // Third should now be active, first should not 457 + await expect(thirdSwatch).toHaveClass(/active/); 458 + await expect(firstSwatch).not.toHaveClass(/active/); 459 + }); 460 + 461 + test('selected color is applied to the created event pill', async ({ page }) => { 462 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 463 + await dayCell.click(); 464 + 465 + // Select the blue swatch (4th, 0-indexed 3) 466 + const blueSwatch = page.locator('.event-color-swatch').nth(3); 467 + await blueSwatch.click(); 468 + const blueColor = await blueSwatch.getAttribute('data-color'); 469 + 470 + await page.locator('#event-title').fill('Blue Event'); 471 + await page.click('#btn-event-save'); 472 + 473 + // The pill should use the selected color via CSS custom property 474 + const pill = page.locator('.cal-event-pill'); 475 + const pillStyle = await pill.getAttribute('style'); 476 + expect(pillStyle).toContain(blueColor!); 477 + }); 478 + }); 479 + 480 + // --------------------------------------------------------------------------- 481 + // Calendar - All-Day Toggle 482 + // --------------------------------------------------------------------------- 483 + 484 + test.describe('Calendar - All-Day Toggle', () => { 485 + test.beforeEach(async ({ page }) => { 486 + await createNewCalendar(page); 487 + }); 488 + 489 + test('time fields are visible by default (not all-day)', async ({ page }) => { 490 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 491 + await dayCell.click(); 492 + 493 + // Time inputs should be visible 494 + await expect(page.locator('#event-start-time')).toBeVisible(); 495 + await expect(page.locator('#event-end-time')).toBeVisible(); 496 + // All-day checkbox should be unchecked 497 + await expect(page.locator('#event-all-day')).not.toBeChecked(); 498 + }); 499 + 500 + test('checking all-day hides time fields', async ({ page }) => { 501 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 502 + await dayCell.click(); 503 + 504 + // Check the all-day checkbox 505 + await page.locator('#event-all-day').check(); 506 + 507 + // Time field containers should be hidden 508 + // The parent .event-modal-field of the time inputs is hidden 509 + const startTimeParent = page.locator('#event-start-time').locator('..'); 510 + const endTimeParent = page.locator('#event-end-time').locator('..'); 511 + await expect(startTimeParent).toBeHidden(); 512 + await expect(endTimeParent).toBeHidden(); 513 + }); 514 + 515 + test('unchecking all-day shows time fields again', async ({ page }) => { 516 + const dayCell = page.locator('.cal-day-cell:not(.cal-day-other-month)').first(); 517 + await dayCell.click(); 518 + 519 + // Check then uncheck 520 + await page.locator('#event-all-day').check(); 521 + await page.locator('#event-all-day').uncheck(); 522 + 523 + // Time inputs should be visible again 524 + await expect(page.locator('#event-start-time')).toBeVisible(); 525 + await expect(page.locator('#event-end-time')).toBeVisible(); 526 + }); 527 + }); 528 + 529 + // --------------------------------------------------------------------------- 530 + // Calendar - Theme Toggle 531 + // --------------------------------------------------------------------------- 532 + 533 + test.describe('Calendar - Theme Toggle', () => { 534 + test.beforeEach(async ({ page }) => { 535 + await createNewCalendar(page); 536 + }); 537 + 538 + test('theme toggle button exists', async ({ page }) => { 539 + await expect(page.locator('#btn-theme-toggle')).toBeVisible(); 540 + }); 541 + 542 + test('clicking theme toggle changes data-theme attribute', async ({ page }) => { 543 + const html = page.locator('html'); 544 + const initialTheme = await html.getAttribute('data-theme'); 545 + 546 + await page.click('#btn-theme-toggle'); 547 + 548 + const newTheme = await html.getAttribute('data-theme'); 549 + expect(newTheme).not.toBe(initialTheme); 550 + 551 + // Should be either 'light' or 'dark' 552 + expect(['light', 'dark']).toContain(newTheme); 553 + }); 554 + 555 + test('clicking theme toggle twice returns to original theme', async ({ page }) => { 556 + const html = page.locator('html'); 557 + const initialTheme = await html.getAttribute('data-theme'); 558 + 559 + await page.click('#btn-theme-toggle'); 560 + await page.click('#btn-theme-toggle'); 561 + 562 + const restoredTheme = await html.getAttribute('data-theme'); 563 + expect(restoredTheme).toBe(initialTheme); 564 + }); 565 + });
+12
e2e/helpers.ts
··· 98 98 } 99 99 100 100 /** 101 + * Create a new calendar via the landing page and wait for the grid to load. 102 + * Returns the full URL of the new calendar. 103 + */ 104 + export async function createNewCalendar(page: Page): Promise<string> { 105 + await goToLanding(page); 106 + await page.click('#new-calendar'); 107 + await page.waitForURL(/\/calendar\//, { timeout: 30000 }); 108 + await page.waitForSelector('#calendar-grid', { timeout: 15000 }); 109 + return page.url(); 110 + } 111 + 112 + /** 101 113 * Click a cell in the spreadsheet by its cell ID (e.g. "A1", "B3"). 102 114 */ 103 115 export async function clickCell(page: Page, cellId: string): Promise<void> {
+25 -9
src/css/app.css
··· 2749 2749 display: flex; align-items: center; gap: var(--space-sm); 2750 2750 } 2751 2751 .calendar-month-nav { font-size: 0.85rem; cursor: pointer; background: none; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 2px 8px; } 2752 - .calendar-grid { 2752 + .calendar-view .calendar-grid { 2753 2753 display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px; 2754 2754 background: var(--color-border); border: 1px solid var(--color-border); border-radius: var(--radius-sm); 2755 2755 } 2756 - .calendar-day-header { 2756 + .calendar-view .calendar-day-header { 2757 2757 background: var(--color-bg-secondary); padding: 4px; text-align: center; 2758 2758 font-size: 0.75rem; font-weight: 600; 2759 2759 } 2760 - .calendar-day { 2760 + .calendar-view .calendar-day { 2761 2761 background: var(--color-bg); min-height: 80px; padding: 4px; 2762 2762 font-size: 0.75rem; vertical-align: top; 2763 2763 } 2764 - .calendar-day.calendar-day-other { opacity: 0.4; } 2765 - .calendar-day-number { font-weight: 600; margin-bottom: 2px; } 2766 - .calendar-event { 2764 + .calendar-view .calendar-day.calendar-day-other { opacity: 0.4; } 2765 + .calendar-view .calendar-day-number { font-weight: 600; margin-bottom: 2px; } 2766 + .calendar-view .calendar-event { 2767 2767 background: #7ee3d0; color: #002922; 2768 2768 background: oklch(0.85 0.1 180); color: oklch(0.25 0.05 180); 2769 2769 border-radius: 3px; padding: 1px 4px; margin-bottom: 1px; 2770 2770 font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 2771 2771 cursor: pointer; 2772 2772 } 2773 - [data-theme="dark"] .calendar-event { background: #004a3c; color: #abd9cf; } 2774 - [data-theme="dark"] .calendar-event { background: oklch(0.35 0.1 180); color: oklch(0.85 0.05 180); } 2775 - .calendar-event:hover { opacity: 0.8; } 2773 + [data-theme="dark"] .calendar-view .calendar-event { background: #004a3c; color: #abd9cf; } 2774 + [data-theme="dark"] .calendar-view .calendar-event { background: oklch(0.35 0.1 180); color: oklch(0.85 0.05 180); } 2775 + .calendar-view .calendar-event:hover { opacity: 0.8; } 2776 2776 2777 2777 /* --- Form Builder Styles --- */ 2778 2778 .form-builder-main { max-width: 720px; margin: 0 auto; padding: var(--space-md); } ··· 9331 9331 overflow: hidden; 9332 9332 } 9333 9333 9334 + /* Calendar grid container — fills remaining space below toolbar */ 9335 + .calendar-app .calendar-grid { 9336 + flex: 1; 9337 + display: flex; 9338 + flex-direction: column; 9339 + overflow: hidden; 9340 + } 9341 + 9334 9342 9335 9343 /* ── Toolbar ─────────────────────────────────────────────────────────── */ 9336 9344 ··· 9589 9597 9590 9598 .cal-week-header-cell.is-today { 9591 9599 color: var(--color-teal); 9600 + } 9601 + 9602 + .cal-week-day-name { 9603 + display: block; 9604 + font-size: 0.65rem; 9605 + font-weight: 600; 9606 + text-transform: uppercase; 9607 + letter-spacing: 0.06em; 9592 9608 } 9593 9609 9594 9610 .cal-week-header-gutter {
+230
tests/calendar-helpers.test.ts
··· 236 236 }); 237 237 }); 238 238 239 + // ------------------------------------------------------------------------- 240 + // Additional edge case coverage 241 + // ------------------------------------------------------------------------- 242 + 243 + describe('formatTimeDisplay — edge cases', () => { 244 + it('formats 11:59pm (23:59)', () => { 245 + expect(formatTimeDisplay('23:59')).toBe('11:59pm'); 246 + }); 247 + 248 + it('formats 12:01am (00:01)', () => { 249 + expect(formatTimeDisplay('00:01')).toBe('12:01am'); 250 + }); 251 + 252 + it('formats single-digit minutes like 1:05pm', () => { 253 + expect(formatTimeDisplay('13:05')).toBe('1:05pm'); 254 + }); 255 + 256 + it('formats 12:30pm (noon with minutes)', () => { 257 + expect(formatTimeDisplay('12:30')).toBe('12:30pm'); 258 + }); 259 + 260 + it('formats 1am (01:00)', () => { 261 + expect(formatTimeDisplay('01:00')).toBe('1am'); 262 + }); 263 + 264 + it('formats 11am (11:00)', () => { 265 + expect(formatTimeDisplay('11:00')).toBe('11am'); 266 + }); 267 + 268 + it('formats 11pm (23:00)', () => { 269 + expect(formatTimeDisplay('23:00')).toBe('11pm'); 270 + }); 271 + 272 + it('formats 12:59pm', () => { 273 + expect(formatTimeDisplay('12:59')).toBe('12:59pm'); 274 + }); 275 + }); 276 + 277 + describe('daysInMonth — all 12 months', () => { 278 + const expected2026 = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 279 + for (let m = 0; m < 12; m++) { 280 + it(`returns ${expected2026[m]} for month ${m} (${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][m]}) 2026`, () => { 281 + expect(daysInMonth(2026, m)).toBe(expected2026[m]); 282 + }); 283 + } 284 + 285 + it('returns 29 for February in leap year 2024', () => { 286 + expect(daysInMonth(2024, 1)).toBe(29); 287 + }); 288 + 289 + it('returns 28 for February in century year 1900 (not a leap year)', () => { 290 + expect(daysInMonth(1900, 1)).toBe(28); 291 + }); 292 + 293 + it('returns 29 for February in 400-year cycle leap year 2000', () => { 294 + expect(daysInMonth(2000, 1)).toBe(29); 295 + }); 296 + }); 297 + 298 + describe('eventsOnDate — empty array', () => { 299 + it('returns empty array when no events exist', () => { 300 + expect(eventsOnDate([], '2026-04-08')).toEqual([]); 301 + }); 302 + 303 + it('returns empty array when events exist but none match the date', () => { 304 + const events = [ 305 + makeEvent({ date: '2026-01-01' }), 306 + makeEvent({ date: '2026-12-31' }), 307 + ]; 308 + expect(eventsOnDate(events, '2026-06-15')).toEqual([]); 309 + }); 310 + }); 311 + 312 + describe('eventsOnDate — multiple all-day events maintain order', () => { 313 + it('keeps all-day events before timed events regardless of insertion order', () => { 314 + const events = [ 315 + makeEvent({ date: '2026-04-08', allDay: false, startTime: '08:00', title: 'Early' }), 316 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'AllDay1' }), 317 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'AllDay2' }), 318 + makeEvent({ date: '2026-04-08', allDay: false, startTime: '17:00', title: 'Late' }), 319 + ]; 320 + const result = eventsOnDate(events, '2026-04-08'); 321 + expect(result).toHaveLength(4); 322 + // Both all-day events come first 323 + expect(result[0]!.title).toBe('AllDay1'); 324 + expect(result[1]!.title).toBe('AllDay2'); 325 + // Timed events follow in time order 326 + expect(result[2]!.title).toBe('Early'); 327 + expect(result[3]!.title).toBe('Late'); 328 + }); 329 + 330 + it('preserves original order among all-day events (stable sort)', () => { 331 + const events = [ 332 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'C' }), 333 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'A' }), 334 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'B' }), 335 + ]; 336 + const result = eventsOnDate(events, '2026-04-08'); 337 + // All-day events with same startTime='' should maintain insertion order 338 + expect(result.map(e => e.title)).toEqual(['C', 'A', 'B']); 339 + }); 340 + }); 341 + 342 + describe('getWeekStart/getWeekEnd — across month boundaries', () => { 343 + it('handles week spanning March 31 and April 1', () => { 344 + // 2026-03-31 is a Tuesday 345 + const tue = new Date(2026, 2, 31); 346 + const start = getWeekStart(tue); 347 + const end = getWeekEnd(tue); 348 + 349 + // Week start should be Sunday March 29 350 + expect(start.getMonth()).toBe(2); // March 351 + expect(start.getDate()).toBe(29); 352 + expect(start.getDay()).toBe(0); // Sunday 353 + 354 + // Week end should be Saturday April 4 355 + expect(end.getMonth()).toBe(3); // April 356 + expect(end.getDate()).toBe(4); 357 + expect(end.getDay()).toBe(6); // Saturday 358 + }); 359 + 360 + it('handles week spanning April 30 and May 1', () => { 361 + // 2026-04-30 is a Thursday 362 + const thu = new Date(2026, 3, 30); 363 + const start = getWeekStart(thu); 364 + const end = getWeekEnd(thu); 365 + 366 + expect(start.getMonth()).toBe(3); // April 367 + expect(start.getDate()).toBe(26); 368 + expect(end.getMonth()).toBe(4); // May 369 + expect(end.getDate()).toBe(2); 370 + }); 371 + 372 + it('handles Saturday at end of month', () => { 373 + // 2026-01-31 is a Saturday 374 + const sat = new Date(2026, 0, 31); 375 + const start = getWeekStart(sat); 376 + const end = getWeekEnd(sat); 377 + 378 + expect(start.getMonth()).toBe(0); // January 379 + expect(start.getDate()).toBe(25); 380 + expect(end.getMonth()).toBe(0); // January 381 + expect(end.getDate()).toBe(31); 382 + }); 383 + }); 384 + 385 + describe('getWeekStart/getWeekEnd — across year boundaries', () => { 386 + it('handles week containing Dec 31 2025 and Jan 1 2026', () => { 387 + // 2025-12-31 is a Wednesday 388 + const wed = new Date(2025, 11, 31); 389 + const start = getWeekStart(wed); 390 + const end = getWeekEnd(wed); 391 + 392 + // Week start: Sunday Dec 28 2025 393 + expect(start.getFullYear()).toBe(2025); 394 + expect(start.getMonth()).toBe(11); // December 395 + expect(start.getDate()).toBe(28); 396 + expect(start.getDay()).toBe(0); 397 + 398 + // Week end: Saturday Jan 3 2026 399 + expect(end.getFullYear()).toBe(2026); 400 + expect(end.getMonth()).toBe(0); // January 401 + expect(end.getDate()).toBe(3); 402 + expect(end.getDay()).toBe(6); 403 + }); 404 + 405 + it('handles Jan 1 2026 (Thursday) — week start is in previous year', () => { 406 + const thu = new Date(2026, 0, 1); 407 + const start = getWeekStart(thu); 408 + 409 + expect(start.getFullYear()).toBe(2025); 410 + expect(start.getMonth()).toBe(11); // December 411 + expect(start.getDate()).toBe(28); 412 + }); 413 + 414 + it('handles Dec 31 2026 (Thursday) — week end is in next year', () => { 415 + const thu = new Date(2026, 11, 31); 416 + const end = getWeekEnd(thu); 417 + 418 + expect(end.getFullYear()).toBe(2027); 419 + expect(end.getMonth()).toBe(0); // January 420 + expect(end.getDate()).toBe(2); // Saturday Jan 2 2027 421 + }); 422 + }); 423 + 424 + describe('parseEventDate — edge dates', () => { 425 + it('parses first day of year', () => { 426 + const d = parseEventDate('2026-01-01'); 427 + expect(d.getFullYear()).toBe(2026); 428 + expect(d.getMonth()).toBe(0); 429 + expect(d.getDate()).toBe(1); 430 + }); 431 + 432 + it('parses last day of year', () => { 433 + const d = parseEventDate('2026-12-31'); 434 + expect(d.getFullYear()).toBe(2026); 435 + expect(d.getMonth()).toBe(11); 436 + expect(d.getDate()).toBe(31); 437 + }); 438 + 439 + it('parses Feb 29 in a leap year', () => { 440 + const d = parseEventDate('2028-02-29'); 441 + expect(d.getFullYear()).toBe(2028); 442 + expect(d.getMonth()).toBe(1); // February 443 + expect(d.getDate()).toBe(29); 444 + }); 445 + 446 + it('parses Feb 28 in a non-leap year', () => { 447 + const d = parseEventDate('2026-02-28'); 448 + expect(d.getFullYear()).toBe(2026); 449 + expect(d.getMonth()).toBe(1); 450 + expect(d.getDate()).toBe(28); 451 + }); 452 + 453 + it('roundtrips first day of year through formatDate', () => { 454 + const original = '2026-01-01'; 455 + expect(formatDate(parseEventDate(original))).toBe(original); 456 + }); 457 + 458 + it('roundtrips last day of year through formatDate', () => { 459 + const original = '2026-12-31'; 460 + expect(formatDate(parseEventDate(original))).toBe(original); 461 + }); 462 + 463 + it('roundtrips Feb 29 leap year through formatDate', () => { 464 + const original = '2028-02-29'; 465 + expect(formatDate(parseEventDate(original))).toBe(original); 466 + }); 467 + }); 468 + 239 469 describe('constants', () => { 240 470 it('has 6 event colors', () => { 241 471 expect(EVENT_COLORS).toHaveLength(6);