Renaming part 1

This commit is contained in:
Janus Knudsen 2025-08-07 00:15:44 +02:00
parent 36ac8d18ab
commit 29811fd4b5
20 changed files with 1424 additions and 582 deletions

89
CLAUDE.md Normal file
View file

@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build and Development
- **Build TypeScript:** `npm run build` - Compiles TypeScript sources to JavaScript using esbuild
- **Watch mode:** `npm run watch` - Continuously compiles TypeScript on file changes
- **Clean build:** `npm run clean` - Removes compiled JavaScript files (uses PowerShell on Windows)
- **Start server:** `dotnet run` - Starts the ASP.NET Core server on http://localhost:8000
### Typical Development Workflow
1. Install dependencies: `npm install`
2. Build TypeScript: `npm run build`
3. Start server: `dotnet run`
4. For active development, run `npm run watch` in a separate terminal
## Architecture Overview
Calendar Plantempus is an event-driven calendar application with the following architecture:
### Core Principles
- **Event-driven communication**: All components communicate via DOM CustomEvents through a central EventBus
- **No global state**: Each manager maintains its own state
- **Manager-based architecture**: Each manager has a specific responsibility
- **Pure DOM manipulation**: No external JavaScript frameworks (React, Vue, etc.)
- **TypeScript with esbuild**: Modern build tooling for fast compilation
### Key Components
#### EventBus (src/core/EventBus.ts)
Central event dispatcher using DOM CustomEvents. All inter-component communication flows through this:
```typescript
// Example event emission
eventBus.emit('calendar:view-changed', { view: 'week' });
// Example event subscription
eventBus.on('calendar:view-changed', (event) => { /* handle */ });
```
#### Manager Pattern
Each manager is instantiated in `src/index.ts` and handles a specific domain:
- **CalendarManager**: Main coordinator, initializes other managers
- **ViewManager**: Handles day/week/month view switching
- **NavigationManager**: Prev/next/today navigation
- **EventManager**: CRUD operations for calendar events
- **EventRenderer**: Visual rendering of events in the grid
- **GridManager**: Creates and maintains the calendar grid structure
- **ScrollManager**: Handles scroll position and time indicators
- **DataManager**: Mock data loading and event data transformation
### Project Structure
```
src/
├── constants/ # Enums and constants (EventTypes)
├── core/ # Core functionality (EventBus, CalendarConfig)
├── managers/ # Manager classes (one per domain)
├── types/ # TypeScript type definitions
└── utils/ # Utility functions (DateUtils, PositionUtils)
wwwroot/
├── css/ # Modular CSS files
├── js/ # Compiled JavaScript output
└── index.html # Main HTML entry point
```
### CSS Architecture
Modular CSS structure without external frameworks:
- `calendar-base-css.css`: CSS custom properties and base styles
- `calendar-components-css.css`: UI components
- `calendar-events-css.css`: Event styling and colors
- `calendar-layout-css.css`: Grid and layout
- `calendar-popup-css.css`: Popup and modal styles
### Event Naming Convention
Events follow the pattern `category:action`:
- `calendar:*` - General calendar events
- `grid:*` - Grid-related events
- `event:*` - Event data changes
- `navigation:*` - Navigation actions
- `view:*` - View changes
### TypeScript Configuration
- Target: ES2020
- Module: ESNext
- Strict mode enabled
- Source maps enabled
- Output directory: `./js`

View file

@ -28,7 +28,7 @@ export class GridManager {
findElements() {
this.container = document.querySelector('swp-calendar-container');
this.timeAxis = document.querySelector('swp-time-axis');
this.weekHeader = document.querySelector('swp-week-header');
this.weekHeader = document.querySelector('swp-calendar-header');
this.timeGrid = document.querySelector('swp-time-grid');
this.scrollableContent = document.querySelector('swp-scrollable-content');
}

View file

@ -64,7 +64,7 @@
swp-calendar-nav,
swp-calendar-container,
swp-time-axis,
swp-week-header,
swp-calendar-header,
swp-scrollable-content,
swp-time-grid,
swp-day-columns,
@ -228,7 +228,7 @@
}
/* Week container for sliding */
swp-week-container {
swp-grid-container {
grid-column: 2;
display: grid;
grid-template-rows: auto 1fr;
@ -237,19 +237,19 @@
transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
swp-week-container.slide-out-left {
swp-grid-container.slide-out-left {
transform: translateX(-100%);
}
swp-week-container.slide-out-right {
swp-grid-container.slide-out-right {
transform: translateX(100%);
}
swp-week-container.slide-in-left {
swp-grid-container.slide-in-left {
transform: translateX(-100%);
}
swp-week-container.slide-in-right {
swp-grid-container.slide-in-right {
transform: translateX(100%);
}
@ -287,7 +287,7 @@
}
/* Week header */
swp-week-header {
swp-calendar-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--color-surface);
@ -522,8 +522,8 @@
<swp-calendar-container>
<swp-time-axis></swp-time-axis>
<swp-week-container>
<swp-week-header></swp-week-header>
<swp-grid-container>
<swp-calendar-header></swp-calendar-header>
<swp-scrollable-content>
<swp-time-grid>
@ -531,7 +531,7 @@
<swp-day-columns></swp-day-columns>
</swp-time-grid>
</swp-scrollable-content>
</swp-week-container>
</swp-grid-container>
</swp-calendar-container>
<swp-loading-overlay hidden>
@ -607,7 +607,7 @@
}
renderWeekHeaders() {
const weekHeader = document.querySelector('swp-week-header');
const weekHeader = document.querySelector('swp-calendar-header');
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
weekHeader.innerHTML = ''; // Clear any existing content
@ -818,12 +818,12 @@
animateTransition(direction, targetWeek) {
const container = document.querySelector('swp-calendar-container');
const currentWeek = document.querySelector('swp-week-container');
const currentWeek = document.querySelector('swp-grid-container');
// Create new week container
const newWeek = document.createElement('swp-week-container');
const newWeek = document.createElement('swp-grid-container');
newWeek.innerHTML = `
<swp-week-header></swp-week-header>
<swp-calendar-header></swp-calendar-header>
<swp-scrollable-content>
<swp-time-grid>
<swp-grid-lines></swp-grid-lines>
@ -844,7 +844,7 @@
container.appendChild(newWeek);
// Render new content in the new container using targetWeek
const tempHeader = newWeek.querySelector('swp-week-header');
const tempHeader = newWeek.querySelector('swp-calendar-header');
const tempColumns = newWeek.querySelector('swp-day-columns');
// Clear any existing content
@ -1027,7 +1027,7 @@
updateCalendar() {
// This method is now only used for initial render
document.querySelector('swp-week-header').innerHTML = '';
document.querySelector('swp-calendar-header').innerHTML = '';
document.querySelector('swp-day-columns').innerHTML = '';
this.renderWeekHeaders();
this.renderDayColumns();

View file

@ -0,0 +1,166 @@
# Resource Calendar JSON Structure (Opdateret)
Her er den opdaterede JSON struktur med resources som array og detaljerede resource informationer:
```json
{
"date": "2025-08-05",
"resources": [
{
"name": "karina.knudsen",
"displayName": "Karina Knudsen",
"avatarUrl": "/avatars/karina.jpg",
"employeeId": "EMP001",
"events": [
{
"id": "1",
"title": "Balayage langt hår",
"start": "2025-08-05T10:00:00",
"end": "2025-08-05T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#9c27b0" }
},
{
"id": "2",
"title": "Klipning og styling",
"start": "2025-08-05T14:00:00",
"end": "2025-08-05T15:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 90, "color": "#e91e63" }
}
]
},
{
"name": "maria.hansen",
"displayName": "Maria Hansen",
"avatarUrl": "/avatars/maria.jpg",
"employeeId": "EMP002",
"events": [
{
"id": "3",
"title": "Permanent",
"start": "2025-08-05T09:00:00",
"end": "2025-08-05T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#3f51b5" }
},
{
"id": "4",
"title": "Farve behandling",
"start": "2025-08-05T13:00:00",
"end": "2025-08-05T15:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#ff9800" }
}
]
},
{
"name": "lars.nielsen",
"displayName": "Lars Nielsen",
"avatarUrl": "/avatars/lars.jpg",
"employeeId": "EMP003",
"events": [
{
"id": "5",
"title": "Herreklipning",
"start": "2025-08-05T11:00:00",
"end": "2025-08-05T11:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#795548" }
},
{
"id": "6",
"title": "Skæg trimning",
"start": "2025-08-05T16:00:00",
"end": "2025-08-05T16:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#607d8b" }
}
]
},
{
"name": "anna.petersen",
"displayName": "Anna Petersen",
"avatarUrl": "/avatars/anna.jpg",
"employeeId": "EMP004",
"events": [
{
"id": "7",
"title": "Bryllupsfrisure",
"start": "2025-08-05T08:00:00",
"end": "2025-08-05T10:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#009688" }
}
]
},
{
"name": "thomas.olsen",
"displayName": "Thomas Olsen",
"avatarUrl": "/avatars/thomas.jpg",
"employeeId": "EMP005",
"events": [
{
"id": "8",
"title": "Highlights",
"start": "2025-08-05T12:00:00",
"end": "2025-08-05T14:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#8bc34a" }
},
{
"id": "9",
"title": "Styling konsultation",
"start": "2025-08-05T15:00:00",
"end": "2025-08-05T15:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#cddc39" }
}
]
}
]
}
```
## Struktur Forklaring
- **date**: Den specifikke dato for resource calendar visningen
- **resources**: Array af resource objekter
- **Resource objekt**:
- **name**: Unikt navn/ID (kebab-case)
- **displayName**: Navn til visning i UI
- **avatarUrl**: URL til profilbillede
- **employeeId**: Medarbejder ID
- **events**: Array af events for denne resource
## Fordele ved denne struktur:
1. **Fleksibel**: Nemt at tilføje flere resource felter
2. **Skalerbar**: Kan håndtere mange resources
3. **UI-venlig**: DisplayName og avatar til visning
4. **Struktureret**: Klar separation mellem resource info og events
5. **Søgbar**: Name og employeeId til filtrering/søgning
Denne struktur gør det nemt at:
- Vise resource info i headers (displayName, avatar)
- Filtrere events per resource
- Håndtere kun én dato ad gangen i resource mode
- Udvide med flere resource felter senere

View file

@ -19,6 +19,7 @@ export const EventTypes = {
EVENT_RENDERED: 'calendar:eventrendered',
EVENT_SELECTED: 'calendar:eventselected',
EVENTS_LOADED: 'calendar:eventsloaded',
RESOURCE_DATA_LOADED: 'calendar:resourcedataloaded',
// Interaction events
DRAG_START: 'calendar:dragstart',
@ -55,6 +56,8 @@ export const EventTypes = {
// State events
STATE_UPDATE: 'calendar:stateupdate',
CONFIG_UPDATE: 'calendar:configupdate',
CALENDAR_TYPE_CHANGED: 'calendar:calendartypechanged',
SELECTED_DATE_CHANGED: 'calendar:selecteddatechanged',
// Time events
TIME_UPDATE: 'calendar:timeupdate',

View file

@ -2,7 +2,7 @@
import { eventBus } from './EventBus';
import { EventTypes } from '../constants/EventTypes';
import { CalendarConfig as ICalendarConfig, ViewType } from '../types/CalendarTypes';
import { CalendarConfig as ICalendarConfig, ViewType, CalendarType } from '../types/CalendarTypes';
/**
* View-specific settings interface
@ -18,6 +18,8 @@ interface ViewSettings {
*/
export class CalendarConfig {
private config: ICalendarConfig;
private calendarType: CalendarType = 'date';
private selectedDate: Date | null = null;
constructor() {
this.config = {
@ -69,10 +71,46 @@ export class CalendarConfig {
// Set computed values
this.config.minEventDuration = this.config.snapInterval;
// Load calendar type from URL parameter
this.loadCalendarType();
// Load from data attributes
this.loadFromDOM();
}
/**
* Load calendar type and date from URL parameters
*/
private loadCalendarType(): void {
const urlParams = new URLSearchParams(window.location.search);
const typeParam = urlParams.get('type');
const dateParam = urlParams.get('date');
// Set calendar type
if (typeParam === 'resource' || typeParam === 'date') {
this.calendarType = typeParam;
console.log(`CalendarConfig: Calendar type set to '${this.calendarType}' from URL parameter`);
} else {
this.calendarType = 'date'; // Default
console.log(`CalendarConfig: Calendar type defaulted to '${this.calendarType}'`);
}
// Set selected date
if (dateParam) {
const parsedDate = new Date(dateParam);
if (!isNaN(parsedDate.getTime())) {
this.selectedDate = parsedDate;
console.log(`CalendarConfig: Selected date set to '${this.selectedDate.toISOString()}' from URL parameter`);
} else {
console.warn(`CalendarConfig: Invalid date parameter '${dateParam}', using current date`);
this.selectedDate = new Date();
}
} else {
this.selectedDate = new Date(); // Default to today
console.log(`CalendarConfig: Selected date defaulted to today: ${this.selectedDate.toISOString()}`);
}
}
/**
* Load configuration from DOM data attributes
*/
@ -194,6 +232,46 @@ export class CalendarConfig {
return settings[view] || settings.week;
}
/**
* Get calendar type
*/
getCalendarType(): CalendarType {
return this.calendarType;
}
/**
* Set calendar type
*/
setCalendarType(type: CalendarType): void {
const oldType = this.calendarType;
this.calendarType = type;
// Emit calendar type change event
eventBus.emit(EventTypes.CALENDAR_TYPE_CHANGED, {
oldType,
newType: type
});
}
/**
* Get selected date
*/
getSelectedDate(): Date | null {
return this.selectedDate;
}
/**
* Set selected date
*/
setSelectedDate(date: Date): void {
this.selectedDate = date;
// Emit date change event
eventBus.emit(EventTypes.SELECTED_DATE_CHANGED, {
date: date
});
}
}
// Create singleton instance

168
src/data/mock-events.json Normal file
View file

@ -0,0 +1,168 @@
[
{
"id": "1",
"title": "Weekend Planning",
"start": "2025-08-03T10:00:00",
"end": "2025-08-03T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#9c27b0" }
},
{
"id": "2",
"title": "Team Standup",
"start": "2025-08-04T09:00:00",
"end": "2025-08-04T09:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#ff5722" }
},
{
"id": "3",
"title": "Project Kickoff",
"start": "2025-08-04T14:00:00",
"end": "2025-08-04T15:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 90, "color": "#e91e63" }
},
{
"id": "4",
"title": "Deep Work Session",
"start": "2025-08-05T10:00:00",
"end": "2025-08-05T12:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#3f51b5" }
},
{
"id": "5",
"title": "Lunch Meeting",
"start": "2025-08-05T12:30:00",
"end": "2025-08-05T13:30:00",
"type": "meal",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#ff9800" }
},
{
"id": "6",
"title": "Client Review",
"start": "2025-08-06T15:00:00",
"end": "2025-08-06T16:00:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#795548" }
},
{
"id": "7",
"title": "Sprint Planning",
"start": "2025-08-07T09:00:00",
"end": "2025-08-07T10:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 90, "color": "#607d8b" }
},
{
"id": "8",
"title": "Code Review",
"start": "2025-08-07T14:00:00",
"end": "2025-08-07T15:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#009688" }
},
{
"id": "9",
"title": "Team Standup",
"start": "2025-08-08T09:00:00",
"end": "2025-08-08T09:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#8bc34a" }
},
{
"id": "10",
"title": "Client Meeting",
"start": "2025-08-08T14:00:00",
"end": "2025-08-08T15:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 90, "color": "#cddc39" }
},
{
"id": "11",
"title": "Weekend Project",
"start": "2025-08-09T10:00:00",
"end": "2025-08-09T12:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#f44336" }
},
{
"id": "12",
"title": "Early Morning Workout",
"start": "2025-08-05T06:00:00",
"end": "2025-08-05T07:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#00bcd4" }
},
{
"id": "13",
"title": "Late Evening Call",
"start": "2025-08-06T21:00:00",
"end": "2025-08-06T22:00:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#673ab7" }
},
{
"id": "14",
"title": "Midnight Deployment",
"start": "2025-08-07T23:00:00",
"end": "2025-08-08T01:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#ffc107" }
},
{
"id": "15",
"title": "Company Holiday",
"start": "2025-08-04T00:00:00",
"end": "2025-08-05T23:59:59",
"type": "milestone",
"allDay": true,
"syncStatus": "synced",
"metadata": {
"duration": 1440,
"color": "#4caf50"
}
},
{
"id": "16",
"title": "Team Building Event",
"start": "2025-08-06T00:00:00",
"end": "2025-08-06T23:59:59",
"type": "meeting",
"allDay": true,
"syncStatus": "synced",
"metadata": {
"duration": 1440,
"color": "#2196f3"
}
}
]

View file

@ -0,0 +1,135 @@
{
"date": "2025-08-05",
"resources": [
{
"name": "karina.knudsen",
"displayName": "Karina Knudsen",
"avatarUrl": "/avatars/karina.jpg",
"employeeId": "EMP001",
"events": [
{
"id": "1",
"title": "Balayage langt hår",
"start": "2025-08-05T10:00:00",
"end": "2025-08-05T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 60, "color": "#9c27b0" }
},
{
"id": "2",
"title": "Klipning og styling",
"start": "2025-08-05T14:00:00",
"end": "2025-08-05T15:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 90, "color": "#e91e63" }
}
]
},
{
"name": "maria.hansen",
"displayName": "Maria Hansen",
"avatarUrl": "/avatars/maria.jpg",
"employeeId": "EMP002",
"events": [
{
"id": "3",
"title": "Permanent",
"start": "2025-08-05T09:00:00",
"end": "2025-08-05T11:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#3f51b5" }
},
{
"id": "4",
"title": "Farve behandling",
"start": "2025-08-05T13:00:00",
"end": "2025-08-05T15:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#ff9800" }
}
]
},
{
"name": "lars.nielsen",
"displayName": "Lars Nielsen",
"avatarUrl": "/avatars/lars.jpg",
"employeeId": "EMP003",
"events": [
{
"id": "5",
"title": "Herreklipning",
"start": "2025-08-05T11:00:00",
"end": "2025-08-05T11:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#795548" }
},
{
"id": "6",
"title": "Skæg trimning",
"start": "2025-08-05T16:00:00",
"end": "2025-08-05T16:30:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#607d8b" }
}
]
},
{
"name": "anna.petersen",
"displayName": "Anna Petersen",
"avatarUrl": "/avatars/anna.jpg",
"employeeId": "EMP004",
"events": [
{
"id": "7",
"title": "Bryllupsfrisure",
"start": "2025-08-05T08:00:00",
"end": "2025-08-05T10:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#009688" }
}
]
},
{
"name": "thomas.olsen",
"displayName": "Thomas Olsen",
"avatarUrl": "/avatars/thomas.jpg",
"employeeId": "EMP005",
"events": [
{
"id": "8",
"title": "Highlights",
"start": "2025-08-05T12:00:00",
"end": "2025-08-05T14:00:00",
"type": "work",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 120, "color": "#8bc34a" }
},
{
"id": "9",
"title": "Styling konsultation",
"start": "2025-08-05T15:00:00",
"end": "2025-08-05T15:30:00",
"type": "meeting",
"allDay": false,
"syncStatus": "synced",
"metadata": { "duration": 30, "color": "#cddc39" }
}
]
}
]
}

View file

@ -0,0 +1,107 @@
// Factory for creating calendar type-specific renderers
import { CalendarType } from '../types/CalendarTypes';
import { HeaderRenderer, DateHeaderRenderer, ResourceHeaderRenderer } from '../renderers/HeaderRenderer';
import { ColumnRenderer, DateColumnRenderer, ResourceColumnRenderer } from '../renderers/ColumnRenderer';
import { EventRendererStrategy, DateEventRenderer, ResourceEventRenderer } from '../renderers/EventRenderer';
/**
* Renderer configuration for a calendar type
*/
export interface RendererConfig {
headerRenderer: HeaderRenderer;
columnRenderer: ColumnRenderer;
eventRenderer: EventRendererStrategy;
}
/**
* Factory for creating calendar type-specific renderers
*/
export class CalendarTypeFactory {
private static renderers: Map<CalendarType, RendererConfig> = new Map();
/**
* Initialize the factory with default renderers
*/
static initialize(): void {
// Register default renderers
this.registerRenderers('date', {
headerRenderer: new DateHeaderRenderer(),
columnRenderer: new DateColumnRenderer(),
eventRenderer: new DateEventRenderer()
});
this.registerRenderers('resource', {
headerRenderer: new ResourceHeaderRenderer(),
columnRenderer: new ResourceColumnRenderer(),
eventRenderer: new ResourceEventRenderer()
});
console.log('CalendarTypeFactory: Initialized with default renderers', Array.from(this.renderers.keys()));
}
/**
* Register renderers for a calendar type
*/
static registerRenderers(type: CalendarType, config: RendererConfig): void {
this.renderers.set(type, config);
console.log(`CalendarTypeFactory: Registered renderers for type '${type}'`);
}
/**
* Get renderers for a calendar type
*/
static getRenderers(type: CalendarType): RendererConfig {
const renderers = this.renderers.get(type);
if (!renderers) {
console.warn(`CalendarTypeFactory: No renderers found for type '${type}', falling back to 'date'`);
return this.renderers.get('date')!;
}
return renderers;
}
/**
* Get header renderer for a calendar type
*/
static getHeaderRenderer(type: CalendarType): HeaderRenderer {
return this.getRenderers(type).headerRenderer;
}
/**
* Get column renderer for a calendar type
*/
static getColumnRenderer(type: CalendarType): ColumnRenderer {
return this.getRenderers(type).columnRenderer;
}
/**
* Get event renderer for a calendar type
*/
static getEventRenderer(type: CalendarType): EventRendererStrategy {
return this.getRenderers(type).eventRenderer;
}
/**
* Check if a calendar type is supported
*/
static isSupported(type: CalendarType): boolean {
return this.renderers.has(type);
}
/**
* Get all supported calendar types
*/
static getSupportedTypes(): CalendarType[] {
return Array.from(this.renderers.keys());
}
/**
* Clear all registered renderers (useful for testing)
*/
static clear(): void {
this.renderers.clear();
console.log('CalendarTypeFactory: All renderers cleared');
}
}

View file

@ -7,7 +7,7 @@ import { EventManager } from './managers/EventManager.js';
import { EventRenderer } from './managers/EventRenderer.js';
import { GridManager } from './managers/GridManager.js';
import { ScrollManager } from './managers/ScrollManager.js';
import { CalendarConfig } from './core/CalendarConfig.js';
import { calendarConfig } from './core/CalendarConfig.js';
/**
* Initialize the calendar application
@ -15,8 +15,8 @@ import { CalendarConfig } from './core/CalendarConfig.js';
function initializeCalendar(): void {
console.log('🗓️ Initializing Calendar Plantempus...');
// Create calendar configuration
const config = new CalendarConfig();
// Use the singleton calendar configuration
const config = calendarConfig;
// Initialize managers
const calendarManager = new CalendarManager(eventBus, config);

View file

@ -1,6 +1,7 @@
import { EventBus } from '../core/EventBus';
import { IEventBus, CalendarEvent } from '../types/CalendarTypes';
import { IEventBus, CalendarEvent, ResourceCalendarData } from '../types/CalendarTypes';
import { EventTypes } from '../constants/EventTypes';
import { calendarConfig } from '../core/CalendarConfig';
/**
* EventManager - Administrerer event lifecycle og CRUD operationer
@ -11,9 +12,17 @@ export class EventManager {
private events: CalendarEvent[] = [];
constructor(eventBus: IEventBus) {
console.log('EventManager: Constructor called');
this.eventBus = eventBus;
this.setupEventListeners();
this.loadMockData();
console.log('EventManager: About to call loadMockData()');
this.loadMockData().then(() => {
console.log('EventManager: loadMockData() completed, syncing events');
// Data loaded, sync events after loading
this.syncEvents();
}).catch(error => {
console.error('EventManager: loadMockData() failed:', error);
});
}
private setupEventListeners(): void {
@ -30,189 +39,54 @@ export class EventManager {
});
}
private loadMockData(): void {
// Mock events for current week (July 27 - August 2, 2025)
this.events = [
// Sunday August 3, 2025
{
id: '1',
title: 'Weekend Planning',
start: '2025-08-03T10:00:00',
end: '2025-08-03T11:00:00',
type: 'work',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 60, color: '#9c27b0' } // Purple
},
// Monday August 4, 2025
{
id: '2',
title: 'Team Standup',
start: '2025-08-04T09:00:00',
end: '2025-08-04T09:30:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 30, color: '#ff5722' } // Deep Orange
},
{
id: '3',
title: 'Project Kickoff',
start: '2025-08-04T14:00:00',
end: '2025-08-04T15:30:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 90, color: '#e91e63' } // Pink
},
// Tuesday August 5, 2025
{
id: '4',
title: 'Deep Work Session',
start: '2025-08-05T10:00:00',
end: '2025-08-05T12:00:00',
type: 'work',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 120, color: '#3f51b5' } // Indigo
},
{
id: '5',
title: 'Lunch Meeting',
start: '2025-08-05T12:30:00',
end: '2025-08-05T13:30:00',
type: 'meal',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 60, color: '#ff9800' } // Orange
},
// Wednesday August 6, 2025
{
id: '6',
title: 'Client Review',
start: '2025-08-06T15:00:00',
end: '2025-08-06T16:00:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 60, color: '#795548' } // Brown
},
// Thursday August 7, 2025
{
id: '7',
title: 'Sprint Planning',
start: '2025-08-07T09:00:00',
end: '2025-08-07T10:30:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 90, color: '#607d8b' } // Blue Grey
},
{
id: '8',
title: 'Code Review',
start: '2025-08-07T14:00:00',
end: '2025-08-07T15:00:00',
type: 'work',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 60, color: '#009688' } // Teal
},
// Friday August 8, 2025
{
id: '9',
title: 'Team Standup',
start: '2025-08-08T09:00:00',
end: '2025-08-08T09:30:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 30, color: '#8bc34a' } // Light Green
},
{
id: '10',
title: 'Client Meeting',
start: '2025-08-08T14:00:00',
end: '2025-08-08T15:30:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 90, color: '#cddc39' } // Lime
},
// Saturday August 9, 2025
{
id: '11',
title: 'Weekend Project',
start: '2025-08-09T10:00:00',
end: '2025-08-09T12:00:00',
type: 'work',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 120, color: '#f44336' } // Red
},
// Test events for early/late hours
{
id: '12',
title: 'Early Morning Workout',
start: '2025-08-05T06:00:00',
end: '2025-08-05T07:00:00',
type: 'work',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 60, color: '#00bcd4' } // Cyan
},
{
id: '13',
title: 'Late Evening Call',
start: '2025-08-06T21:00:00',
end: '2025-08-06T22:00:00',
type: 'meeting',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 60, color: '#673ab7' } // Deep Purple
},
{
id: '14',
title: 'Midnight Deployment',
start: '2025-08-07T23:00:00',
end: '2025-08-08T01:00:00',
type: 'work',
allDay: false,
syncStatus: 'synced',
metadata: { duration: 120, color: '#ffc107' } // Amber
},
// All-day events for demo
{
id: '15',
title: 'Company Holiday',
start: '2025-08-04T00:00:00',
end: '2025-08-05T23:59:59',
type: 'milestone',
allDay: true,
syncStatus: 'synced',
metadata: {
duration: 1440, // Full day in minutes
color: '#4caf50' // Green color
}
},
{
id: '16',
title: 'Team Building Event',
start: '2025-08-06T00:00:00',
end: '2025-08-06T23:59:59',
type: 'meeting',
allDay: true,
syncStatus: 'synced',
metadata: {
duration: 1440, // Full day in minutes
color: '#2196f3' // Blue color
}
}
];
private async loadMockData(): Promise<void> {
try {
const calendarType = calendarConfig.getCalendarType();
let jsonFile: string;
console.log(`EventManager: Loaded ${this.events.length} mock events`);
console.log('EventManager: First event:', this.events[0]);
console.log('EventManager: Last event:', this.events[this.events.length - 1]);
console.log(`EventManager: Calendar type detected: '${calendarType}'`);
if (calendarType === 'resource') {
jsonFile = '/src/data/mock-resource-events.json';
} else {
jsonFile = '/src/data/mock-events.json';
}
console.log(`EventManager: Loading ${calendarType} calendar data from ${jsonFile}`);
const response = await fetch(jsonFile);
if (!response.ok) {
throw new Error(`Failed to load mock events: ${response.status}`);
}
if (calendarType === 'resource') {
const resourceData: ResourceCalendarData = await response.json();
// Flatten events from all resources and add resource metadata
this.events = resourceData.resources.flatMap(resource =>
resource.events.map(event => ({
...event,
resourceName: resource.name,
resourceDisplayName: resource.displayName,
resourceEmployeeId: resource.employeeId
}))
);
console.log(`EventManager: Loaded ${this.events.length} events from ${resourceData.resources.length} resources`);
// Emit resource data for GridManager
this.eventBus.emit(EventTypes.RESOURCE_DATA_LOADED, {
resourceData: resourceData
});
} else {
this.events = await response.json();
console.log(`EventManager: Loaded ${this.events.length} date calendar events`);
}
console.log('EventManager: First event:', this.events[0]);
console.log('EventManager: Last event:', this.events[this.events.length - 1]);
} catch (error) {
console.error('EventManager: Failed to load mock events:', error);
this.events = []; // Fallback to empty array
}
}
private syncEvents(): void {

View file

@ -2,18 +2,22 @@ import { EventBus } from '../core/EventBus';
import { IEventBus, CalendarEvent } from '../types/CalendarTypes';
import { EventTypes } from '../constants/EventTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { DateUtils } from '../utils/DateUtils';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
/**
* EventRenderer - Render events i DOM med positionering
* EventRenderer - Render events i DOM med positionering using Strategy Pattern
* Håndterer event positioning og overlap detection
*/
export class EventRenderer {
private eventBus: IEventBus;
private pendingEvents: CalendarEvent[] = [];
constructor(eventBus: IEventBus) {
this.eventBus = eventBus;
this.setupEventListeners();
// Initialize the factory (if not already done)
CalendarTypeFactory.initialize();
}
private setupEventListeners(): void {
@ -35,17 +39,26 @@ export class EventRenderer {
// Clear existing events when view changes
this.clearEvents();
});
// Handle calendar type changes
this.eventBus.on(EventTypes.CALENDAR_TYPE_CHANGED, () => {
// Re-render events with new strategy
this.tryRenderEvents();
});
}
private pendingEvents: CalendarEvent[] = [];
private tryRenderEvents(): void {
// Only render if we have both events and grid is ready
// Only render if we have both events and appropriate columns are ready
console.log('EventRenderer: tryRenderEvents called, pending events:', this.pendingEvents.length);
if (this.pendingEvents.length > 0) {
const dayColumns = document.querySelectorAll('swp-day-column');
console.log('EventRenderer: Found', dayColumns.length, 'day columns');
if (dayColumns.length > 0) {
const calendarType = calendarConfig.getCalendarType();
let columnsSelector = calendarType === 'resource' ? 'swp-resource-column' : 'swp-day-column';
const columns = document.querySelectorAll(columnsSelector);
console.log(`EventRenderer: Found ${columns.length} ${columnsSelector} elements for ${calendarType} calendar`);
if (columns.length > 0) {
this.renderEvents(this.pendingEvents);
this.pendingEvents = []; // Clear pending events after rendering
}
@ -54,173 +67,34 @@ export class EventRenderer {
private renderEvents(events: CalendarEvent[]): void {
console.log('EventRenderer: renderEvents called with', events.length, 'events');
console.log('EventRenderer: All events:', events.map(e => ({ title: e.title, allDay: e.allDay, start: e.start })));
// Clear existing events first
this.clearEvents();
// Get the appropriate event renderer strategy
const calendarType = calendarConfig.getCalendarType();
const eventRenderer = CalendarTypeFactory.getEventRenderer(calendarType);
// Get current week dates for filtering
const currentWeekDates = this.getCurrentWeekDates();
// Filter events for current week and exclude all-day events (handled by GridManager)
const currentWeekEvents = events.filter(event => {
// Skip all-day events - they are handled by GridManager
if (event.allDay) {
return false;
}
console.log(`EventRenderer: Using ${calendarType} event renderer strategy`);
const eventDate = new Date(event.start);
const eventDateStr = DateUtils.formatDate(eventDate);
const isInCurrentWeek = currentWeekDates.some(weekDate =>
DateUtils.formatDate(weekDate) === eventDateStr
);
return isInCurrentWeek;
});
// Render each event in the correct day column
currentWeekEvents.forEach(event => {
const eventDate = new Date(event.start);
const dayColumn = this.findDayColumn(eventDate);
if (dayColumn) {
const eventsLayer = dayColumn.querySelector('swp-events-layer');
if (eventsLayer) {
this.renderEvent(event, eventsLayer);
} else {
console.warn('EventRenderer: No events layer found in day column for', DateUtils.formatDate(eventDate));
}
} else {
console.warn('EventRenderer: No day column found for event date', DateUtils.formatDate(eventDate));
}
});
// Use strategy to render events
eventRenderer.renderEvents(events, calendarConfig);
// Emit event rendered
this.eventBus.emit(EventTypes.EVENT_RENDERED, {
count: currentWeekEvents.length
count: events.filter(e => !e.allDay).length
});
}
/**
* Get current week dates (Sunday to Saturday)
*/
private getCurrentWeekDates(): Date[] {
const today = new Date();
const weekStart = DateUtils.getWeekStart(today, 0); // Sunday start
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
const date = DateUtils.addDays(weekStart, i);
dates.push(date);
}
return dates;
}
/**
* Find day column for specific date
*/
private findDayColumn(date: Date): HTMLElement | null {
const dateStr = DateUtils.formatDate(date);
const dayColumn = document.querySelector(`swp-day-column[data-date="${dateStr}"]`) as HTMLElement;
console.log('EventRenderer: Looking for day column with date', dateStr, 'found:', !!dayColumn);
return dayColumn;
}
private renderEvent(event: CalendarEvent, container: Element): void {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.type = event.type;
// Calculate position based on time
const position = this.calculateEventPosition(event);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 1}px`;
// Only set positioning and color - rest is in CSS
eventElement.style.backgroundColor = event.metadata?.color || '#3498db';
// Format time for display
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
// Create event content
eventElement.innerHTML = `
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
// Add event listeners
this.addEventListeners(eventElement, event);
container.appendChild(eventElement);
// Event successfully rendered
}
private calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
// Use dayStartHour to match time-axis positioning - this is the visible start hour (6 AM)
const dayStartHour = calendarConfig.get('dayStartHour'); // 6 (6 AM)
const hourHeight = calendarConfig.get('hourHeight');
// Calculate minutes from visible day start (6 AM, not midnight)
const eventHour = startDate.getHours();
const eventMinutes = startDate.getMinutes();
const startMinutes = (eventHour - dayStartHour) * 60 + eventMinutes;
// Calculate duration in minutes
const duration = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
// Convert to pixels - position relative to visible time-grid (starts at dayStartHour)
const top = startMinutes * (hourHeight / 60);
const height = duration * (hourHeight / 60);
return { top, height };
}
private formatTime(isoString: string): string {
const date = new Date(isoString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
const displayMinutes = minutes.toString().padStart(2, '0');
return `${displayHours}:${displayMinutes} ${period}`;
}
private addEventListeners(eventElement: HTMLElement, event: CalendarEvent): void {
// Click handler
eventElement.addEventListener('click', (e) => {
e.stopPropagation();
this.eventBus.emit(EventTypes.EVENT_SELECTED, {
event,
element: eventElement
});
});
// Hover effects are handled by CSS
// Hover effects are now handled by CSS
}
private clearEvents(): void {
const eventsLayers = document.querySelectorAll('swp-events-layer');
eventsLayers.forEach(layer => {
layer.innerHTML = '';
});
const calendarType = calendarConfig.getCalendarType();
const eventRenderer = CalendarTypeFactory.getEventRenderer(calendarType);
eventRenderer.clearEvents();
}
public refresh(): void {
// Request fresh events from EventManager
this.eventBus.emit(EventTypes.REFRESH_REQUESTED);
this.tryRenderEvents();
}
public destroy(): void {
this.pendingEvents = [];
this.clearEvents();
}
}

View file

@ -1,9 +1,13 @@
// Grid structure management - Simple CSS Grid Implementation
// Grid structure management - Simple CSS Grid Implementation with Strategy Pattern
import { eventBus } from '../core/EventBus';
import { calendarConfig } from '../core/CalendarConfig';
import { EventTypes } from '../constants/EventTypes';
import { DateUtils } from '../utils/DateUtils';
import { ResourceCalendarData } from '../types/CalendarTypes';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { HeaderRenderContext } from '../renderers/HeaderRenderer';
import { ColumnRenderContext } from '../renderers/ColumnRenderer';
/**
* Grid position interface
@ -15,19 +19,23 @@ interface GridPosition {
}
/**
* Manages the calendar grid structure using simple CSS Grid
* Manages the calendar grid structure using simple CSS Grid with Strategy Pattern
*/
export class GridManager {
private container: HTMLElement | null = null;
private grid: HTMLElement | null = null;
private currentWeek: Date | null = null;
private allDayEvents: any[] = []; // Store all-day events for current week
private resourceData: ResourceCalendarData | null = null; // Store resource data for resource calendar
constructor() {
this.init();
}
private init(): void {
// Initialize the factory
CalendarTypeFactory.initialize();
this.findElements();
this.subscribeToEvents();
@ -58,6 +66,11 @@ export class GridManager {
}
});
// Re-render on calendar type change
eventBus.on(EventTypes.CALENDAR_TYPE_CHANGED, () => {
this.render();
});
// Re-render on view change
eventBus.on(EventTypes.VIEW_CHANGE, () => {
this.render();
@ -83,6 +96,21 @@ export class GridManager {
this.updateAllDayEvents(detail.events);
});
// Handle resource data loaded
eventBus.on(EventTypes.RESOURCE_DATA_LOADED, (e: Event) => {
const detail = (e as CustomEvent).detail;
this.resourceData = detail.resourceData;
console.log(`GridManager: Received resource data for ${this.resourceData!.resources.length} resources`);
// Update grid styles with new column count immediately
this.updateGridStyles();
// Re-render if grid is already rendered
if (this.grid && this.grid.children.length > 0) {
this.render();
}
});
// Handle grid clicks
this.setupGridInteractions();
}
@ -122,11 +150,8 @@ export class GridManager {
console.log('GridManager: First render - creating grid structure');
// Create POC structure: header-spacer + time-axis + week-container + right-column + bottom spacers
this.createHeaderSpacer();
this.createRightHeaderSpacer();
this.createTimeAxis();
this.createWeekContainer();
this.createRightColumn();
this.createBottomRow();
} else {
console.log('GridManager: Re-render - updating existing structure');
// Just update the week header for all-day events
@ -146,27 +171,6 @@ export class GridManager {
this.grid.appendChild(headerSpacer);
}
/**
* Create right header spacer for scrollbar alignment
*/
private createRightHeaderSpacer(): void {
if (!this.grid) return;
const rightHeaderSpacer = document.createElement('swp-right-header-spacer');
this.grid.appendChild(rightHeaderSpacer);
}
/**
* Create right column for scrollbar area
*/
private createRightColumn(): void {
if (!this.grid) return;
const rightColumn = document.createElement('swp-right-column');
this.grid.appendChild(rightColumn);
}
/**
* Create time axis (positioned beside week container) like in POC
*/
@ -192,15 +196,15 @@ export class GridManager {
}
/**
* Create week container with header and scrollable content like in POC
* Create week container with header and scrollable content using Strategy Pattern
*/
private createWeekContainer(): void {
if (!this.grid || !this.currentWeek) return;
const weekContainer = document.createElement('swp-week-container');
const weekContainer = document.createElement('swp-grid-container');
// Create week header
const weekHeader = document.createElement('swp-week-header');
// Create week header using Strategy Pattern
const weekHeader = document.createElement('swp-calendar-header');
this.renderWeekHeaders(weekHeader);
weekContainer.appendChild(weekHeader);
@ -212,7 +216,7 @@ export class GridManager {
const gridLines = document.createElement('swp-grid-lines');
timeGrid.appendChild(gridLines);
// Create day columns
// Create day columns using Strategy Pattern
const dayColumns = document.createElement('swp-day-columns');
this.renderDayColumns(dayColumns);
timeGrid.appendChild(dayColumns);
@ -224,74 +228,60 @@ export class GridManager {
}
/**
* Create bottom row with spacers
*/
private createBottomRow(): void {
if (!this.grid) return;
// Bottom spacer (left)
const bottomSpacer = document.createElement('swp-bottom-spacer');
this.grid.appendChild(bottomSpacer);
// Bottom middle spacer
const bottomMiddleSpacer = document.createElement('swp-bottom-middle-spacer');
this.grid.appendChild(bottomMiddleSpacer);
// Right bottom spacer
const rightBottomSpacer = document.createElement('swp-right-bottom-spacer');
this.grid.appendChild(rightBottomSpacer);
}
/**
* Render week headers like in POC
* Render week headers using Strategy Pattern
*/
private renderWeekHeaders(weekHeader: HTMLElement): void {
if (!this.currentWeek) return;
const dates = this.getWeekDates(this.currentWeek);
const weekDays = calendarConfig.get('weekDays');
const daysToShow = dates.slice(0, weekDays);
const calendarType = calendarConfig.getCalendarType();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
daysToShow.forEach((date) => {
const header = document.createElement('swp-day-header');
if (this.isToday(date)) {
(header as any).dataset.today = 'true';
}
const context: HeaderRenderContext = {
currentWeek: this.currentWeek,
config: calendarConfig,
allDayEvents: this.allDayEvents,
resourceData: this.resourceData
};
header.innerHTML = `
<swp-day-name>${this.getDayName(date)}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
(header as any).dataset.date = this.formatDate(date);
weekHeader.appendChild(header);
});
// Render all-day events in row 2
this.renderAllDayEvents(weekHeader);
headerRenderer.render(weekHeader, context);
// Update spacer heights based on all-day events
this.updateSpacerHeights();
}
/**
* Render day columns using Strategy Pattern
*/
private renderDayColumns(dayColumns: HTMLElement): void {
if (!this.currentWeek) return;
console.log('GridManager: renderDayColumns called');
const calendarType = calendarConfig.getCalendarType();
const columnRenderer = CalendarTypeFactory.getColumnRenderer(calendarType);
const context: ColumnRenderContext = {
currentWeek: this.currentWeek,
config: calendarConfig,
resourceData: this.resourceData
};
columnRenderer.render(dayColumns, context);
}
/**
* Update only the week header (for all-day events) without rebuilding entire grid
*/
private updateWeekHeader(): void {
if (!this.grid || !this.currentWeek) return;
const weekHeader = this.grid.querySelector('swp-week-header');
const weekHeader = this.grid.querySelector('swp-calendar-header');
if (!weekHeader) return;
// Clear existing all-day events but keep day headers
const allDayEvents = weekHeader.querySelectorAll('swp-allday-event');
allDayEvents.forEach(event => event.remove());
// Clear existing content
weekHeader.innerHTML = '';
// Re-render all-day events
this.renderAllDayEvents(weekHeader as HTMLElement);
// Update spacer heights
this.updateSpacerHeights();
// Re-render headers using Strategy Pattern
this.renderWeekHeaders(weekHeader as HTMLElement);
}
/**
@ -320,64 +310,6 @@ export class GridManager {
}
}
/**
* Render all-day events in week header row 2
*/
private renderAllDayEvents(weekHeader: HTMLElement): void {
if (!this.currentWeek) return;
const dates = this.getWeekDates(this.currentWeek);
const weekDays = calendarConfig.get('weekDays');
const daysToShow = dates.slice(0, weekDays);
// Process each all-day event to calculate its span
this.allDayEvents.forEach(event => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
// Find start and end column indices
let startColumnIndex = -1;
let endColumnIndex = -1;
daysToShow.forEach((date, index) => {
const dateStr = this.formatDate(date);
const startDateStr = this.formatDate(startDate);
const endDateStr = this.formatDate(endDate);
if (dateStr === startDateStr) {
startColumnIndex = index;
}
// For end date, we need to check if the event spans to this day
// All-day events typically end at 23:59:59, so we check if this date is <= end date
if (date <= endDate) {
endColumnIndex = index;
}
});
// Only render if the event starts within the visible week
if (startColumnIndex >= 0) {
// If end column is not found or is before start, default to single day
if (endColumnIndex < startColumnIndex) {
endColumnIndex = startColumnIndex;
}
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.textContent = event.title;
// Set grid column span: start column (1-based) to end column + 1 (1-based)
const gridColumnStart = startColumnIndex + 1;
const gridColumnEnd = endColumnIndex + 2; // +2 because grid columns are 1-based and we want to include the end column
allDayEvent.style.gridColumn = `${gridColumnStart} / ${gridColumnEnd}`;
allDayEvent.style.backgroundColor = event.metadata?.color || '#ff9800';
console.log(`GridManager: All-day event "${event.title}" spans columns ${gridColumnStart} to ${gridColumnEnd-1} (${endColumnIndex - startColumnIndex + 1} days)`);
weekHeader.appendChild(allDayEvent);
}
});
}
/**
* Update spacer heights based on all-day events presence
*/
@ -393,33 +325,6 @@ export class GridManager {
console.log('GridManager: Updated --all-day-row-height to', `${allDayHeight}px`, 'for', allDayEventCount, 'events');
}
/**
* Render day columns like in POC
*/
private renderDayColumns(dayColumns: HTMLElement): void {
console.log('GridManager: renderDayColumns called');
if (!this.currentWeek) {
console.log('GridManager: No currentWeek, returning');
return;
}
const dates = this.getWeekDates(this.currentWeek);
const weekDays = calendarConfig.get('weekDays');
const daysToShow = dates.slice(0, weekDays);
console.log('GridManager: About to render', daysToShow.length, 'day columns');
daysToShow.forEach((date, dayIndex) => {
const column = document.createElement('swp-day-column');
(column as any).dataset.date = this.formatDate(date);
const eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
dayColumns.appendChild(column);
});
}
/**
* Update grid CSS variables
*/
@ -427,6 +332,7 @@ export class GridManager {
const root = document.documentElement;
const config = calendarConfig.getAll();
const calendar = document.querySelector('swp-calendar') as HTMLElement;
const calendarType = calendarConfig.getCalendarType();
// Set CSS variables
root.style.setProperty('--hour-height', `${config.hourHeight}px`);
@ -437,6 +343,15 @@ export class GridManager {
root.style.setProperty('--work-start-hour', config.workStartHour.toString());
root.style.setProperty('--work-end-hour', config.workEndHour.toString());
// Set number of columns based on calendar type
let columnCount = 7; // Default for date mode
if (calendarType === 'resource' && this.resourceData) {
columnCount = this.resourceData.resources.length;
} else if (calendarType === 'date') {
columnCount = config.weekDays;
}
root.style.setProperty('--grid-columns', columnCount.toString());
// Set day column min width based on fitToWidth setting
if (config.fitToWidth) {
root.style.setProperty('--day-column-min-width', '50px'); // Small min-width allows columns to fit available space
@ -448,6 +363,8 @@ export class GridManager {
if (calendar) {
calendar.setAttribute('data-fit-to-width', config.fitToWidth.toString());
}
console.log('GridManager: Updated grid styles with', columnCount, 'columns for', calendarType, 'calendar');
}
/**
@ -456,18 +373,20 @@ export class GridManager {
private setupGridInteractions(): void {
if (!this.grid) return;
// Click handler for day columns
// Click handler for day columns (works for both date and resource columns)
this.grid.addEventListener('click', (e: MouseEvent) => {
// Ignore if clicking on an event
if ((e.target as Element).closest('swp-event')) return;
const dayColumn = (e.target as Element).closest('swp-day-column') as HTMLElement;
const dayColumn = (e.target as Element).closest('swp-day-column, swp-resource-column') as HTMLElement;
if (!dayColumn) return;
const position = this.getClickPosition(e, dayColumn);
eventBus.emit(EventTypes.GRID_CLICK, {
date: (dayColumn as any).dataset.date,
resource: (dayColumn as any).dataset.resource,
employeeId: (dayColumn as any).dataset.employeeId,
time: position.time,
minutes: position.minutes
});
@ -478,13 +397,15 @@ export class GridManager {
// Ignore if clicking on an event
if ((e.target as Element).closest('swp-event')) return;
const dayColumn = (e.target as Element).closest('swp-day-column') as HTMLElement;
const dayColumn = (e.target as Element).closest('swp-day-column, swp-resource-column') as HTMLElement;
if (!dayColumn) return;
const position = this.getClickPosition(e, dayColumn);
eventBus.emit(EventTypes.GRID_DBLCLICK, {
date: (dayColumn as any).dataset.date,
resource: (dayColumn as any).dataset.resource,
employeeId: (dayColumn as any).dataset.employeeId,
time: position.time,
minutes: position.minutes
});
@ -537,36 +458,6 @@ export class GridManager {
* Utility methods
*/
private formatHour(hour: number): string {
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
return `${displayHour} ${period}`;
}
private formatDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private getDayName(date: Date): string {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return days[date.getDay()];
}
private getWeekDates(weekStart: Date): Date[] {
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(weekStart);
date.setDate(weekStart.getDate() + i);
dates.push(date);
}
return dates;
}
private isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
private minutesToTime(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;

View file

@ -108,7 +108,7 @@ export class ScrollManager {
this.scrollableContent = document.querySelector('swp-scrollable-content');
this.calendarContainer = document.querySelector('swp-calendar-container');
this.timeAxis = document.querySelector('swp-time-axis');
this.weekHeader = document.querySelector('swp-week-header');
this.weekHeader = document.querySelector('swp-calendar-header');
console.log('ScrollManager: Found elements:', {
scrollableContent: !!this.scrollableContent,
@ -173,7 +173,7 @@ export class ScrollManager {
const navHeight = navigation ? navigation.getBoundingClientRect().height : 0;
// Find week header height
const weekHeader = document.querySelector('swp-week-header');
const weekHeader = document.querySelector('swp-calendar-header');
const headerHeight = weekHeader ? weekHeader.getBoundingClientRect().height : 80;
// Calculate available height for scrollable content

View file

@ -0,0 +1,87 @@
// Column rendering strategy interface and implementations
import { CalendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData } from '../types/CalendarTypes';
/**
* Interface for column rendering strategies
*/
export interface ColumnRenderer {
render(dayColumns: HTMLElement, context: ColumnRenderContext): void;
}
/**
* Context for column rendering
*/
export interface ColumnRenderContext {
currentWeek: Date;
config: CalendarConfig;
resourceData?: ResourceCalendarData | null;
}
/**
* Date-based column renderer (original functionality)
*/
export class DateColumnRenderer implements ColumnRenderer {
render(dayColumns: HTMLElement, context: ColumnRenderContext): void {
const { currentWeek, config } = context;
const dates = this.getWeekDates(currentWeek);
const weekDays = config.get('weekDays');
const daysToShow = dates.slice(0, weekDays);
console.log('DateColumnRenderer: About to render', daysToShow.length, 'date columns');
daysToShow.forEach((date) => {
const column = document.createElement('swp-day-column');
(column as any).dataset.date = this.formatDate(date);
const eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
dayColumns.appendChild(column);
});
}
private getWeekDates(weekStart: Date): Date[] {
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(weekStart);
date.setDate(weekStart.getDate() + i);
dates.push(date);
}
return dates;
}
private formatDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
}
/**
* Resource-based column renderer
*/
export class ResourceColumnRenderer implements ColumnRenderer {
render(dayColumns: HTMLElement, context: ColumnRenderContext): void {
const { resourceData } = context;
if (!resourceData) {
console.warn('ResourceColumnRenderer: No resource data available for resource columns');
return;
}
console.log('ResourceColumnRenderer: About to render', resourceData.resources.length, 'resource columns');
resourceData.resources.forEach((resource) => {
const column = document.createElement('swp-resource-column');
(column as any).dataset.resource = resource.name;
(column as any).dataset.employeeId = resource.employeeId;
(column as any).dataset.date = resourceData.date;
const eventsLayer = document.createElement('swp-events-layer');
column.appendChild(eventsLayer);
dayColumns.appendChild(column);
});
}
}

View file

@ -0,0 +1,141 @@
// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { CalendarConfig } from '../core/CalendarConfig';
import { DateUtils } from '../utils/DateUtils';
/**
* Interface for event rendering strategies
*/
export interface EventRendererStrategy {
findColumn(event: CalendarEvent): HTMLElement | null;
renderEvents(events: CalendarEvent[], config: CalendarConfig): void;
clearEvents(): void;
}
/**
* Base class for event renderers with common functionality
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
abstract findColumn(event: CalendarEvent): HTMLElement | null;
renderEvents(events: CalendarEvent[], config: CalendarConfig): void {
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
// Clear existing events first
this.clearEvents();
// Filter out all-day events (handled by GridManager)
const nonAllDayEvents = events.filter(event => !event.allDay);
console.log('BaseEventRenderer: Rendering', nonAllDayEvents.length, 'non-all-day events');
// Render each event in the correct column
nonAllDayEvents.forEach(event => {
const column = this.findColumn(event);
if (column) {
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
this.renderEvent(event, eventsLayer, config);
} else {
console.warn('BaseEventRenderer: No events layer found in column for event', event.title);
}
} else {
console.warn('BaseEventRenderer: No column found for event', event.title);
}
});
}
protected renderEvent(event: CalendarEvent, container: Element, config: CalendarConfig): void {
const eventElement = document.createElement('swp-event');
eventElement.dataset.eventId = event.id;
eventElement.dataset.type = event.type;
// Calculate position based on time
const position = this.calculateEventPosition(event, config);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 1}px`;
// Set color
eventElement.style.backgroundColor = event.metadata?.color || '#3498db';
// Format time for display
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
// Create event content
eventElement.innerHTML = `
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
container.appendChild(eventElement);
}
protected calculateEventPosition(event: CalendarEvent, config: CalendarConfig): { top: number; height: number } {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const dayStartHour = config.get('dayStartHour');
const hourHeight = config.get('hourHeight');
// Calculate minutes from visible day start
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
const dayStartMinutes = dayStartHour * 60;
// Calculate top position (subtract day start to align with time axis)
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
// Calculate height
const durationMinutes = endMinutes - startMinutes;
const height = (durationMinutes / 60) * hourHeight;
return { top, height };
}
protected formatTime(isoString: string): string {
const date = new Date(isoString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? 'PM' : 'AM';
const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
}
clearEvents(): void {
const existingEvents = document.querySelectorAll('swp-event');
existingEvents.forEach(event => event.remove());
}
}
/**
* Date-based event renderer
*/
export class DateEventRenderer extends BaseEventRenderer {
findColumn(event: CalendarEvent): HTMLElement | null {
const eventDate = new Date(event.start);
const dateStr = DateUtils.formatDate(eventDate);
const dayColumn = document.querySelector(`swp-day-column[data-date="${dateStr}"]`) as HTMLElement;
console.log('DateEventRenderer: Looking for day column with date', dateStr, 'found:', !!dayColumn);
return dayColumn;
}
}
/**
* Resource-based event renderer
*/
export class ResourceEventRenderer extends BaseEventRenderer {
findColumn(event: CalendarEvent): HTMLElement | null {
if (!event.resourceName) {
console.warn('ResourceEventRenderer: Event has no resourceName', event);
return null;
}
const resourceColumn = document.querySelector(`swp-resource-column[data-resource="${event.resourceName}"]`) as HTMLElement;
console.log('ResourceEventRenderer: Looking for resource column with name', event.resourceName, 'found:', !!resourceColumn);
return resourceColumn;
}
}

View file

@ -0,0 +1,158 @@
// Header rendering strategy interface and implementations
import { CalendarConfig } from '../core/CalendarConfig';
import { ResourceCalendarData } from '../types/CalendarTypes';
/**
* Interface for header rendering strategies
*/
export interface HeaderRenderer {
render(weekHeader: HTMLElement, context: HeaderRenderContext): void;
}
/**
* Context for header rendering
*/
export interface HeaderRenderContext {
currentWeek: Date;
config: CalendarConfig;
allDayEvents?: any[];
resourceData?: ResourceCalendarData | null;
}
/**
* Date-based header renderer (original functionality)
*/
export class DateHeaderRenderer implements HeaderRenderer {
render(weekHeader: HTMLElement, context: HeaderRenderContext): void {
const { currentWeek, config, allDayEvents = [] } = context;
const dates = this.getWeekDates(currentWeek);
const weekDays = config.get('weekDays');
const daysToShow = dates.slice(0, weekDays);
daysToShow.forEach((date) => {
const header = document.createElement('swp-day-header');
if (this.isToday(date)) {
(header as any).dataset.today = 'true';
}
header.innerHTML = `
<swp-day-name>${this.getDayName(date)}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date>
`;
(header as any).dataset.date = this.formatDate(date);
weekHeader.appendChild(header);
});
// Render all-day events in row 2
this.renderAllDayEvents(weekHeader, context);
}
private renderAllDayEvents(weekHeader: HTMLElement, context: HeaderRenderContext): void {
const { currentWeek, config, allDayEvents = [] } = context;
const dates = this.getWeekDates(currentWeek);
const weekDays = config.get('weekDays');
const daysToShow = dates.slice(0, weekDays);
// Process each all-day event to calculate its span
allDayEvents.forEach(event => {
const startDate = new Date(event.start);
const endDate = new Date(event.end);
// Find start and end column indices
let startColumnIndex = -1;
let endColumnIndex = -1;
daysToShow.forEach((date, index) => {
const dateStr = this.formatDate(date);
const startDateStr = this.formatDate(startDate);
if (dateStr === startDateStr) {
startColumnIndex = index;
}
// For end date, we need to check if the event spans to this day
if (date <= endDate) {
endColumnIndex = index;
}
});
// Only render if the event starts within the visible week
if (startColumnIndex >= 0) {
// If end column is not found or is before start, default to single day
if (endColumnIndex < startColumnIndex) {
endColumnIndex = startColumnIndex;
}
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.textContent = event.title;
// Set grid column span: start column (1-based) to end column + 1 (1-based)
const gridColumnStart = startColumnIndex + 1;
const gridColumnEnd = endColumnIndex + 2;
allDayEvent.style.gridColumn = `${gridColumnStart} / ${gridColumnEnd}`;
allDayEvent.style.backgroundColor = event.metadata?.color || '#ff9800';
weekHeader.appendChild(allDayEvent);
}
});
}
private getWeekDates(weekStart: Date): Date[] {
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
const date = new Date(weekStart);
date.setDate(weekStart.getDate() + i);
dates.push(date);
}
return dates;
}
private isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
private formatDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
private getDayName(date: Date): string {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return days[date.getDay()];
}
}
/**
* Resource-based header renderer
*/
export class ResourceHeaderRenderer implements HeaderRenderer {
render(weekHeader: HTMLElement, context: HeaderRenderContext): void {
const { resourceData } = context;
if (!resourceData) {
console.warn('ResourceHeaderRenderer: No resource data available for resource headers');
return;
}
resourceData.resources.forEach((resource) => {
const header = document.createElement('swp-resource-header');
header.setAttribute('data-resource', resource.name);
header.setAttribute('data-employee-id', resource.employeeId);
header.innerHTML = `
<swp-resource-avatar>
<img src="${resource.avatarUrl}" alt="${resource.displayName}" onerror="this.style.display='none'">
</swp-resource-avatar>
<swp-resource-name>${resource.displayName}</swp-resource-name>
`;
weekHeader.appendChild(header);
});
console.log(`ResourceHeaderRenderer: Rendered ${resourceData.resources.length} resource headers`);
}
}

View file

@ -3,10 +3,25 @@
export type ViewType = 'day' | 'week' | 'month';
export type CalendarView = ViewType; // Alias for compatibility
export type CalendarType = 'date' | 'resource';
export type EventType = 'meeting' | 'meal' | 'work' | 'milestone';
export type SyncStatus = 'synced' | 'pending' | 'error';
export interface Resource {
name: string;
displayName: string;
avatarUrl: string;
employeeId: string;
events: CalendarEvent[];
}
export interface ResourceCalendarData {
date: string;
resources: Resource[];
}
export interface CalendarEvent {
id: string;
title: string;
@ -15,6 +30,10 @@ export interface CalendarEvent {
type: EventType;
allDay: boolean;
syncStatus: SyncStatus;
// Resource information (only present in resource calendar mode)
resourceName?: string;
resourceDisplayName?: string;
resourceEmployeeId?: string;
recurringId?: string;
resources?: string[];
metadata?: Record<string, any>;

View file

@ -62,7 +62,7 @@ swp-header-spacer {
/* Week container for sliding */
swp-week-container {
swp-grid-container {
grid-column: 2;
grid-row: 1 / 3;
display: grid;
@ -127,7 +127,7 @@ swp-time-grid::after {
left: 0;
right: 0;
bottom: 0;
min-width: calc(var(--week-days) * var(--day-column-min-width)); /* Dynamic width like swp-grid-lines */
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width)); /* Dynamic width like swp-grid-lines */
background-image: repeating-linear-gradient(
to bottom,
transparent,
@ -140,11 +140,11 @@ swp-time-grid::after {
}
/* Week header - dynamic height based on content */
swp-week-header {
swp-calendar-header {
display: grid;
grid-template-columns: repeat(7, minmax(var(--day-column-min-width), 1fr));
grid-template-columns: repeat(var(--grid-columns, 7), minmax(var(--day-column-min-width), 1fr));
grid-template-rows: var(--header-height) auto; /* Row 1: use CSS variable, Row 2: auto-sized for all-day events */
min-width: calc(var(--week-days) * var(--day-column-min-width)); /* Dynamic width */
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width)); /* Dynamic width */
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
@ -169,6 +169,47 @@ swp-day-header:last-child {
border-right: none;
}
/* Resource header styling */
swp-resource-header {
padding: 12px;
text-align: center;
border-right: 1px solid var(--color-grid-line);
border-bottom: 1px solid var(--color-grid-line);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--color-surface);
}
swp-resource-header:last-child {
border-right: none;
}
swp-resource-avatar {
display: block;
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 8px;
background: var(--color-border);
}
swp-resource-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
swp-resource-name {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
text-align: center;
}
swp-day-name {
display: block;
font-weight: 500;
@ -274,7 +315,7 @@ swp-time-grid::before {
left: 0;
right: 0;
background: var(--color-work-hours);
min-width: calc(var(--week-days) * var(--day-column-min-width));
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width));
pointer-events: none;
}
@ -285,7 +326,7 @@ swp-grid-lines {
left: 0;
right: 0;
bottom: 0;
min-width: calc(var(--week-days) * var(--day-column-min-width)); /* Dynamic width */
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width)); /* Dynamic width */
pointer-events: none;
z-index: var(--z-grid);
background-image: repeating-linear-gradient(
@ -303,8 +344,8 @@ swp-day-columns {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(7, minmax(var(--day-column-min-width), 1fr));
min-width: calc(var(--week-days) * var(--day-column-min-width)); /* Dynamic width */
grid-template-columns: repeat(var(--grid-columns, 7), minmax(var(--day-column-min-width), 1fr));
min-width: calc(var(--grid-columns, 7) * var(--day-column-min-width)); /* Dynamic width */
}
@ -318,6 +359,17 @@ swp-day-column:last-child {
border-right: none;
}
/* Resource column styling */
swp-resource-column {
position: relative;
border-right: 1px solid var(--color-grid-line);
min-width: var(--day-column-min-width);
}
swp-resource-column:last-child {
border-right: none;
}
swp-events-layer {
position: absolute;
inset: 0;

View file

@ -7,7 +7,7 @@ swp-calendar,
swp-calendar-nav,
swp-calendar-container,
swp-time-axis,
swp-week-header,
swp-calendar-header,
swp-scrollable-content,
swp-time-grid,
swp-day-columns,
@ -15,7 +15,7 @@ swp-day-column,
swp-events-layer,
swp-event,
swp-loading-overlay,
swp-week-container,
swp-grid-container,
swp-grid-lines {
display: block;
}
@ -164,7 +164,7 @@ swp-view-button[data-active="true"] {
/* Week container for sliding */
swp-week-container {
swp-grid-container {
grid-column: 2;
display: grid;
grid-template-rows: auto 1fr;
@ -173,19 +173,19 @@ swp-week-container {
transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
swp-week-container.slide-out-left {
swp-grid-container.slide-out-left {
transform: translateX(-100%);
}
swp-week-container.slide-out-right {
swp-grid-container.slide-out-right {
transform: translateX(100%);
}
swp-week-container.slide-in-left {
swp-grid-container.slide-in-left {
transform: translateX(-100%);
}
swp-week-container.slide-in-right {
swp-grid-container.slide-in-right {
transform: translateX(100%);
}