From f06c02121cd3790c79c3cecfd2977c1c14354319 Mon Sep 17 00:00:00 2001 From: Janus Knudsen Date: Thu, 24 Jul 2025 22:17:38 +0200 Subject: [PATCH] Initial commit: Calendar Plantempus project setup with TypeScript, ASP.NET Core, and event-driven architecture --- .gitignore | 32 + .roo/mcp.json | 1 + CalendarServer.csproj | 9 + Program.cs | 20 + README.md | 177 ++++ build.js | 58 ++ calendar-complete-specification.md | 460 ++++++++++ calendar-config.js | 189 ++++ calendar-data-manager.js | 385 ++++++++ calendar-date-utils.js | 231 +++++ calendar-event-types.js | 73 ++ calendar-eventbus.js | 118 +++ calendar-grid-manager.js | 334 +++++++ calendar-poc-single-file.html | 1066 +++++++++++++++++++++++ package-lock.json | 435 +++++++++ package.json | 16 + src/constants/EventTypes.ts | 98 +++ src/core/CalendarConfig.ts | 191 ++++ src/core/EventBus.ts | 103 +++ src/index.ts | 50 ++ src/managers/CalendarManager.ts | 256 ++++++ src/managers/DataManager.ts | 414 +++++++++ src/managers/EventManager.ts | 227 +++++ src/managers/EventRenderer.ts | 177 ++++ src/managers/GridManager.ts | 348 ++++++++ src/managers/NavigationManager.ts | 239 +++++ src/managers/ViewManager.ts | 174 ++++ src/types/CalendarTypes.ts | 103 +++ src/utils/DateUtils.ts | 230 +++++ src/utils/PositionUtils.ts | 291 +++++++ tsconfig.json | 24 + wwwroot/css/calendar-base-css.css | 190 ++++ wwwroot/css/calendar-components-css.css | 184 ++++ wwwroot/css/calendar-events-css.css | 263 ++++++ wwwroot/css/calendar-layout-css.css | 257 ++++++ wwwroot/css/calendar-popup-css.css | 190 ++++ wwwroot/css/calendar.css | 498 +++++++++++ wwwroot/index.html | 122 +++ 38 files changed, 8233 insertions(+) create mode 100644 .gitignore create mode 100644 .roo/mcp.json create mode 100644 CalendarServer.csproj create mode 100644 Program.cs create mode 100644 README.md create mode 100644 build.js create mode 100644 calendar-complete-specification.md create mode 100644 calendar-config.js create mode 100644 calendar-data-manager.js create mode 100644 calendar-date-utils.js create mode 100644 calendar-event-types.js create mode 100644 calendar-eventbus.js create mode 100644 calendar-grid-manager.js create mode 100644 calendar-poc-single-file.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/constants/EventTypes.ts create mode 100644 src/core/CalendarConfig.ts create mode 100644 src/core/EventBus.ts create mode 100644 src/index.ts create mode 100644 src/managers/CalendarManager.ts create mode 100644 src/managers/DataManager.ts create mode 100644 src/managers/EventManager.ts create mode 100644 src/managers/EventRenderer.ts create mode 100644 src/managers/GridManager.ts create mode 100644 src/managers/NavigationManager.ts create mode 100644 src/managers/ViewManager.ts create mode 100644 src/types/CalendarTypes.ts create mode 100644 src/utils/DateUtils.ts create mode 100644 src/utils/PositionUtils.ts create mode 100644 tsconfig.json create mode 100644 wwwroot/css/calendar-base-css.css create mode 100644 wwwroot/css/calendar-components-css.css create mode 100644 wwwroot/css/calendar-events-css.css create mode 100644 wwwroot/css/calendar-layout-css.css create mode 100644 wwwroot/css/calendar-popup-css.css create mode 100644 wwwroot/css/calendar.css create mode 100644 wwwroot/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07ae22b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Build outputs +bin/ +obj/ +wwwroot/js/ + +# Node modules +node_modules/ + +# IDE files +.vs/ +.vscode/settings.json + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temporary files +*.tmp +*.temp + +# Package files +*.nupkg +*.snupkg + +# User-specific files +*.user +*.suo +*.userosscache +*.sln.docstates \ No newline at end of file diff --git a/.roo/mcp.json b/.roo/mcp.json new file mode 100644 index 0000000..6b0a486 --- /dev/null +++ b/.roo/mcp.json @@ -0,0 +1 @@ +{"mcpServers":{}} \ No newline at end of file diff --git a/CalendarServer.csproj b/CalendarServer.csproj new file mode 100644 index 0000000..2447542 --- /dev/null +++ b/CalendarServer.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..10cbdaa --- /dev/null +++ b/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.FileProviders; + +var builder = WebApplication.CreateBuilder(args); + +var app = builder.Build(); + +// Configure static files to serve from current directory +app.UseStaticFiles(new StaticFileOptions +{ + FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")), + RequestPath = "" +}); + +// Fallback to index.html for SPA routing +app.MapFallbackToFile("index.html"); + +app.Run("http://localhost:8000"); \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ae48fe --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# Calendar Plantempus + +En moderne, event-drevet kalenderapplikation bygget med TypeScript og ASP.NET Core. + +## Projekt Information + +- **Projekt ID:** 8ecf2aa3-a2e4-4cc3-aa18-1c4352f00ff1 +- **Repository:** Calendar (afb8a8ec-cdbc-4c55-8631-fd0285974485) +- **Status:** Under aktiv udvikling + +## Teknisk Arkitektur + +- **Frontend:** TypeScript med esbuild som bundler +- **Arkitektur:** Event-drevet med CustomEvents (`document.dispatchEvent`/`addEventListener`) +- **Backend:** ASP.NET Core Kestrel server +- **Styling:** Modulær CSS struktur uden eksterne frameworks +- **Bundling:** esbuild for TypeScript transpilering og bundling + +## Arkitekturelle Principper + +- **Ingen global state** - Alt state håndteres i de relevante managers +- **Event-drevet kommunikation** - Alle komponenter kommunikerer via DOM CustomEvents +- **Modulær opbygning** - Hver manager har et specifikt ansvarsområde +- **Ren DOM manipulation** - Ingen eksterne JavaScript frameworks (React, Vue, etc.) +- **Custom HTML tags** - Semantisk markup med custom elements + +## Implementerede Komponenter + +Projektet følger en manager-baseret arkitektur, hvor hver manager er ansvarlig for et specifikt aspekt af kalenderen: + +### 1. CalendarManager +Hovedkoordinator for alle managers +- Initialiserer og koordinerer alle andre managers +- Håndterer global konfiguration +- Administrerer kalender lifecycle + +### 2. ViewManager +Håndterer kalendervisninger +- Skifter mellem dag/uge/måned visninger +- Opdaterer UI baseret på den valgte visning +- Renderer kalender grid struktur + +### 3. NavigationManager +Håndterer navigation +- Implementerer prev/next/today funktionalitet +- Håndterer dato navigation +- Opdaterer week info (uge nummer, dato range) + +### 4. EventManager +Administrerer events +- Håndterer event lifecycle og CRUD operationer +- Loader og synkroniserer event data +- Administrerer event selection og state + +### 5. EventRenderer +Renderer events i DOM +- Positionerer events korrekt i kalender grid +- Håndterer event styling baseret på type +- Implementerer visual feedback for event interactions + +### 6. DataManager +Håndterer data operationer +- Mock data loading for udvikling +- Event data transformation +- Data persistence interface + +### 7. GridManager +Administrerer kalender grid +- Opretter og vedligeholder grid struktur +- Håndterer time slots og positioning +- Responsive grid layout + +## CSS Struktur + +Projektet har en modulær CSS struktur for bedre organisering: + +- **`calendar-base-css.css`** - Grundlæggende styling og CSS custom properties +- **`calendar-components-css.css`** - UI komponenter og controls +- **`calendar-events-css.css`** - Event styling og farver +- **`calendar-layout-css.css`** - Layout struktur og grid +- **`calendar-popup-css.css`** - Popup og modal styling +- **`calendar.css`** - Samlet styling fra POC (bruges i øjeblikket) + +## Kommende Funktionalitet + +Baseret på projektstrukturen planlægges følgende komponenter: + +### Utilities +- **PositionUtils** - Konvertering mellem pixels og tidspunkter +- **SnapUtils** - Snap-to-interval funktionalitet +- **DOMUtils** - DOM manipulation utilities + +### Interaction Managers +- **DragManager** - Drag & drop funktionalitet for events +- **ResizeManager** - Resize funktionalitet for events +- **PopupManager** - Håndtering af event detaljer og popups + +### Feature Managers +- **SearchManager** - Søgefunktionalitet i events +- **TimeManager** - Current time indicator +- **LoadingManager** - Loading states og error handling + +### Avancerede Features +- Collision detection system for overlappende events +- Animation system for smooth transitions +- Event creation funktionalitet (double-click, drag-to-create) +- Multi-day event support +- Touch support for mobile enheder +- Keyboard navigation + +## Projekt Struktur + +``` +Calendar Plantempus/ +├── src/ # TypeScript source files +│ ├── constants/ # Konstanter og enums +│ ├── core/ # Core funktionalitet +│ ├── managers/ # Manager klasser +│ ├── types/ # TypeScript type definitioner +│ └── utils/ # Utility funktioner +├── wwwroot/ # Static web assets +│ ├── css/ # Stylesheets +│ ├── js/ # Compiled JavaScript +│ └── index.html # Main HTML file +├── build.js # esbuild configuration +├── tsconfig.json # TypeScript configuration +├── package.json # Node.js dependencies +└── Program.cs # ASP.NET Core server +``` + +## Kom i Gang + +### Forudsætninger +- .NET 8.0 SDK +- Node.js (for esbuild) + +### Installation +1. Klon repository +2. Installer dependencies: `npm install` +3. Build TypeScript: `npm run build` +4. Start server: `dotnet run` +5. Åbn browser på `http://localhost:8000` + +### Development +- **Build TypeScript:** `npm run build` +- **Watch mode:** `npm run watch` (hvis konfigureret) +- **Start server:** `dotnet run` + +## Event System + +Projektet bruger et event-drevet system hvor alle komponenter kommunikerer via DOM CustomEvents: + +```typescript +// Dispatch event +document.dispatchEvent(new CustomEvent('calendar:view-changed', { + detail: { view: 'week', date: new Date() } +})); + +// Listen for event +document.addEventListener('calendar:view-changed', (event) => { + // Handle view change +}); +``` + +## Bidrag + +Dette projekt følger clean code principper og modulær arkitektur. Når du bidrager: + +1. Følg den eksisterende manager-baserede struktur +2. Brug event-drevet kommunikation mellem komponenter +3. Undgå global state - hold state i relevante managers +4. Skriv semantisk HTML med custom tags +5. Brug modulær CSS struktur + +## Licens + +[Specificer licens her] \ No newline at end of file diff --git a/build.js b/build.js new file mode 100644 index 0000000..06e48dc --- /dev/null +++ b/build.js @@ -0,0 +1,58 @@ +import * as esbuild from 'esbuild'; +import { readdir, rename } from 'fs/promises'; +import { join, dirname, basename, extname } from 'path'; + +// Convert PascalCase to kebab-case +function toKebabCase(str) { + return str.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''); +} + +// Recursively rename files to kebab-case +async function renameFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + await renameFiles(fullPath); + } else if (entry.isFile() && extname(entry.name) === '.js') { + const baseName = basename(entry.name, '.js'); + const kebabName = toKebabCase(baseName); + + if (baseName !== kebabName) { + const newPath = join(dirname(fullPath), kebabName + '.js'); + await rename(fullPath, newPath); + console.log(`Renamed: ${entry.name} -> ${kebabName}.js`); + } + } + } +} + +// Build with esbuild +async function build() { + try { + console.log('Building TypeScript files...'); + + await esbuild.build({ + entryPoints: ['src/index.ts'], + bundle: true, + outfile: 'wwwroot/js/calendar.js', + format: 'esm', + sourcemap: 'inline', + target: 'es2020', + minify: false, + keepNames: true, + platform: 'browser' + }); + + console.log('Bundle created: js/calendar.js'); + + console.log('Build completed successfully!'); + } catch (error) { + console.error('Build failed:', error); + process.exit(1); + } +} + +build(); \ No newline at end of file diff --git a/calendar-complete-specification.md b/calendar-complete-specification.md new file mode 100644 index 0000000..9ddb463 --- /dev/null +++ b/calendar-complete-specification.md @@ -0,0 +1,460 @@ +# Complete Calendar Component Specification + +## 1. Project Overview + +### Purpose +Build a professional calendar component with week, day, and month views, featuring drag-and-drop functionality, event management, and real-time synchronization. + +### Technology Stack +- **Frontend**: Vanilla JavaScript (ES Modules), ready for TypeScript conversion +- **Styling**: CSS with nested selectors, CSS Grid/Flexbox +- **Backend** (planned): .NET Core with SignalR +- **Architecture**: Modular manager-based system with event-driven communication + +### Design Principles +1. **Modularity**: Each manager handles one specific concern +2. **Loose Coupling**: Communication via custom events on document +3. **No External Dependencies**: Pure JavaScript implementation +4. **Custom HTML Tags**: Semantic markup without Web Components registration +5. **CSS-based Positioning**: Events positioned using CSS calc() and variables + +## 2. What Has Been Implemented + +### 2.1 Core Infrastructure + +#### EventBus.js ✅ +- Central event dispatcher for all calendar events +- Publish/subscribe pattern implementation +- Debug logging capabilities +- Event history tracking +- Priority-based listeners + +#### CalendarConfig.js ✅ +- Centralized configuration management +- Default values for all settings +- DOM data-attribute reading +- Computed value calculations (minuteHeight, totalSlots, etc.) +- Configuration change events + +#### EventTypes.js ✅ +- All event type constants defined +- Organized by category (view, CRUD, interaction, UI, data, state) +- Consistent naming convention + +### 2.2 Managers + +#### GridManager.js ✅ +- Renders time axis with configurable hours +- Creates week headers with day names and dates +- Generates day columns for events +- Sets up grid interactions (click, dblclick) +- Updates CSS variables for dynamic styling +- Handles grid click position calculations with snap + +#### DataManager.js ✅ +- Mock data generation for testing +- API request preparation (ready for backend) +- Cache management +- Event CRUD operations +- Loading state management +- Sync status handling + +### 2.3 Utilities + +#### DateUtils.js ✅ +- Week start/end calculations +- Date/time formatting (12/24 hour) +- Duration calculations +- Time-to-minutes conversions +- Week number calculation (ISO standard) +- Snap-to-interval logic + +### 2.4 Styles + +#### base.css ✅ +- CSS reset and variables +- Color scheme definition +- Grid measurements +- Animation keyframes +- Utility classes + +#### layout.css ✅ +- Main calendar container structure +- CSS Grid layout for calendar +- Time axis styling +- Week headers with sticky positioning +- Scrollable content area +- Work hours background indication + +#### navigation.css ✅ +- Top navigation bar layout +- Button styling (prev/next/today) +- View selector (day/week/month) +- Search box with icons +- Week info display + +#### events.css ✅ +- Event card styling by type +- Hover and active states +- Resize handles design +- Multi-day event styling +- Sync status indicators +- CSS-based positioning system + +#### popup.css ✅ +- Event popup styling +- Chevron arrow positioning +- Action buttons +- Loading overlay +- Snap indicators + +### 2.5 HTML Structure ✅ +- Semantic custom HTML tags +- Modular component structure +- No inline styles or JavaScript +- Data attributes for configuration + +## 3. Implementation Details + +### 3.1 Event Positioning System +```css +swp-event { + /* Position via CSS variables */ + top: calc(var(--start-minutes) * var(--minute-height)); + height: calc(var(--duration-minutes) * var(--minute-height)); +} +``` + +### 3.2 Custom Event Flow +```javascript +// Example event flow for drag operation +1. User mousedown on event +2. DragManager → emit('calendar:dragstart') +3. ResizeManager → disable() +4. GridManager → show snap lines +5. User mousemove +6. DragManager → emit('calendar:dragmove') +7. EventRenderer → update ghost position +8. User mouseup +9. DragManager → emit('calendar:dragend') +10. EventManager → update event data +11. DataManager → sync to backend +``` + +### 3.3 Configuration Options +```javascript +{ + view: 'week', // 'day' | 'week' | 'month' + weekDays: 7, // 4-7 days for week view + dayStartHour: 7, // Calendar start time + dayEndHour: 19, // Calendar end time + workStartHour: 8, // Work hours highlighting + workEndHour: 17, + snapInterval: 15, // Minutes: 5, 10, 15, 30, 60 + hourHeight: 60, // Pixels per hour + showCurrentTime: true, + allowDrag: true, + allowResize: true, + allowCreate: true +} +``` + +## 4. What Needs to Be Implemented + +### 4.1 Missing Managers + +#### CalendarManager.js 🔲 +**Purpose**: Main coordinator for all managers +```javascript +class CalendarManager { + - Initialize all managers in correct order + - Handle app lifecycle (start, destroy) + - Coordinate cross-manager operations + - Global error handling + - State persistence +} +``` + +#### ViewManager.js 🔲 +**Purpose**: Handle view mode changes +```javascript +class ViewManager { + - Switch between day/week/month views + - Calculate visible date range + - Update grid structure for view + - Emit view change events + - Handle view-specific settings +} +``` + +#### NavigationManager.js 🔲 +**Purpose**: Handle navigation controls +```javascript +class NavigationManager { + - Previous/Next period navigation + - Today button functionality + - Update week info display + - Coordinate with animations + - Handle navigation limits +} +``` + +#### EventManager.js 🔲 +**Purpose**: Manage event lifecycle +```javascript +class EventManager { + - Store events in memory + - Handle event CRUD operations + - Manage event selection + - Calculate event overlaps + - Validate event constraints +} +``` + +#### EventRenderer.js 🔲 +**Purpose**: Render events in DOM +```javascript +class EventRenderer { + - Create event DOM elements + - Calculate pixel positions + - Handle collision layouts + - Render multi-day events + - Update event appearance +} +``` + +#### DragManager.js 🔲 +**Purpose**: Handle drag operations +```javascript +class DragManager { + - Track drag state + - Create ghost element + - Calculate snap positions + - Validate drop targets + - Handle multi-select drag +} +``` + +#### ResizeManager.js 🔲 +**Purpose**: Handle resize operations +```javascript +class ResizeManager { + - Add/remove resize handles + - Track resize direction + - Calculate new duration + - Enforce min/max limits + - Snap to intervals +} +``` + +#### PopupManager.js 🔲 +**Purpose**: Show event details popup +```javascript +class PopupManager { + - Show/hide popup + - Smart positioning (left/right) + - Update popup content + - Handle action buttons + - Click-outside detection +} +``` + +#### SearchManager.js 🔲 +**Purpose**: Search functionality +```javascript +class SearchManager { + - Real-time search + - Highlight matching events + - Update transparency + - Clear search + - Search history +} +``` + +#### TimeManager.js 🔲 +**Purpose**: Current time indicator +```javascript +class TimeManager { + - Show red line at current time + - Update position every minute + - Auto-scroll to current time + - Show/hide based on view +} +``` + +#### LoadingManager.js 🔲 +**Purpose**: Loading states +```javascript +class LoadingManager { + - Show/hide spinner + - Block interactions + - Show error states + - Progress indication +} +``` + +### 4.2 Missing Utilities + +#### PositionUtils.js 🔲 +```javascript +- pixelsToMinutes(y, config) +- minutesToPixels(minutes, config) +- getEventBounds(element) +- detectCollisions(events) +- calculateOverlapGroups(events) +``` + +#### SnapUtils.js 🔲 +```javascript +- snapToInterval(value, interval) +- getNearestSlot(position, interval) +- calculateSnapPoints(config) +- isValidSnapPosition(position) +``` + +#### DOMUtils.js 🔲 +```javascript +- createElement(tag, attributes, children) +- toggleClass(element, className, force) +- findParent(element, selector) +- batchUpdate(updates) +``` + +### 4.3 Missing Features + +#### Animation System 🔲 +- Week-to-week slide transition (as shown in POC) +- Smooth state transitions +- Drag preview animations +- Loading animations + +#### Collision Detection System 🔲 +```javascript +// Two strategies needed: +1. Side-by-side: Events share column width +2. Overlay: Events stack with z-index +``` + +#### Multi-day Event Support 🔲 +- Events spanning multiple days +- Visual continuation indicators +- Proper positioning in week header area + +#### Touch Support 🔲 +- Touch drag/drop +- Pinch to zoom +- Swipe navigation +- Long press for context menu + +#### Keyboard Navigation 🔲 +- Tab through events +- Arrow keys for selection +- Enter to edit +- Delete key support + +#### Context Menu 🔲 +- Right-click on events +- Right-click on empty slots +- Quick actions menu + +#### Event Creation 🔲 +- Double-click empty slot +- Drag to create +- Default duration +- Inline editing + +#### Advanced Features 🔲 +- Undo/redo stack +- Copy/paste events +- Bulk operations +- Print view +- Export (iCal, PDF) +- Recurring events UI +- Event templates +- Color customization +- Resource scheduling +- Timezone support + +## 5. Integration Points + +### 5.1 Backend API Endpoints +``` +GET /api/events?start={date}&end={date}&view={view} +POST /api/events +PATCH /api/events/{id} +DELETE /api/events/{id} +GET /api/events/search?q={query} +``` + +### 5.2 SignalR Events +``` +- EventCreated +- EventUpdated +- EventDeleted +- EventsReloaded +``` + +### 5.3 Data Models +```typescript +interface CalendarEvent { + id: string; + title: string; + start: string; // ISO 8601 + end: string; // ISO 8601 + type: 'meeting' | 'meal' | 'work' | 'milestone'; + allDay: boolean; + syncStatus: 'synced' | 'pending' | 'error'; + recurringId?: string; + resources?: string[]; + metadata?: Record; +} +``` + +## 6. Performance Considerations + +1. **Virtual Scrolling**: For large date ranges +2. **Event Pooling**: Reuse DOM elements +3. **Throttled Updates**: During drag/resize +4. **Batch Operations**: For multiple changes +5. **Lazy Loading**: Load events as needed +6. **Web Workers**: For heavy calculations + +## 7. Testing Strategy + +1. **Unit Tests**: Each manager/utility +2. **Integration Tests**: Manager interactions +3. **E2E Tests**: User workflows +4. **Performance Tests**: Large datasets +5. **Accessibility Tests**: Keyboard/screen reader + +## 8. Deployment Considerations + +1. **Build Process**: Bundle modules +2. **Minification**: Reduce file size +3. **Code Splitting**: Load on demand +4. **CDN**: Static assets +5. **Monitoring**: Error tracking +6. **Analytics**: Usage patterns + +## 9. Future Enhancements + +1. **AI Integration**: Smart scheduling +2. **Mobile Apps**: Native wrappers +3. **Offline Support**: Service workers +4. **Collaboration**: Real-time cursors +5. **Advanced Analytics**: Usage insights +6. **Third-party Integrations**: Google Calendar, Outlook + +## 10. Migration Path + +### From POC to Production: +1. Extract animation logic from POC +2. Implement missing managers +3. Add error boundaries +4. Implement loading states +5. Add accessibility +6. Performance optimization +7. Security hardening +8. Documentation +9. Testing suite +10. Deployment pipeline \ No newline at end of file diff --git a/calendar-config.js b/calendar-config.js new file mode 100644 index 0000000..b70695b --- /dev/null +++ b/calendar-config.js @@ -0,0 +1,189 @@ +// js/core/CalendarConfig.js + +import { eventBus } from './EventBus.js'; +import { EventTypes } from '../types/EventTypes.js'; + +/** + * Calendar configuration management + */ +export class CalendarConfig { + constructor() { + this.config = { + // View settings + view: 'week', // 'day' | 'week' | 'month' + weekDays: 7, // 4-7 days for week view + firstDayOfWeek: 1, // 0 = Sunday, 1 = Monday + + // Time settings + dayStartHour: 7, // Calendar starts at 7 AM + dayEndHour: 19, // Calendar ends at 7 PM + workStartHour: 8, // Work hours start + workEndHour: 17, // Work hours end + snapInterval: 15, // Minutes: 5, 10, 15, 30, 60 + + // Display settings + hourHeight: 60, // Pixels per hour + showCurrentTime: true, + showWorkHours: true, + + // Interaction settings + allowDrag: true, + allowResize: true, + allowCreate: true, + + // API settings + apiEndpoint: '/api/events', + dateFormat: 'YYYY-MM-DD', + timeFormat: 'HH:mm', + + // Feature flags + enableSearch: true, + enableTouch: true, + + // Event defaults + defaultEventDuration: 60, // Minutes + minEventDuration: null, // Will be same as snapInterval + maxEventDuration: 480 // 8 hours + }; + + // Set computed values + this.config.minEventDuration = this.config.snapInterval; + + // Load from data attributes + this.loadFromDOM(); + } + + /** + * Load configuration from DOM data attributes + */ + loadFromDOM() { + const calendar = document.querySelector('swp-calendar'); + if (!calendar) return; + + // Read data attributes + const attrs = calendar.dataset; + + if (attrs.view) this.config.view = attrs.view; + if (attrs.weekDays) this.config.weekDays = parseInt(attrs.weekDays); + if (attrs.snapInterval) this.config.snapInterval = parseInt(attrs.snapInterval); + if (attrs.dayStartHour) this.config.dayStartHour = parseInt(attrs.dayStartHour); + if (attrs.dayEndHour) this.config.dayEndHour = parseInt(attrs.dayEndHour); + if (attrs.hourHeight) this.config.hourHeight = parseInt(attrs.hourHeight); + } + + /** + * Get a config value + * @param {string} key + * @returns {*} + */ + get(key) { + return this.config[key]; + } + + /** + * Set a config value + * @param {string} key + * @param {*} value + */ + set(key, value) { + const oldValue = this.config[key]; + this.config[key] = value; + + // Update computed values + if (key === 'snapInterval') { + this.config.minEventDuration = value; + } + + // Emit config update event + eventBus.emit(EventTypes.CONFIG_UPDATE, { + key, + value, + oldValue + }); + } + + /** + * Update multiple config values + * @param {Object} updates + */ + update(updates) { + Object.entries(updates).forEach(([key, value]) => { + this.set(key, value); + }); + } + + /** + * Get all config + * @returns {Object} + */ + getAll() { + return { ...this.config }; + } + + /** + * Calculate derived values + */ + + get minuteHeight() { + return this.config.hourHeight / 60; + } + + get totalHours() { + return this.config.dayEndHour - this.config.dayStartHour; + } + + get totalMinutes() { + return this.totalHours * 60; + } + + get slotsPerHour() { + return 60 / this.config.snapInterval; + } + + get totalSlots() { + return this.totalHours * this.slotsPerHour; + } + + get slotHeight() { + return this.config.hourHeight / this.slotsPerHour; + } + + /** + * Validate snap interval + * @param {number} interval + * @returns {boolean} + */ + isValidSnapInterval(interval) { + return [5, 10, 15, 30, 60].includes(interval); + } + + /** + * Get view-specific settings + * @param {string} view + * @returns {Object} + */ + getViewSettings(view = this.config.view) { + const settings = { + day: { + columns: 1, + showAllDay: true, + scrollToHour: 8 + }, + week: { + columns: this.config.weekDays, + showAllDay: true, + scrollToHour: 8 + }, + month: { + columns: 7, + showAllDay: false, + scrollToHour: null + } + }; + + return settings[view] || settings.week; + } +} + +// Create singleton instance +export const calendarConfig = new CalendarConfig(); \ No newline at end of file diff --git a/calendar-data-manager.js b/calendar-data-manager.js new file mode 100644 index 0000000..e5d7632 --- /dev/null +++ b/calendar-data-manager.js @@ -0,0 +1,385 @@ +// js/managers/DataManager.js + +import { eventBus } from '../core/EventBus.js'; +import { EventTypes } from '../types/EventTypes.js'; + +/** + * Manages data fetching and API communication + * Currently uses mock data until backend is implemented + */ +export class DataManager { + constructor() { + this.baseUrl = '/api/events'; + this.useMockData = true; // Toggle this when backend is ready + this.cache = new Map(); + + this.init(); + } + + init() { + this.subscribeToEvents(); + } + + subscribeToEvents() { + // Listen for period changes to fetch new data + eventBus.on(EventTypes.PERIOD_CHANGE, (e) => { + this.fetchEventsForPeriod(e.detail); + }); + + // Listen for event updates + eventBus.on(EventTypes.EVENT_UPDATE, (e) => { + this.updateEvent(e.detail); + }); + + // Listen for event creation + eventBus.on(EventTypes.EVENT_CREATE, (e) => { + this.createEvent(e.detail); + }); + + // Listen for event deletion + eventBus.on(EventTypes.EVENT_DELETE, (e) => { + this.deleteEvent(e.detail.eventId); + }); + } + + /** + * Fetch events for a specific period + * @param {Object} period - Contains start, end, view + */ + async fetchEventsForPeriod(period) { + const cacheKey = `${period.start}-${period.end}-${period.view}`; + + // Check cache first + if (this.cache.has(cacheKey)) { + const cachedData = this.cache.get(cacheKey); + eventBus.emit(EventTypes.DATA_FETCH_SUCCESS, cachedData); + return cachedData; + } + + // Emit loading start + eventBus.emit(EventTypes.DATA_FETCH_START, { period }); + + try { + let data; + + if (this.useMockData) { + // Simulate network delay + await this.delay(300); + data = this.getMockData(period); + } else { + // Real API call + const params = new URLSearchParams({ + start: period.start, + end: period.end, + view: period.view + }); + + const response = await fetch(`${this.baseUrl}?${params}`); + if (!response.ok) throw new Error('Failed to fetch events'); + + data = await response.json(); + } + + // Cache the data + this.cache.set(cacheKey, data); + + // Emit success + eventBus.emit(EventTypes.DATA_FETCH_SUCCESS, data); + + return data; + } catch (error) { + eventBus.emit(EventTypes.DATA_FETCH_ERROR, { error: error.message }); + throw error; + } + } + + /** + * Create a new event + */ + async createEvent(eventData) { + eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'create' }); + + try { + if (this.useMockData) { + await this.delay(200); + const newEvent = { + id: `evt-${Date.now()}`, + ...eventData, + syncStatus: 'synced' + }; + + // Clear cache to force refresh + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'create', + event: newEvent + }); + + return newEvent; + } else { + // Real API call + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(eventData) + }); + + if (!response.ok) throw new Error('Failed to create event'); + + const newEvent = await response.json(); + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'create', + event: newEvent + }); + + return newEvent; + } + } catch (error) { + eventBus.emit(EventTypes.DATA_SYNC_ERROR, { + action: 'create', + error: error.message + }); + throw error; + } + } + + /** + * Update an existing event + */ + async updateEvent(updateData) { + eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'update' }); + + try { + if (this.useMockData) { + await this.delay(200); + + // Clear cache to force refresh + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'update', + eventId: updateData.eventId, + changes: updateData.changes + }); + + return true; + } else { + // Real API call + const response = await fetch(`${this.baseUrl}/${updateData.eventId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData.changes) + }); + + if (!response.ok) throw new Error('Failed to update event'); + + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'update', + eventId: updateData.eventId + }); + + return true; + } + } catch (error) { + eventBus.emit(EventTypes.DATA_SYNC_ERROR, { + action: 'update', + error: error.message, + eventId: updateData.eventId + }); + throw error; + } + } + + /** + * Delete an event + */ + async deleteEvent(eventId) { + eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'delete' }); + + try { + if (this.useMockData) { + await this.delay(200); + + // Clear cache to force refresh + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'delete', + eventId + }); + + return true; + } else { + // Real API call + const response = await fetch(`${this.baseUrl}/${eventId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to delete event'); + + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'delete', + eventId + }); + + return true; + } + } catch (error) { + eventBus.emit(EventTypes.DATA_SYNC_ERROR, { + action: 'delete', + error: error.message, + eventId + }); + throw error; + } + } + + /** + * Generate mock data for testing + */ + getMockData(period) { + const events = []; + const types = ['meeting', 'meal', 'work', 'milestone']; + const titles = { + meeting: ['Team Standup', 'Client Meeting', 'Project Review', 'Sprint Planning', 'Design Review'], + meal: ['Breakfast', 'Lunch', 'Coffee Break', 'Dinner'], + work: ['Deep Work Session', 'Code Review', 'Documentation', 'Testing'], + milestone: ['Project Deadline', 'Release Day', 'Demo Day'] + }; + + // Parse dates + const startDate = new Date(period.start); + const endDate = new Date(period.end); + + // Generate some events for each day + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + // Skip weekends for most events + const dayOfWeek = d.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + if (isWeekend) { + // Maybe one or two events on weekends + if (Math.random() > 0.7) { + const type = 'meal'; + const title = titles[type][Math.floor(Math.random() * titles[type].length)]; + const hour = 12 + Math.floor(Math.random() * 4); + + events.push({ + id: `evt-${events.length + 1}`, + title, + type, + start: `${this.formatDate(d)}T${hour}:00:00`, + end: `${this.formatDate(d)}T${hour + 1}:00:00`, + allDay: false, + syncStatus: 'synced' + }); + } + } else { + // Regular workday events + + // Morning standup + if (Math.random() > 0.3) { + events.push({ + id: `evt-${events.length + 1}`, + title: 'Team Standup', + type: 'meeting', + start: `${this.formatDate(d)}T09:00:00`, + end: `${this.formatDate(d)}T09:30:00`, + allDay: false, + syncStatus: 'synced' + }); + } + + // Lunch + events.push({ + id: `evt-${events.length + 1}`, + title: 'Lunch', + type: 'meal', + start: `${this.formatDate(d)}T12:00:00`, + end: `${this.formatDate(d)}T13:00:00`, + allDay: false, + syncStatus: 'synced' + }); + + // Random afternoon events + const numAfternoonEvents = Math.floor(Math.random() * 3) + 1; + for (let i = 0; i < numAfternoonEvents; i++) { + const type = types[Math.floor(Math.random() * types.length)]; + const title = titles[type][Math.floor(Math.random() * titles[type].length)]; + const startHour = 13 + Math.floor(Math.random() * 4); + const duration = 1 + Math.floor(Math.random() * 2); + + events.push({ + id: `evt-${events.length + 1}`, + title, + type, + start: `${this.formatDate(d)}T${startHour}:${Math.random() > 0.5 ? '00' : '30'}:00`, + end: `${this.formatDate(d)}T${startHour + duration}:00:00`, + allDay: false, + syncStatus: Math.random() > 0.9 ? 'pending' : 'synced' + }); + } + } + } + + // Add a multi-day event + if (period.view === 'week') { + const midWeek = new Date(startDate); + midWeek.setDate(midWeek.getDate() + 2); + + events.push({ + id: `evt-${events.length + 1}`, + title: 'Project Sprint', + type: 'milestone', + start: `${this.formatDate(startDate)}T00:00:00`, + end: `${this.formatDate(midWeek)}T23:59:59`, + allDay: true, + syncStatus: 'synced' + }); + } + + return { + events, + meta: { + start: period.start, + end: period.end, + view: period.view, + total: events.length + } + }; + } + + /** + * Utility methods + */ + + formatDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Clear all cached data + */ + clearCache() { + this.cache.clear(); + } + + /** + * Toggle between mock and real data + */ + setUseMockData(useMock) { + this.useMockData = useMock; + this.clearCache(); + } +} \ No newline at end of file diff --git a/calendar-date-utils.js b/calendar-date-utils.js new file mode 100644 index 0000000..2b39187 --- /dev/null +++ b/calendar-date-utils.js @@ -0,0 +1,231 @@ +// js/utils/DateUtils.js + +/** + * Date and time utility functions + */ +export class DateUtils { + /** + * Get start of week for a given date + * @param {Date} date + * @param {number} firstDayOfWeek - 0 = Sunday, 1 = Monday + * @returns {Date} + */ + static getWeekStart(date, firstDayOfWeek = 1) { + const d = new Date(date); + const day = d.getDay(); + const diff = (day - firstDayOfWeek + 7) % 7; + d.setDate(d.getDate() - diff); + d.setHours(0, 0, 0, 0); + return d; + } + + /** + * Get end of week for a given date + * @param {Date} date + * @param {number} firstDayOfWeek + * @returns {Date} + */ + static getWeekEnd(date, firstDayOfWeek = 1) { + const start = this.getWeekStart(date, firstDayOfWeek); + const end = new Date(start); + end.setDate(end.getDate() + 6); + end.setHours(23, 59, 59, 999); + return end; + } + + /** + * Format date to YYYY-MM-DD + * @param {Date} date + * @returns {string} + */ + static formatDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + /** + * Format time to HH:MM + * @param {Date} date + * @returns {string} + */ + static formatTime(date) { + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + } + + /** + * Format time to 12-hour format + * @param {Date} date + * @returns {string} + */ + static formatTime12(date) { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + + return `${displayHours}:${String(minutes).padStart(2, '0')} ${period}`; + } + + /** + * Convert minutes since midnight to time string + * @param {number} minutes + * @returns {string} + */ + static minutesToTime(minutes) { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + + return `${displayHours}:${String(mins).padStart(2, '0')} ${period}`; + } + + /** + * Convert time string to minutes since midnight + * @param {string} timeStr - Format: "HH:MM" or "HH:MM:SS" + * @returns {number} + */ + static timeToMinutes(timeStr) { + const [time] = timeStr.split('T').pop().split('.'); + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; + } + + /** + * Get minutes since start of day + * @param {Date|string} date + * @returns {number} + */ + static getMinutesSinceMidnight(date) { + const d = typeof date === 'string' ? new Date(date) : date; + return d.getHours() * 60 + d.getMinutes(); + } + + /** + * Calculate duration in minutes between two dates + * @param {Date|string} start + * @param {Date|string} end + * @returns {number} + */ + static getDurationMinutes(start, end) { + const startDate = typeof start === 'string' ? new Date(start) : start; + const endDate = typeof end === 'string' ? new Date(end) : end; + return Math.floor((endDate - startDate) / 60000); + } + + /** + * Check if date is today + * @param {Date} date + * @returns {boolean} + */ + static isToday(date) { + const today = new Date(); + return date.toDateString() === today.toDateString(); + } + + /** + * Check if two dates are on the same day + * @param {Date} date1 + * @param {Date} date2 + * @returns {boolean} + */ + static isSameDay(date1, date2) { + return date1.toDateString() === date2.toDateString(); + } + + /** + * Check if event spans multiple days + * @param {Date|string} start + * @param {Date|string} end + * @returns {boolean} + */ + static isMultiDay(start, end) { + const startDate = typeof start === 'string' ? new Date(start) : start; + const endDate = typeof end === 'string' ? new Date(end) : end; + return !this.isSameDay(startDate, endDate); + } + + /** + * Get day name + * @param {Date} date + * @param {string} format - 'short' or 'long' + * @returns {string} + */ + static getDayName(date, format = 'short') { + const days = { + short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + long: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + }; + return days[format][date.getDay()]; + } + + /** + * Add days to date + * @param {Date} date + * @param {number} days + * @returns {Date} + */ + static addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } + + /** + * Add minutes to date + * @param {Date} date + * @param {number} minutes + * @returns {Date} + */ + static addMinutes(date, minutes) { + const result = new Date(date); + result.setMinutes(result.getMinutes() + minutes); + return result; + } + + /** + * Snap time to nearest interval + * @param {Date} date + * @param {number} intervalMinutes + * @returns {Date} + */ + static snapToInterval(date, intervalMinutes) { + const minutes = date.getMinutes(); + const snappedMinutes = Math.round(minutes / intervalMinutes) * intervalMinutes; + const result = new Date(date); + result.setMinutes(snappedMinutes); + result.setSeconds(0); + result.setMilliseconds(0); + return result; + } + + /** + * Get current time in minutes since day start + * @param {number} dayStartHour + * @returns {number} + */ + static getCurrentTimeMinutes(dayStartHour = 0) { + const now = new Date(); + const minutesSinceMidnight = now.getHours() * 60 + now.getMinutes(); + return minutesSinceMidnight - (dayStartHour * 60); + } + + /** + * Format duration to human readable string + * @param {number} minutes + * @returns {string} + */ + static formatDuration(minutes) { + if (minutes < 60) { + return `${minutes} min`; + } + + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (mins === 0) { + return `${hours} hour${hours > 1 ? 's' : ''}`; + } + + return `${hours} hour${hours > 1 ? 's' : ''} ${mins} min`; + } +} \ No newline at end of file diff --git a/calendar-event-types.js b/calendar-event-types.js new file mode 100644 index 0000000..86fd73d --- /dev/null +++ b/calendar-event-types.js @@ -0,0 +1,73 @@ +// js/types/EventTypes.js + +/** + * Calendar event type constants + */ +export const EventTypes = { + // View events + VIEW_CHANGE: 'calendar:viewchange', + VIEW_RENDERED: 'calendar:viewrendered', + PERIOD_CHANGE: 'calendar:periodchange', + + // Event CRUD + EVENT_CREATE: 'calendar:eventcreate', + EVENT_UPDATE: 'calendar:eventupdate', + EVENT_DELETE: 'calendar:eventdelete', + EVENT_RENDERED: 'calendar:eventrendered', + EVENT_SELECTED: 'calendar:eventselected', + + // Interaction events + DRAG_START: 'calendar:dragstart', + DRAG_MOVE: 'calendar:dragmove', + DRAG_END: 'calendar:dragend', + DRAG_CANCEL: 'calendar:dragcancel', + + RESIZE_START: 'calendar:resizestart', + RESIZE_MOVE: 'calendar:resizemove', + RESIZE_END: 'calendar:resizeend', + RESIZE_CANCEL: 'calendar:resizecancel', + + // UI events + POPUP_SHOW: 'calendar:popupshow', + POPUP_HIDE: 'calendar:popuphide', + + SEARCH_START: 'calendar:searchstart', + SEARCH_UPDATE: 'calendar:searchupdate', + SEARCH_CLEAR: 'calendar:searchclear', + + // Grid events + GRID_CLICK: 'calendar:gridclick', + GRID_DBLCLICK: 'calendar:griddblclick', + GRID_RENDERED: 'calendar:gridrendered', + + // Data events + DATA_FETCH_START: 'calendar:datafetchstart', + DATA_FETCH_SUCCESS: 'calendar:datafetchsuccess', + DATA_FETCH_ERROR: 'calendar:datafetcherror', + DATA_SYNC_START: 'calendar:datasyncstart', + DATA_SYNC_SUCCESS: 'calendar:datasyncsuccess', + DATA_SYNC_ERROR: 'calendar:datasyncerror', + + // State events + STATE_UPDATE: 'calendar:stateupdate', + CONFIG_UPDATE: 'calendar:configupdate', + + // Time events + TIME_UPDATE: 'calendar:timeupdate', + + // Navigation events + NAV_PREV: 'calendar:navprev', + NAV_NEXT: 'calendar:navnext', + NAV_TODAY: 'calendar:navtoday', + + // Loading events + LOADING_START: 'calendar:loadingstart', + LOADING_END: 'calendar:loadingend', + + // Error events + ERROR: 'calendar:error', + + // Init events + READY: 'calendar:ready', + DESTROY: 'calendar:destroy' +}; \ No newline at end of file diff --git a/calendar-eventbus.js b/calendar-eventbus.js new file mode 100644 index 0000000..e13ec15 --- /dev/null +++ b/calendar-eventbus.js @@ -0,0 +1,118 @@ +// js/core/EventBus.js + +/** + * Central event dispatcher for calendar using DOM CustomEvents + * Provides logging and debugging capabilities + */ +export class EventBus { + constructor() { + this.eventLog = []; + this.debug = false; // Set to true for console logging + this.listeners = new Set(); // Track listeners for cleanup + } + + /** + * Subscribe to an event via DOM addEventListener + * @param {string} eventType + * @param {Function} handler + * @param {Object} options + * @returns {Function} Unsubscribe function + */ + on(eventType, handler, options = {}) { + document.addEventListener(eventType, handler, options); + + // Track for cleanup + this.listeners.add({ eventType, handler, options }); + + // Return unsubscribe function + return () => this.off(eventType, handler); + } + + /** + * Subscribe to an event once + * @param {string} eventType + * @param {Function} handler + */ + once(eventType, handler) { + return this.on(eventType, handler, { once: true }); + } + + /** + * Unsubscribe from an event + * @param {string} eventType + * @param {Function} handler + */ + off(eventType, handler) { + document.removeEventListener(eventType, handler); + + // Remove from tracking + for (const listener of this.listeners) { + if (listener.eventType === eventType && listener.handler === handler) { + this.listeners.delete(listener); + break; + } + } + } + + /** + * Emit an event via DOM CustomEvent + * @param {string} eventType + * @param {*} detail + * @returns {boolean} Whether event was cancelled + */ + emit(eventType, detail = {}) { + const event = new CustomEvent(eventType, { + detail, + bubbles: true, + cancelable: true + }); + + // Log event + if (this.debug) { + console.log(`📢 Event: ${eventType}`, detail); + } + + this.eventLog.push({ + type: eventType, + detail, + timestamp: Date.now() + }); + + // Emit on document (only DOM events now) + return !document.dispatchEvent(event); + } + + /** + * Get event history + * @param {string} eventType Optional filter by type + * @returns {Array} + */ + getEventLog(eventType = null) { + if (eventType) { + return this.eventLog.filter(e => e.type === eventType); + } + return this.eventLog; + } + + /** + * Enable/disable debug mode + * @param {boolean} enabled + */ + setDebug(enabled) { + this.debug = enabled; + } + + /** + * Clean up all tracked listeners + */ + destroy() { + for (const listener of this.listeners) { + document.removeEventListener(listener.eventType, listener.handler); + } + this.listeners.clear(); + this.eventLog = []; + } +} + +// Create singleton instance +export const eventBus = new EventBus(); \ No newline at end of file diff --git a/calendar-grid-manager.js b/calendar-grid-manager.js new file mode 100644 index 0000000..a244898 --- /dev/null +++ b/calendar-grid-manager.js @@ -0,0 +1,334 @@ +// js/managers/GridManager.js + +import { eventBus } from '../core/EventBus.js'; +import { calendarConfig } from '../core/CalendarConfig.js'; +import { EventTypes } from '../types/EventTypes.js'; +import { DateUtils } from '../utils/DateUtils.js'; + +/** + * Manages the calendar grid structure + */ +export class GridManager { + constructor() { + this.container = null; + this.timeAxis = null; + this.weekHeader = null; + this.timeGrid = null; + this.dayColumns = null; + this.currentWeek = null; + + this.init(); + } + + init() { + this.findElements(); + this.subscribeToEvents(); + } + + findElements() { + this.container = document.querySelector('swp-calendar-container'); + this.timeAxis = document.querySelector('swp-time-axis'); + this.weekHeader = document.querySelector('swp-week-header'); + this.timeGrid = document.querySelector('swp-time-grid'); + this.scrollableContent = document.querySelector('swp-scrollable-content'); + } + + subscribeToEvents() { + // Re-render grid on config changes + eventBus.on(EventTypes.CONFIG_UPDATE, (e) => { + if (['dayStartHour', 'dayEndHour', 'hourHeight', 'view', 'weekDays'].includes(e.detail.key)) { + this.render(); + } + }); + + // Re-render on view change + eventBus.on(EventTypes.VIEW_CHANGE, () => { + this.render(); + }); + + // Re-render on period change + eventBus.on(EventTypes.PERIOD_CHANGE, (e) => { + this.currentWeek = e.detail.week; + this.renderHeaders(); + }); + + // Handle grid clicks + this.setupGridInteractions(); + } + + /** + * Render the complete grid structure + */ + render() { + this.renderTimeAxis(); + this.renderHeaders(); + this.renderGrid(); + this.renderGridLines(); + + // Emit grid rendered event + eventBus.emit(EventTypes.GRID_RENDERED); + } + + /** + * Render time axis (left side hours) + */ + renderTimeAxis() { + if (!this.timeAxis) return; + + const startHour = calendarConfig.get('dayStartHour'); + const endHour = calendarConfig.get('dayEndHour'); + + this.timeAxis.innerHTML = ''; + + for (let hour = startHour; hour <= endHour; hour++) { + const marker = document.createElement('swp-hour-marker'); + marker.textContent = this.formatHour(hour); + marker.dataset.hour = hour; + this.timeAxis.appendChild(marker); + } + } + + /** + * Render week headers + */ + renderHeaders() { + if (!this.weekHeader || !this.currentWeek) return; + + const view = calendarConfig.get('view'); + const weekDays = calendarConfig.get('weekDays'); + + this.weekHeader.innerHTML = ''; + + if (view === 'week') { + const dates = this.getWeekDates(this.currentWeek); + const daysToShow = dates.slice(0, weekDays); + + daysToShow.forEach((date, index) => { + const header = document.createElement('swp-day-header'); + header.innerHTML = ` + ${this.getDayName(date)} + ${date.getDate()} + `; + header.dataset.date = this.formatDate(date); + header.dataset.dayIndex = index; + + // Mark today + if (this.isToday(date)) { + header.dataset.today = 'true'; + } + + this.weekHeader.appendChild(header); + }); + } + } + + /** + * Render the main grid structure + */ + renderGrid() { + if (!this.timeGrid) return; + + // Clear existing columns + let dayColumns = this.timeGrid.querySelector('swp-day-columns'); + if (!dayColumns) { + dayColumns = document.createElement('swp-day-columns'); + this.timeGrid.appendChild(dayColumns); + } + + dayColumns.innerHTML = ''; + + const view = calendarConfig.get('view'); + const columnsCount = view === 'week' ? calendarConfig.get('weekDays') : 1; + + // Create columns + for (let i = 0; i < columnsCount; i++) { + const column = document.createElement('swp-day-column'); + column.dataset.columnIndex = i; + + if (this.currentWeek) { + const dates = this.getWeekDates(this.currentWeek); + if (dates[i]) { + column.dataset.date = this.formatDate(dates[i]); + } + } + + // Add events container + const eventsLayer = document.createElement('swp-events-layer'); + column.appendChild(eventsLayer); + + dayColumns.appendChild(column); + } + + this.dayColumns = dayColumns; + this.updateGridStyles(); + } + + /** + * Render grid lines + */ + renderGridLines() { + let gridLines = this.timeGrid.querySelector('swp-grid-lines'); + if (!gridLines) { + gridLines = document.createElement('swp-grid-lines'); + this.timeGrid.insertBefore(gridLines, this.timeGrid.firstChild); + } + + const totalHours = calendarConfig.totalHours; + const hourHeight = calendarConfig.get('hourHeight'); + + // Set CSS variables + this.timeGrid.style.setProperty('--total-hours', totalHours); + this.timeGrid.style.setProperty('--hour-height', `${hourHeight}px`); + + // Grid lines are handled by CSS + } + + /** + * Update grid CSS variables + */ + updateGridStyles() { + const root = document.documentElement; + const config = calendarConfig.getAll(); + + // Set CSS variables + root.style.setProperty('--hour-height', `${config.hourHeight}px`); + root.style.setProperty('--minute-height', `${config.hourHeight / 60}px`); + root.style.setProperty('--snap-interval', config.snapInterval); + root.style.setProperty('--day-start-hour', config.dayStartHour); + root.style.setProperty('--day-end-hour', config.dayEndHour); + root.style.setProperty('--work-start-hour', config.workStartHour); + root.style.setProperty('--work-end-hour', config.workEndHour); + + // Set grid height + const totalHeight = calendarConfig.totalHours * config.hourHeight; + if (this.timeGrid) { + this.timeGrid.style.height = `${totalHeight}px`; + } + } + + /** + * Setup grid interaction handlers + */ + setupGridInteractions() { + if (!this.timeGrid) return; + + // Click handler + this.timeGrid.addEventListener('click', (e) => { + // Ignore if clicking on an event + if (e.target.closest('swp-event')) return; + + const column = e.target.closest('swp-day-column'); + if (!column) return; + + const position = this.getClickPosition(e, column); + + eventBus.emit(EventTypes.GRID_CLICK, { + date: column.dataset.date, + time: position.time, + minutes: position.minutes, + columnIndex: parseInt(column.dataset.columnIndex) + }); + }); + + // Double click handler + this.timeGrid.addEventListener('dblclick', (e) => { + // Ignore if clicking on an event + if (e.target.closest('swp-event')) return; + + const column = e.target.closest('swp-day-column'); + if (!column) return; + + const position = this.getClickPosition(e, column); + + eventBus.emit(EventTypes.GRID_DBLCLICK, { + date: column.dataset.date, + time: position.time, + minutes: position.minutes, + columnIndex: parseInt(column.dataset.columnIndex) + }); + }); + } + + /** + * Get click position in grid + */ + getClickPosition(event, column) { + const rect = column.getBoundingClientRect(); + const y = event.clientY - rect.top + this.scrollableContent.scrollTop; + + const minuteHeight = calendarConfig.minuteHeight; + const snapInterval = calendarConfig.get('snapInterval'); + const dayStartHour = calendarConfig.get('dayStartHour'); + + // Calculate minutes from start of day + let minutes = Math.floor(y / minuteHeight); + + // Snap to interval + minutes = Math.round(minutes / snapInterval) * snapInterval; + + // Add day start offset + const totalMinutes = (dayStartHour * 60) + minutes; + + return { + minutes: totalMinutes, + time: this.minutesToTime(totalMinutes), + y: minutes * minuteHeight + }; + } + + /** + * Utility methods + */ + + formatHour(hour) { + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); + return `${displayHour} ${period}`; + } + + formatDate(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + getDayName(date) { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + return days[date.getDay()]; + } + + getWeekDates(weekStart) { + const dates = []; + for (let i = 0; i < 7; i++) { + const date = new Date(weekStart); + date.setDate(weekStart.getDate() + i); + dates.push(date); + } + return dates; + } + + isToday(date) { + const today = new Date(); + return date.toDateString() === today.toDateString(); + } + + minutesToTime(totalMinutes) { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); + + return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`; + } + + /** + * Scroll to specific hour + */ + scrollToHour(hour) { + if (!this.scrollableContent) return; + + const hourHeight = calendarConfig.get('hourHeight'); + const dayStartHour = calendarConfig.get('dayStartHour'); + const scrollTop = (hour - dayStartHour) * hourHeight; + + this.scrollableContent.scrollTop = scrollTop; + } +} \ No newline at end of file diff --git a/calendar-poc-single-file.html b/calendar-poc-single-file.html new file mode 100644 index 0000000..42e163c --- /dev/null +++ b/calendar-poc-single-file.html @@ -0,0 +1,1066 @@ + + + + + + Calendar Week View - POC + + + + + + + + + + Today + + + + Week 3 + Jan 15 - Jan 21, 2024 + + + + + + + + + + + + + + + Day + Week + Month + + Day + Week + Month + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f59fa9b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,435 @@ +{ + "name": "calendar-plantempus", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calendar-plantempus", + "version": "1.0.0", + "devDependencies": { + "esbuild": "^0.19.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..556d15a --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "calendar-plantempus", + "version": "1.0.0", + "description": "Professional calendar component with TypeScript", + "type": "module", + "scripts": { + "build": "node build.js", + "build-simple": "esbuild src/**/*.ts --outdir=js --format=esm --sourcemap=inline --target=es2020", + "watch": "esbuild src/**/*.ts --outdir=js --format=esm --sourcemap=inline --target=es2020 --watch", + "clean": "powershell -Command \"if (Test-Path js) { Remove-Item -Recurse -Force js }\"" + }, + "devDependencies": { + "esbuild": "^0.19.0", + "typescript": "^5.0.0" + } +} \ No newline at end of file diff --git a/src/constants/EventTypes.ts b/src/constants/EventTypes.ts new file mode 100644 index 0000000..74e94e9 --- /dev/null +++ b/src/constants/EventTypes.ts @@ -0,0 +1,98 @@ +// Calendar event type constants + +/** + * Calendar event type constants for DOM CustomEvents + */ +export const EventTypes = { + // View events + VIEW_CHANGE: 'calendar:viewchange', + VIEW_RENDERED: 'calendar:viewrendered', + PERIOD_CHANGE: 'calendar:periodchange', + + // Event CRUD + EVENT_CREATE: 'calendar:eventcreate', + EVENT_CREATED: 'calendar:eventcreated', + EVENT_UPDATE: 'calendar:eventupdate', + EVENT_UPDATED: 'calendar:eventupdated', + EVENT_DELETE: 'calendar:eventdelete', + EVENT_DELETED: 'calendar:eventdeleted', + EVENT_RENDERED: 'calendar:eventrendered', + EVENT_SELECTED: 'calendar:eventselected', + EVENTS_LOADED: 'calendar:eventsloaded', + + // Interaction events + DRAG_START: 'calendar:dragstart', + DRAG_MOVE: 'calendar:dragmove', + DRAG_END: 'calendar:dragend', + DRAG_CANCEL: 'calendar:dragcancel', + + RESIZE_START: 'calendar:resizestart', + RESIZE_MOVE: 'calendar:resizemove', + RESIZE_END: 'calendar:resizeend', + RESIZE_CANCEL: 'calendar:resizecancel', + + // UI events + POPUP_SHOW: 'calendar:popupshow', + POPUP_HIDE: 'calendar:popuphide', + + SEARCH_START: 'calendar:searchstart', + SEARCH_UPDATE: 'calendar:searchupdate', + SEARCH_CLEAR: 'calendar:searchclear', + + // Grid events + GRID_CLICK: 'calendar:gridclick', + GRID_DBLCLICK: 'calendar:griddblclick', + GRID_RENDERED: 'calendar:gridrendered', + + // Data events + DATA_FETCH_START: 'calendar:datafetchstart', + DATA_FETCH_SUCCESS: 'calendar:datafetchsuccess', + DATA_FETCH_ERROR: 'calendar:datafetcherror', + DATA_SYNC_START: 'calendar:datasyncstart', + DATA_SYNC_SUCCESS: 'calendar:datasyncsuccess', + DATA_SYNC_ERROR: 'calendar:datasyncerror', + + // State events + STATE_UPDATE: 'calendar:stateupdate', + CONFIG_UPDATE: 'calendar:configupdate', + + // Time events + TIME_UPDATE: 'calendar:timeupdate', + + // Navigation events + NAV_PREV: 'calendar:navprev', + NAV_NEXT: 'calendar:navnext', + NAV_TODAY: 'calendar:navtoday', + NAVIGATE_TO_DATE: 'calendar:navigatetodate', + WEEK_CHANGED: 'calendar:weekchanged', + WEEK_INFO_UPDATED: 'calendar:weekinfoupdated', + WEEK_CONTAINER_CREATED: 'calendar:weekcontainercreated', + + // Loading events + LOADING_START: 'calendar:loadingstart', + LOADING_END: 'calendar:loadingend', + + // Error events + ERROR: 'calendar:error', + + // Init events + READY: 'calendar:ready', + DESTROY: 'calendar:destroy', + + // Calendar Manager Events + CALENDAR_INITIALIZING: 'calendar:initializing', + CALENDAR_INITIALIZED: 'calendar:initialized', + VIEW_CHANGED: 'calendar:viewchanged', + DATE_CHANGED: 'calendar:datechanged', + CALENDAR_REFRESH_REQUESTED: 'calendar:refreshrequested', + CALENDAR_RESET: 'calendar:reset', + VIEW_CHANGE_REQUESTED: 'calendar:viewchangerequested', + NAVIGATE_TO_TODAY: 'calendar:navigatetotoday', + NAVIGATE_NEXT: 'calendar:navigatenext', + NAVIGATE_PREVIOUS: 'calendar:navigateprevious', + REFRESH_REQUESTED: 'calendar:refreshrequested', + RESET_REQUESTED: 'calendar:resetrequested' +} as const; + +// Type for event type values +export type EventType = typeof EventTypes[keyof typeof EventTypes]; \ No newline at end of file diff --git a/src/core/CalendarConfig.ts b/src/core/CalendarConfig.ts new file mode 100644 index 0000000..93d37ab --- /dev/null +++ b/src/core/CalendarConfig.ts @@ -0,0 +1,191 @@ +// Calendar configuration management + +import { eventBus } from './EventBus'; +import { EventTypes } from '../constants/EventTypes'; +import { CalendarConfig as ICalendarConfig, ViewType } from '../types/CalendarTypes'; + +/** + * View-specific settings interface + */ +interface ViewSettings { + columns: number; + showAllDay: boolean; + scrollToHour: number | null; +} + +/** + * Calendar configuration management + */ +export class CalendarConfig { + private config: ICalendarConfig; + + constructor() { + this.config = { + // View settings + view: 'week', // 'day' | 'week' | 'month' + weekDays: 7, // 4-7 days for week view + firstDayOfWeek: 1, // 0 = Sunday, 1 = Monday + + // Time settings + dayStartHour: 7, // Calendar starts at 7 AM + dayEndHour: 19, // Calendar ends at 7 PM + workStartHour: 8, // Work hours start + workEndHour: 17, // Work hours end + snapInterval: 15, // Minutes: 5, 10, 15, 30, 60 + + // Display settings + hourHeight: 60, // Pixels per hour + showCurrentTime: true, + showWorkHours: true, + + // Interaction settings + allowDrag: true, + allowResize: true, + allowCreate: true, + + // API settings + apiEndpoint: '/api/events', + dateFormat: 'YYYY-MM-DD', + timeFormat: 'HH:mm', + + // Feature flags + enableSearch: true, + enableTouch: true, + + // Event defaults + defaultEventDuration: 60, // Minutes + minEventDuration: 15, // Will be same as snapInterval + maxEventDuration: 480 // 8 hours + }; + + // Set computed values + this.config.minEventDuration = this.config.snapInterval; + + // Load from data attributes + this.loadFromDOM(); + } + + /** + * Load configuration from DOM data attributes + */ + private loadFromDOM(): void { + const calendar = document.querySelector('swp-calendar') as HTMLElement; + if (!calendar) return; + + // Read data attributes + const attrs = calendar.dataset; + + if (attrs.view) this.config.view = attrs.view as ViewType; + if (attrs.weekDays) this.config.weekDays = parseInt(attrs.weekDays); + if (attrs.snapInterval) this.config.snapInterval = parseInt(attrs.snapInterval); + if (attrs.dayStartHour) this.config.dayStartHour = parseInt(attrs.dayStartHour); + if (attrs.dayEndHour) this.config.dayEndHour = parseInt(attrs.dayEndHour); + if (attrs.hourHeight) this.config.hourHeight = parseInt(attrs.hourHeight); + } + + /** + * Get a config value + */ + get(key: K): ICalendarConfig[K] { + return this.config[key]; + } + + /** + * Set a config value + */ + set(key: K, value: ICalendarConfig[K]): void { + const oldValue = this.config[key]; + this.config[key] = value; + + // Update computed values + if (key === 'snapInterval') { + this.config.minEventDuration = value as number; + } + + // Emit config update event + eventBus.emit(EventTypes.CONFIG_UPDATE, { + key, + value, + oldValue + }); + } + + /** + * Update multiple config values + */ + update(updates: Partial): void { + Object.entries(updates).forEach(([key, value]) => { + this.set(key as keyof ICalendarConfig, value); + }); + } + + /** + * Get all config + */ + getAll(): ICalendarConfig { + return { ...this.config }; + } + + /** + * Calculate derived values + */ + + get minuteHeight(): number { + return this.config.hourHeight / 60; + } + + get totalHours(): number { + return this.config.dayEndHour - this.config.dayStartHour; + } + + get totalMinutes(): number { + return this.totalHours * 60; + } + + get slotsPerHour(): number { + return 60 / this.config.snapInterval; + } + + get totalSlots(): number { + return this.totalHours * this.slotsPerHour; + } + + get slotHeight(): number { + return this.config.hourHeight / this.slotsPerHour; + } + + /** + * Validate snap interval + */ + isValidSnapInterval(interval: number): boolean { + return [5, 10, 15, 30, 60].includes(interval); + } + + /** + * Get view-specific settings + */ + getViewSettings(view: ViewType = this.config.view): ViewSettings { + const settings: Record = { + day: { + columns: 1, + showAllDay: true, + scrollToHour: 8 + }, + week: { + columns: this.config.weekDays, + showAllDay: true, + scrollToHour: 8 + }, + month: { + columns: 7, + showAllDay: false, + scrollToHour: null + } + }; + + return settings[view] || settings.week; + } +} + +// Create singleton instance +export const calendarConfig = new CalendarConfig(); \ No newline at end of file diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts new file mode 100644 index 0000000..aa81bed --- /dev/null +++ b/src/core/EventBus.ts @@ -0,0 +1,103 @@ +// Core EventBus using pure DOM CustomEvents +import { EventLogEntry, ListenerEntry, IEventBus } from '../types/CalendarTypes'; + +/** + * Central event dispatcher for calendar using DOM CustomEvents + * Provides logging and debugging capabilities + */ +export class EventBus implements IEventBus { + private eventLog: EventLogEntry[] = []; + private debug: boolean = false; + private listeners: Set = new Set(); + + /** + * Subscribe to an event via DOM addEventListener + */ + on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void { + document.addEventListener(eventType, handler, options); + + // Track for cleanup + this.listeners.add({ eventType, handler, options }); + + // Return unsubscribe function + return () => this.off(eventType, handler); + } + + /** + * Subscribe to an event once + */ + once(eventType: string, handler: EventListener): () => void { + return this.on(eventType, handler, { once: true }); + } + + /** + * Unsubscribe from an event + */ + off(eventType: string, handler: EventListener): void { + document.removeEventListener(eventType, handler); + + // Remove from tracking + for (const listener of this.listeners) { + if (listener.eventType === eventType && listener.handler === handler) { + this.listeners.delete(listener); + break; + } + } + } + + /** + * Emit an event via DOM CustomEvent + */ + emit(eventType: string, detail: any = {}): boolean { + const event = new CustomEvent(eventType, { + detail, + bubbles: true, + cancelable: true + }); + + // Log event + if (this.debug) { + console.log(`📢 Event: ${eventType}`, detail); + } + + this.eventLog.push({ + type: eventType, + detail, + timestamp: Date.now() + }); + + // Emit on document (only DOM events now) + return !document.dispatchEvent(event); + } + + /** + * Get event history + */ + getEventLog(eventType?: string): EventLogEntry[] { + if (eventType) { + return this.eventLog.filter(e => e.type === eventType); + } + return this.eventLog; + } + + /** + * Enable/disable debug mode + */ + setDebug(enabled: boolean): void { + this.debug = enabled; + } + + /** + * Clean up all tracked listeners + */ + destroy(): void { + for (const listener of this.listeners) { + document.removeEventListener(listener.eventType, listener.handler); + } + this.listeners.clear(); + this.eventLog = []; + } +} + +// Create singleton instance +export const eventBus = new EventBus(); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4c6cf39 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,50 @@ +// Main entry point for Calendar Plantempus +import { eventBus } from './core/EventBus.js'; +import { CalendarManager } from './managers/CalendarManager.js'; +import { NavigationManager } from './managers/NavigationManager.js'; +import { ViewManager } from './managers/ViewManager.js'; +import { EventManager } from './managers/EventManager.js'; +import { EventRenderer } from './managers/EventRenderer.js'; +import { CalendarConfig } from './core/CalendarConfig.js'; + +/** + * Initialize the calendar application + */ +function initializeCalendar(): void { + console.log('🗓️ Initializing Calendar Plantempus...'); + + // Create calendar configuration + const config = new CalendarConfig(); + + // Initialize managers + const calendarManager = new CalendarManager(eventBus, config); + const navigationManager = new NavigationManager(eventBus); + const viewManager = new ViewManager(eventBus); + const eventManager = new EventManager(eventBus); + const eventRenderer = new EventRenderer(eventBus); + + // Enable debug mode for development + eventBus.setDebug(true); + + // Initialize all managers + calendarManager.initialize(); + + console.log('✅ Calendar Plantempus initialized successfully with all core managers'); + + // Expose to window for debugging + (window as any).calendarDebug = { + eventBus, + calendarManager, + navigationManager, + viewManager, + eventManager, + eventRenderer + }; +} + +// Initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeCalendar); +} else { + initializeCalendar(); +} \ No newline at end of file diff --git a/src/managers/CalendarManager.ts b/src/managers/CalendarManager.ts new file mode 100644 index 0000000..b316950 --- /dev/null +++ b/src/managers/CalendarManager.ts @@ -0,0 +1,256 @@ +import { EventBus } from '../core/EventBus.js'; +import { EventTypes } from '../constants/EventTypes.js'; +import { CalendarConfig } from '../core/CalendarConfig.js'; +import { CalendarEvent, CalendarView, IEventBus } from '../types/CalendarTypes.js'; + +/** + * CalendarManager - Hovedkoordinator for alle calendar managers + * Håndterer initialisering, koordinering og kommunikation mellem alle managers + */ +export class CalendarManager { + private eventBus: IEventBus; + private config: CalendarConfig; + private currentView: CalendarView = 'week'; + private currentDate: Date = new Date(); + private isInitialized: boolean = false; + + constructor(eventBus: IEventBus, config: CalendarConfig) { + this.eventBus = eventBus; + this.config = config; + this.setupEventListeners(); + } + + /** + * Initialiser calendar systemet + */ + public initialize(): void { + if (this.isInitialized) { + console.warn('CalendarManager is already initialized'); + return; + } + + console.log('Initializing CalendarManager...'); + + // Emit initialization event + this.eventBus.emit(EventTypes.CALENDAR_INITIALIZING, { + view: this.currentView, + date: this.currentDate, + config: this.config + }); + + // Set initial view and date + this.setView(this.currentView); + this.setCurrentDate(this.currentDate); + + this.isInitialized = true; + + // Emit initialization complete event + this.eventBus.emit(EventTypes.CALENDAR_INITIALIZED, { + view: this.currentView, + date: this.currentDate + }); + + console.log('CalendarManager initialized successfully'); + } + + /** + * Skift calendar view (dag/uge/måned) + */ + public setView(view: CalendarView): void { + if (this.currentView === view) { + return; + } + + const previousView = this.currentView; + this.currentView = view; + + console.log(`Changing view from ${previousView} to ${view}`); + + // Emit view change event + this.eventBus.emit(EventTypes.VIEW_CHANGED, { + previousView, + currentView: view, + date: this.currentDate + }); + } + + /** + * Sæt aktuel dato + */ + public setCurrentDate(date: Date): void { + const previousDate = this.currentDate; + this.currentDate = new Date(date); + + console.log(`Changing date from ${previousDate.toISOString()} to ${date.toISOString()}`); + + // Emit date change event + this.eventBus.emit(EventTypes.DATE_CHANGED, { + previousDate, + currentDate: this.currentDate, + view: this.currentView + }); + } + + /** + * Naviger til i dag + */ + public goToToday(): void { + this.setCurrentDate(new Date()); + } + + /** + * Naviger til næste periode (dag/uge/måned afhængig af view) + */ + public goToNext(): void { + const nextDate = this.calculateNextDate(); + this.setCurrentDate(nextDate); + } + + /** + * Naviger til forrige periode (dag/uge/måned afhængig af view) + */ + public goToPrevious(): void { + const previousDate = this.calculatePreviousDate(); + this.setCurrentDate(previousDate); + } + + /** + * Hent aktuel view + */ + public getCurrentView(): CalendarView { + return this.currentView; + } + + /** + * Hent aktuel dato + */ + public getCurrentDate(): Date { + return new Date(this.currentDate); + } + + /** + * Hent calendar konfiguration + */ + public getConfig(): CalendarConfig { + return this.config; + } + + /** + * Check om calendar er initialiseret + */ + public isCalendarInitialized(): boolean { + return this.isInitialized; + } + + /** + * Genindlæs calendar data + */ + public refresh(): void { + console.log('Refreshing calendar...'); + + this.eventBus.emit(EventTypes.CALENDAR_REFRESH_REQUESTED, { + view: this.currentView, + date: this.currentDate + }); + } + + /** + * Ryd calendar og nulstil til standard tilstand + */ + public reset(): void { + console.log('Resetting calendar...'); + + this.currentView = 'week'; + this.currentDate = new Date(); + + this.eventBus.emit(EventTypes.CALENDAR_RESET, { + view: this.currentView, + date: this.currentDate + }); + } + + /** + * Setup event listeners for at håndtere events fra andre managers + */ + private setupEventListeners(): void { + // Lyt efter navigation events + this.eventBus.on(EventTypes.NAVIGATE_TO_DATE, (event) => { + const customEvent = event as CustomEvent; + const { date } = customEvent.detail; + this.setCurrentDate(new Date(date)); + }); + + // Lyt efter view change requests + this.eventBus.on(EventTypes.VIEW_CHANGE_REQUESTED, (event) => { + const customEvent = event as CustomEvent; + const { view } = customEvent.detail; + this.setView(view); + }); + + // Lyt efter today navigation + this.eventBus.on(EventTypes.NAVIGATE_TO_TODAY, () => { + this.goToToday(); + }); + + // Lyt efter next/previous navigation + this.eventBus.on(EventTypes.NAVIGATE_NEXT, () => { + this.goToNext(); + }); + + this.eventBus.on(EventTypes.NAVIGATE_PREVIOUS, () => { + this.goToPrevious(); + }); + + // Lyt efter refresh requests + this.eventBus.on(EventTypes.REFRESH_REQUESTED, () => { + this.refresh(); + }); + + // Lyt efter reset requests + this.eventBus.on(EventTypes.RESET_REQUESTED, () => { + this.reset(); + }); + } + + /** + * Beregn næste dato baseret på aktuel view + */ + private calculateNextDate(): Date { + const nextDate = new Date(this.currentDate); + + switch (this.currentView) { + case 'day': + nextDate.setDate(nextDate.getDate() + 1); + break; + case 'week': + nextDate.setDate(nextDate.getDate() + 7); + break; + case 'month': + nextDate.setMonth(nextDate.getMonth() + 1); + break; + } + + return nextDate; + } + + /** + * Beregn forrige dato baseret på aktuel view + */ + private calculatePreviousDate(): Date { + const previousDate = new Date(this.currentDate); + + switch (this.currentView) { + case 'day': + previousDate.setDate(previousDate.getDate() - 1); + break; + case 'week': + previousDate.setDate(previousDate.getDate() - 7); + break; + case 'month': + previousDate.setMonth(previousDate.getMonth() - 1); + break; + } + + return previousDate; + } +} \ No newline at end of file diff --git a/src/managers/DataManager.ts b/src/managers/DataManager.ts new file mode 100644 index 0000000..50e4e38 --- /dev/null +++ b/src/managers/DataManager.ts @@ -0,0 +1,414 @@ +// Data management and API communication + +import { eventBus } from '../core/EventBus'; +import { EventTypes } from '../constants/EventTypes'; +import { CalendarEvent, EventData, Period, EventType } from '../types/CalendarTypes'; + +/** + * Event creation data interface + */ +interface EventCreateData { + title: string; + type: EventType; + start: string; + end: string; + allDay: boolean; + description?: string; +} + +/** + * Event update data interface + */ +interface EventUpdateData { + eventId: string; + changes: Partial; +} + +/** + * Manages data fetching and API communication + * Currently uses mock data until backend is implemented + */ +export class DataManager { + private baseUrl: string = '/api/events'; + private useMockData: boolean = true; // Toggle this when backend is ready + private cache: Map = new Map(); + + constructor() { + this.init(); + } + + private init(): void { + this.subscribeToEvents(); + } + + private subscribeToEvents(): void { + // Listen for period changes to fetch new data + eventBus.on(EventTypes.PERIOD_CHANGE, (e: Event) => { + this.fetchEventsForPeriod((e as CustomEvent).detail); + }); + + // Listen for event updates + eventBus.on(EventTypes.EVENT_UPDATE, (e: Event) => { + this.updateEvent((e as CustomEvent).detail); + }); + + // Listen for event creation + eventBus.on(EventTypes.EVENT_CREATE, (e: Event) => { + this.createEvent((e as CustomEvent).detail); + }); + + // Listen for event deletion + eventBus.on(EventTypes.EVENT_DELETE, (e: Event) => { + this.deleteEvent((e as CustomEvent).detail.eventId); + }); + } + + /** + * Fetch events for a specific period + */ + async fetchEventsForPeriod(period: Period): Promise { + const cacheKey = `${period.start}-${period.end}-${period.view}`; + + // Check cache first + if (this.cache.has(cacheKey)) { + const cachedData = this.cache.get(cacheKey)!; + eventBus.emit(EventTypes.DATA_FETCH_SUCCESS, cachedData); + return cachedData; + } + + // Emit loading start + eventBus.emit(EventTypes.DATA_FETCH_START, { period }); + + try { + let data: EventData; + + if (this.useMockData) { + // Simulate network delay + await this.delay(300); + data = this.getMockData(period); + } else { + // Real API call + const params = new URLSearchParams({ + start: period.start, + end: period.end, + view: period.view + }); + + const response = await fetch(`${this.baseUrl}?${params}`); + if (!response.ok) throw new Error('Failed to fetch events'); + + data = await response.json(); + } + + // Cache the data + this.cache.set(cacheKey, data); + + // Emit success + eventBus.emit(EventTypes.DATA_FETCH_SUCCESS, data); + + return data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + eventBus.emit(EventTypes.DATA_FETCH_ERROR, { error: errorMessage }); + throw error; + } + } + + /** + * Create a new event + */ + async createEvent(eventData: EventCreateData): Promise { + eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'create' }); + + try { + if (this.useMockData) { + await this.delay(200); + const newEvent: CalendarEvent = { + id: `evt-${Date.now()}`, + title: eventData.title, + start: eventData.start, + end: eventData.end, + type: eventData.type, + allDay: eventData.allDay, + syncStatus: 'synced', + metadata: eventData.description ? { description: eventData.description } : undefined + }; + + // Clear cache to force refresh + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'create', + event: newEvent + }); + + return newEvent; + } else { + // Real API call + const response = await fetch(this.baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(eventData) + }); + + if (!response.ok) throw new Error('Failed to create event'); + + const newEvent = await response.json(); + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'create', + event: newEvent + }); + + return newEvent; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + eventBus.emit(EventTypes.DATA_SYNC_ERROR, { + action: 'create', + error: errorMessage + }); + throw error; + } + } + + /** + * Update an existing event + */ + async updateEvent(updateData: EventUpdateData): Promise { + eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'update' }); + + try { + if (this.useMockData) { + await this.delay(200); + + // Clear cache to force refresh + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'update', + eventId: updateData.eventId, + changes: updateData.changes + }); + + return true; + } else { + // Real API call + const response = await fetch(`${this.baseUrl}/${updateData.eventId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData.changes) + }); + + if (!response.ok) throw new Error('Failed to update event'); + + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'update', + eventId: updateData.eventId + }); + + return true; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + eventBus.emit(EventTypes.DATA_SYNC_ERROR, { + action: 'update', + error: errorMessage, + eventId: updateData.eventId + }); + throw error; + } + } + + /** + * Delete an event + */ + async deleteEvent(eventId: string): Promise { + eventBus.emit(EventTypes.DATA_SYNC_START, { action: 'delete' }); + + try { + if (this.useMockData) { + await this.delay(200); + + // Clear cache to force refresh + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'delete', + eventId + }); + + return true; + } else { + // Real API call + const response = await fetch(`${this.baseUrl}/${eventId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Failed to delete event'); + + this.cache.clear(); + + eventBus.emit(EventTypes.DATA_SYNC_SUCCESS, { + action: 'delete', + eventId + }); + + return true; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + eventBus.emit(EventTypes.DATA_SYNC_ERROR, { + action: 'delete', + error: errorMessage, + eventId + }); + throw error; + } + } + + /** + * Generate mock data for testing + */ + private getMockData(period: Period): EventData { + const events: CalendarEvent[] = []; + const types: EventType[] = ['meeting', 'meal', 'work', 'milestone']; + const titles: Record = { + meeting: ['Team Standup', 'Client Meeting', 'Project Review', 'Sprint Planning', 'Design Review'], + meal: ['Breakfast', 'Lunch', 'Coffee Break', 'Dinner'], + work: ['Deep Work Session', 'Code Review', 'Documentation', 'Testing'], + milestone: ['Project Deadline', 'Release Day', 'Demo Day'] + }; + + // Parse dates + const startDate = new Date(period.start); + const endDate = new Date(period.end); + + // Generate some events for each day + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + // Skip weekends for most events + const dayOfWeek = d.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + if (isWeekend) { + // Maybe one or two events on weekends + if (Math.random() > 0.7) { + const type: EventType = 'meal'; + const title = titles[type][Math.floor(Math.random() * titles[type].length)]; + const hour = 12 + Math.floor(Math.random() * 4); + + events.push({ + id: `evt-${events.length + 1}`, + title, + type, + start: `${this.formatDate(d)}T${hour}:00:00`, + end: `${this.formatDate(d)}T${hour + 1}:00:00`, + allDay: false, + syncStatus: 'synced' + }); + } + } else { + // Regular workday events + + // Morning standup + if (Math.random() > 0.3) { + events.push({ + id: `evt-${events.length + 1}`, + title: 'Team Standup', + type: 'meeting', + start: `${this.formatDate(d)}T09:00:00`, + end: `${this.formatDate(d)}T09:30:00`, + allDay: false, + syncStatus: 'synced' + }); + } + + // Lunch + events.push({ + id: `evt-${events.length + 1}`, + title: 'Lunch', + type: 'meal', + start: `${this.formatDate(d)}T12:00:00`, + end: `${this.formatDate(d)}T13:00:00`, + allDay: false, + syncStatus: 'synced' + }); + + // Random afternoon events + const numAfternoonEvents = Math.floor(Math.random() * 3) + 1; + for (let i = 0; i < numAfternoonEvents; i++) { + const type = types[Math.floor(Math.random() * types.length)]; + const title = titles[type][Math.floor(Math.random() * titles[type].length)]; + const startHour = 13 + Math.floor(Math.random() * 4); + const duration = 1 + Math.floor(Math.random() * 2); + + events.push({ + id: `evt-${events.length + 1}`, + title, + type, + start: `${this.formatDate(d)}T${startHour}:${Math.random() > 0.5 ? '00' : '30'}:00`, + end: `${this.formatDate(d)}T${startHour + duration}:00:00`, + allDay: false, + syncStatus: Math.random() > 0.9 ? 'pending' : 'synced' + }); + } + } + } + + // Add a multi-day event + if (period.view === 'week') { + const midWeek = new Date(startDate); + midWeek.setDate(midWeek.getDate() + 2); + + events.push({ + id: `evt-${events.length + 1}`, + title: 'Project Sprint', + type: 'milestone', + start: `${this.formatDate(startDate)}T00:00:00`, + end: `${this.formatDate(midWeek)}T23:59:59`, + allDay: true, + syncStatus: 'synced' + }); + } + + return { + events, + meta: { + start: period.start, + end: period.end, + view: period.view, + total: events.length + } + }; + } + + /** + * Utility methods + */ + + private formatDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Clear all cached data + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Toggle between mock and real data + */ + setUseMockData(useMock: boolean): void { + this.useMockData = useMock; + this.clearCache(); + } +} \ No newline at end of file diff --git a/src/managers/EventManager.ts b/src/managers/EventManager.ts new file mode 100644 index 0000000..6635a43 --- /dev/null +++ b/src/managers/EventManager.ts @@ -0,0 +1,227 @@ +import { EventBus } from '../core/EventBus'; +import { IEventBus, CalendarEvent } from '../types/CalendarTypes'; +import { EventTypes } from '../constants/EventTypes'; + +/** + * EventManager - Administrerer event lifecycle og CRUD operationer + * Håndterer mock data og event synchronization + */ +export class EventManager { + private eventBus: IEventBus; + private events: CalendarEvent[] = []; + + constructor(eventBus: IEventBus) { + this.eventBus = eventBus; + this.setupEventListeners(); + this.loadMockData(); + } + + private setupEventListeners(): void { + this.eventBus.on(EventTypes.CALENDAR_INITIALIZED, () => { + this.syncEvents(); + }); + + this.eventBus.on(EventTypes.DATE_CHANGED, () => { + this.syncEvents(); + }); + + this.eventBus.on(EventTypes.VIEW_RENDERED, () => { + this.syncEvents(); + }); + } + + private loadMockData(): void { + // Mock events baseret på POC data med korrekt CalendarEvent struktur + this.events = [ + { + id: '1', + title: 'Team Standup', + start: '2024-01-15T09:00:00', + end: '2024-01-15T09:30:00', + type: 'meeting', + allDay: false, + syncStatus: 'synced', + metadata: { day: 1, duration: 30 } + }, + { + id: '2', + title: 'Client Meeting', + start: '2024-01-15T14:00:00', + end: '2024-01-15T15:30:00', + type: 'meeting', + allDay: false, + syncStatus: 'synced', + metadata: { day: 1, duration: 90 } + }, + { + id: '3', + title: 'Lunch', + start: '2024-01-15T12:00:00', + end: '2024-01-15T13:00:00', + type: 'meal', + allDay: false, + syncStatus: 'synced', + metadata: { day: 1, duration: 60 } + }, + { + id: '4', + title: 'Deep Work Session', + start: '2024-01-16T10:00:00', + end: '2024-01-16T12:00:00', + type: 'work', + allDay: false, + syncStatus: 'synced', + metadata: { day: 2, duration: 120 } + }, + { + id: '5', + title: 'Team Standup', + start: '2024-01-16T09:00:00', + end: '2024-01-16T09:30:00', + type: 'meeting', + allDay: false, + syncStatus: 'synced', + metadata: { day: 2, duration: 30 } + }, + { + id: '6', + title: 'Lunch', + start: '2024-01-16T12:30:00', + end: '2024-01-16T13:30:00', + type: 'meal', + allDay: false, + syncStatus: 'synced', + metadata: { day: 2, duration: 60 } + }, + { + id: '7', + title: 'Project Review', + start: '2024-01-17T15:00:00', + end: '2024-01-17T16:00:00', + type: 'meeting', + allDay: false, + syncStatus: 'synced', + metadata: { day: 3, duration: 60 } + }, + { + id: '8', + title: 'Lunch', + start: '2024-01-17T12:00:00', + end: '2024-01-17T13:00:00', + type: 'meal', + allDay: false, + syncStatus: 'synced', + metadata: { day: 3, duration: 60 } + }, + { + id: '9', + title: 'Sprint Planning', + start: '2024-01-18T10:00:00', + end: '2024-01-18T12:00:00', + type: 'meeting', + allDay: false, + syncStatus: 'synced', + metadata: { day: 4, duration: 120 } + }, + { + id: '10', + title: 'Coffee Break', + start: '2024-01-18T15:00:00', + end: '2024-01-18T15:30:00', + type: 'meal', + allDay: false, + syncStatus: 'synced', + metadata: { day: 4, duration: 30 } + }, + { + id: '11', + title: 'Documentation', + start: '2024-01-19T13:00:00', + end: '2024-01-19T16:00:00', + type: 'work', + allDay: false, + syncStatus: 'synced', + metadata: { day: 5, duration: 180 } + } + ]; + + console.log(`EventManager: Loaded ${this.events.length} mock events`); + } + + private syncEvents(): void { + // Emit events for rendering + this.eventBus.emit(EventTypes.EVENTS_LOADED, { + events: this.events + }); + + console.log(`EventManager: Synced ${this.events.length} events`); + } + + public getEvents(): CalendarEvent[] { + return [...this.events]; + } + + public getEventsByDay(day: number): CalendarEvent[] { + return this.events.filter(event => event.metadata?.day === day); + } + + public getEventById(id: string): CalendarEvent | undefined { + return this.events.find(event => event.id === id); + } + + public addEvent(event: Omit): CalendarEvent { + const newEvent: CalendarEvent = { + ...event, + id: Date.now().toString() + }; + + this.events.push(newEvent); + this.syncEvents(); + + this.eventBus.emit(EventTypes.EVENT_CREATED, { + event: newEvent + }); + + return newEvent; + } + + public updateEvent(id: string, updates: Partial): CalendarEvent | null { + const eventIndex = this.events.findIndex(event => event.id === id); + if (eventIndex === -1) return null; + + const updatedEvent = { ...this.events[eventIndex], ...updates }; + this.events[eventIndex] = updatedEvent; + + this.syncEvents(); + + this.eventBus.emit(EventTypes.EVENT_UPDATED, { + event: updatedEvent + }); + + return updatedEvent; + } + + public deleteEvent(id: string): boolean { + const eventIndex = this.events.findIndex(event => event.id === id); + if (eventIndex === -1) return false; + + const deletedEvent = this.events[eventIndex]; + this.events.splice(eventIndex, 1); + + this.syncEvents(); + + this.eventBus.emit(EventTypes.EVENT_DELETED, { + event: deletedEvent + }); + + return true; + } + + public refresh(): void { + this.syncEvents(); + } + + public destroy(): void { + this.events = []; + } +} \ No newline at end of file diff --git a/src/managers/EventRenderer.ts b/src/managers/EventRenderer.ts new file mode 100644 index 0000000..e4ff7dc --- /dev/null +++ b/src/managers/EventRenderer.ts @@ -0,0 +1,177 @@ +import { EventBus } from '../core/EventBus'; +import { IEventBus, CalendarEvent } from '../types/CalendarTypes'; +import { EventTypes } from '../constants/EventTypes'; +import { calendarConfig } from '../core/CalendarConfig'; + +/** + * EventRenderer - Render events i DOM med positionering + * Håndterer event positioning og overlap detection + */ +export class EventRenderer { + private eventBus: IEventBus; + + constructor(eventBus: IEventBus) { + this.eventBus = eventBus; + this.setupEventListeners(); + } + + private setupEventListeners(): void { + this.eventBus.on(EventTypes.EVENTS_LOADED, (event: Event) => { + const customEvent = event as CustomEvent; + const { events } = customEvent.detail; + this.renderEvents(events); + }); + + this.eventBus.on(EventTypes.VIEW_RENDERED, () => { + // Clear existing events when view changes + this.clearEvents(); + }); + } + + private renderEvents(events: CalendarEvent[]): void { + console.log(`EventRenderer: Rendering ${events.length} events`); + + // Clear existing events first + this.clearEvents(); + + // Group events by day for better rendering + const eventsByDay = this.groupEventsByDay(events); + + // Render events for each day + Object.entries(eventsByDay).forEach(([dayIndex, dayEvents]) => { + this.renderDayEvents(parseInt(dayIndex), dayEvents); + }); + + this.eventBus.emit(EventTypes.EVENT_RENDERED, { + count: events.length + }); + } + + private groupEventsByDay(events: CalendarEvent[]): Record { + const grouped: Record = {}; + + events.forEach(event => { + const day = event.metadata?.day || 0; + if (!grouped[day]) { + grouped[day] = []; + } + grouped[day].push(event); + }); + + return grouped; + } + + private renderDayEvents(dayIndex: number, events: CalendarEvent[]): void { + const dayColumns = document.querySelectorAll('swp-day-column'); + const dayColumn = dayColumns[dayIndex]; + if (!dayColumn) { + console.warn(`EventRenderer: Day column ${dayIndex} not found`); + return; + } + + const eventsLayer = dayColumn.querySelector('swp-events-layer'); + if (!eventsLayer) { + console.warn(`EventRenderer: Events layer not found for day ${dayIndex}`); + return; + } + + // Sort events by start time + const sortedEvents = events.sort((a, b) => a.start.localeCompare(b.start)); + + sortedEvents.forEach(event => { + this.renderEvent(event, eventsLayer); + }); + } + + 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.top = `${position.top}px`; + eventElement.style.height = `${position.height}px`; + + // Format time for display + const startTime = this.formatTime(event.start); + const endTime = this.formatTime(event.end); + + // Create event content + eventElement.innerHTML = ` + ${startTime} - ${endTime} + ${event.title} + `; + + // Add event listeners + this.addEventListeners(eventElement, event); + + container.appendChild(eventElement); + } + + private calculateEventPosition(event: CalendarEvent): { top: number; height: number } { + const startDate = new Date(event.start); + const endDate = new Date(event.end); + + const startHour = calendarConfig.get('dayStartHour'); + const hourHeight = calendarConfig.get('hourHeight'); + + // Calculate minutes from day start + const startMinutes = (startDate.getHours() - startHour) * 60 + startDate.getMinutes(); + const duration = (endDate.getTime() - startDate.getTime()) / (1000 * 60); // Duration in minutes + + // Convert to pixels + 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 + eventElement.addEventListener('mouseenter', () => { + eventElement.style.zIndex = '20'; + }); + + eventElement.addEventListener('mouseleave', () => { + eventElement.style.zIndex = '10'; + }); + } + + private clearEvents(): void { + const eventsLayers = document.querySelectorAll('swp-events-layer'); + eventsLayers.forEach(layer => { + layer.innerHTML = ''; + }); + } + + public refresh(): void { + // Request fresh events from EventManager + this.eventBus.emit(EventTypes.REFRESH_REQUESTED); + } + + public destroy(): void { + this.clearEvents(); + } +} \ No newline at end of file diff --git a/src/managers/GridManager.ts b/src/managers/GridManager.ts new file mode 100644 index 0000000..f14f868 --- /dev/null +++ b/src/managers/GridManager.ts @@ -0,0 +1,348 @@ +// Grid structure management + +import { eventBus } from '../core/EventBus'; +import { calendarConfig } from '../core/CalendarConfig'; +import { EventTypes } from '../constants/EventTypes'; +import { DateUtils } from '../utils/DateUtils'; + +/** + * Grid position interface + */ +interface GridPosition { + minutes: number; + time: string; + y: number; +} + +/** + * Manages the calendar grid structure + */ +export class GridManager { + private container: HTMLElement | null = null; + private timeAxis: HTMLElement | null = null; + private weekHeader: HTMLElement | null = null; + private timeGrid: HTMLElement | null = null; + private dayColumns: HTMLElement | null = null; + private scrollableContent: HTMLElement | null = null; + private currentWeek: Date | null = null; + + constructor() { + this.init(); + } + + private init(): void { + this.findElements(); + this.subscribeToEvents(); + } + + private findElements(): void { + this.container = document.querySelector('swp-calendar-container'); + this.timeAxis = document.querySelector('swp-time-axis'); + this.weekHeader = document.querySelector('swp-week-header'); + this.timeGrid = document.querySelector('swp-time-grid'); + this.scrollableContent = document.querySelector('swp-scrollable-content'); + } + + private subscribeToEvents(): void { + // Re-render grid on config changes + eventBus.on(EventTypes.CONFIG_UPDATE, (e: Event) => { + const detail = (e as CustomEvent).detail; + if (['dayStartHour', 'dayEndHour', 'hourHeight', 'view', 'weekDays'].includes(detail.key)) { + this.render(); + } + }); + + // Re-render on view change + eventBus.on(EventTypes.VIEW_CHANGE, () => { + this.render(); + }); + + // Re-render on period change + eventBus.on(EventTypes.PERIOD_CHANGE, (e: Event) => { + const detail = (e as CustomEvent).detail; + this.currentWeek = detail.week; + this.renderHeaders(); + }); + + // Handle grid clicks + this.setupGridInteractions(); + } + + /** + * Render the complete grid structure + */ + render(): void { + this.renderTimeAxis(); + this.renderHeaders(); + this.renderGrid(); + this.renderGridLines(); + + // Emit grid rendered event + eventBus.emit(EventTypes.GRID_RENDERED); + } + + /** + * Render time axis (left side hours) + */ + private renderTimeAxis(): void { + if (!this.timeAxis) return; + + const startHour = calendarConfig.get('dayStartHour'); + const endHour = calendarConfig.get('dayEndHour'); + + this.timeAxis.innerHTML = ''; + + for (let hour = startHour; hour <= endHour; hour++) { + const marker = document.createElement('swp-hour-marker'); + marker.textContent = this.formatHour(hour); + (marker as any).dataset.hour = hour; + this.timeAxis.appendChild(marker); + } + } + + /** + * Render week headers + */ + private renderHeaders(): void { + if (!this.weekHeader || !this.currentWeek) return; + + const view = calendarConfig.get('view'); + const weekDays = calendarConfig.get('weekDays'); + + this.weekHeader.innerHTML = ''; + + if (view === 'week') { + const dates = this.getWeekDates(this.currentWeek); + const daysToShow = dates.slice(0, weekDays); + + daysToShow.forEach((date, index) => { + const header = document.createElement('swp-day-header'); + header.innerHTML = ` + ${this.getDayName(date)} + ${date.getDate()} + `; + (header as any).dataset.date = this.formatDate(date); + (header as any).dataset.dayIndex = index; + + // Mark today + if (this.isToday(date)) { + (header as any).dataset.today = 'true'; + } + + this.weekHeader!.appendChild(header); + }); + } + } + + /** + * Render the main grid structure + */ + private renderGrid(): void { + if (!this.timeGrid) return; + + // Clear existing columns + let dayColumns = this.timeGrid.querySelector('swp-day-columns'); + if (!dayColumns) { + dayColumns = document.createElement('swp-day-columns'); + this.timeGrid.appendChild(dayColumns); + } + + dayColumns.innerHTML = ''; + + const view = calendarConfig.get('view'); + const columnsCount = view === 'week' ? calendarConfig.get('weekDays') : 1; + + // Create columns + for (let i = 0; i < columnsCount; i++) { + const column = document.createElement('swp-day-column'); + (column as any).dataset.columnIndex = i; + + if (this.currentWeek) { + const dates = this.getWeekDates(this.currentWeek); + if (dates[i]) { + (column as any).dataset.date = this.formatDate(dates[i]); + } + } + + // Add events container + const eventsLayer = document.createElement('swp-events-layer'); + column.appendChild(eventsLayer); + + dayColumns.appendChild(column); + } + + this.dayColumns = dayColumns as HTMLElement; + this.updateGridStyles(); + } + + /** + * Render grid lines + */ + private renderGridLines(): void { + if (!this.timeGrid) return; + + let gridLines = this.timeGrid.querySelector('swp-grid-lines'); + if (!gridLines) { + gridLines = document.createElement('swp-grid-lines'); + this.timeGrid.insertBefore(gridLines, this.timeGrid.firstChild); + } + + const totalHours = calendarConfig.totalHours; + const hourHeight = calendarConfig.get('hourHeight'); + + // Set CSS variables + this.timeGrid.style.setProperty('--total-hours', totalHours.toString()); + this.timeGrid.style.setProperty('--hour-height', `${hourHeight}px`); + + // Grid lines are handled by CSS + } + + /** + * Update grid CSS variables + */ + private updateGridStyles(): void { + const root = document.documentElement; + const config = calendarConfig.getAll(); + + // Set CSS variables + root.style.setProperty('--hour-height', `${config.hourHeight}px`); + root.style.setProperty('--minute-height', `${config.hourHeight / 60}px`); + root.style.setProperty('--snap-interval', config.snapInterval.toString()); + root.style.setProperty('--day-start-hour', config.dayStartHour.toString()); + root.style.setProperty('--day-end-hour', config.dayEndHour.toString()); + root.style.setProperty('--work-start-hour', config.workStartHour.toString()); + root.style.setProperty('--work-end-hour', config.workEndHour.toString()); + + // Set grid height + const totalHeight = calendarConfig.totalHours * config.hourHeight; + if (this.timeGrid) { + this.timeGrid.style.height = `${totalHeight}px`; + } + } + + /** + * Setup grid interaction handlers + */ + private setupGridInteractions(): void { + if (!this.timeGrid) return; + + // Click handler + this.timeGrid.addEventListener('click', (e: MouseEvent) => { + // Ignore if clicking on an event + if ((e.target as Element).closest('swp-event')) return; + + const column = (e.target as Element).closest('swp-day-column') as HTMLElement; + if (!column) return; + + const position = this.getClickPosition(e, column); + + eventBus.emit(EventTypes.GRID_CLICK, { + date: (column as any).dataset.date, + time: position.time, + minutes: position.minutes, + columnIndex: parseInt((column as any).dataset.columnIndex) + }); + }); + + // Double click handler + this.timeGrid.addEventListener('dblclick', (e: MouseEvent) => { + // Ignore if clicking on an event + if ((e.target as Element).closest('swp-event')) return; + + const column = (e.target as Element).closest('swp-day-column') as HTMLElement; + if (!column) return; + + const position = this.getClickPosition(e, column); + + eventBus.emit(EventTypes.GRID_DBLCLICK, { + date: (column as any).dataset.date, + time: position.time, + minutes: position.minutes, + columnIndex: parseInt((column as any).dataset.columnIndex) + }); + }); + } + + /** + * Get click position in grid + */ + private getClickPosition(event: MouseEvent, column: HTMLElement): GridPosition { + const rect = column.getBoundingClientRect(); + const y = event.clientY - rect.top + (this.scrollableContent?.scrollTop || 0); + + const minuteHeight = calendarConfig.minuteHeight; + const snapInterval = calendarConfig.get('snapInterval'); + const dayStartHour = calendarConfig.get('dayStartHour'); + + // Calculate minutes from start of day + let minutes = Math.floor(y / minuteHeight); + + // Snap to interval + minutes = Math.round(minutes / snapInterval) * snapInterval; + + // Add day start offset + const totalMinutes = (dayStartHour * 60) + minutes; + + return { + minutes: totalMinutes, + time: this.minutesToTime(totalMinutes), + y: minutes * minuteHeight + }; + } + + /** + * 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; + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); + + return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`; + } + + /** + * Scroll to specific hour + */ + scrollToHour(hour: number): void { + if (!this.scrollableContent) return; + + const hourHeight = calendarConfig.get('hourHeight'); + const dayStartHour = calendarConfig.get('dayStartHour'); + const scrollTop = (hour - dayStartHour) * hourHeight; + + this.scrollableContent.scrollTop = scrollTop; + } +} \ No newline at end of file diff --git a/src/managers/NavigationManager.ts b/src/managers/NavigationManager.ts new file mode 100644 index 0000000..6d01405 --- /dev/null +++ b/src/managers/NavigationManager.ts @@ -0,0 +1,239 @@ +import { IEventBus } from '../types/CalendarTypes.js'; +import { DateUtils } from '../utils/DateUtils.js'; +import { EventTypes } from '../constants/EventTypes.js'; + +/** + * NavigationManager handles calendar navigation (prev/next/today buttons) + * and week transitions with smooth animations + */ +export class NavigationManager { + private eventBus: IEventBus; + private currentWeek: Date; + private targetWeek: Date; + private animationQueue: number = 0; + + constructor(eventBus: IEventBus) { + this.eventBus = eventBus; + this.currentWeek = DateUtils.getWeekStart(new Date(), 0); // Sunday start like POC + this.targetWeek = new Date(this.currentWeek); + this.init(); + } + + private init(): void { + this.setupEventListeners(); + this.updateWeekInfo(); + } + + private setupEventListeners(): void { + // Listen for navigation button clicks + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const navButton = target.closest('[data-action]') as HTMLElement; + + if (!navButton) return; + + const action = navButton.dataset.action; + + switch (action) { + case 'prev': + this.navigateToPreviousWeek(); + break; + case 'next': + this.navigateToNextWeek(); + break; + case 'today': + this.navigateToToday(); + break; + } + }); + + // Listen for external navigation requests + this.eventBus.on(EventTypes.NAVIGATE_TO_DATE, (event: Event) => { + const customEvent = event as CustomEvent; + const targetDate = new Date(customEvent.detail.date); + this.navigateToDate(targetDate); + }); + } + + private navigateToPreviousWeek(): void { + this.targetWeek.setDate(this.targetWeek.getDate() - 7); + const weekToShow = new Date(this.targetWeek); + this.animationQueue++; + this.animateTransition('prev', weekToShow); + } + + private navigateToNextWeek(): void { + this.targetWeek.setDate(this.targetWeek.getDate() + 7); + const weekToShow = new Date(this.targetWeek); + this.animationQueue++; + this.animateTransition('next', weekToShow); + } + + private navigateToToday(): void { + const today = new Date(); + const todayWeekStart = DateUtils.getWeekStart(today, 0); + + // Reset to today + this.targetWeek = new Date(todayWeekStart); + + const currentTime = this.currentWeek.getTime(); + const targetTime = todayWeekStart.getTime(); + + if (currentTime < targetTime) { + this.animationQueue++; + this.animateTransition('next', todayWeekStart); + } else if (currentTime > targetTime) { + this.animationQueue++; + this.animateTransition('prev', todayWeekStart); + } + } + + private navigateToDate(date: Date): void { + const weekStart = DateUtils.getWeekStart(date, 0); + this.targetWeek = new Date(weekStart); + + const currentTime = this.currentWeek.getTime(); + const targetTime = weekStart.getTime(); + + if (currentTime < targetTime) { + this.animationQueue++; + this.animateTransition('next', weekStart); + } else if (currentTime > targetTime) { + this.animationQueue++; + this.animateTransition('prev', weekStart); + } + } + + private animateTransition(direction: 'prev' | 'next', targetWeek: Date): void { + const container = document.querySelector('swp-calendar-container'); + const currentWeekContainer = document.querySelector('swp-week-container'); + + if (!container || !currentWeekContainer) { + console.warn('NavigationManager: Required DOM elements not found'); + return; + } + + // Create new week container + const newWeekContainer = document.createElement('swp-week-container'); + newWeekContainer.innerHTML = ` + + + + + + + + `; + + // Position new week off-screen + newWeekContainer.style.position = 'absolute'; + newWeekContainer.style.top = '0'; + newWeekContainer.style.left = '0'; + newWeekContainer.style.width = '100%'; + newWeekContainer.style.height = '100%'; + newWeekContainer.style.transform = direction === 'next' ? 'translateX(100%)' : 'translateX(-100%)'; + + // Add to container + container.appendChild(newWeekContainer); + + // Notify other managers to render content for the new week + this.eventBus.emit(EventTypes.WEEK_CONTAINER_CREATED, { + container: newWeekContainer, + weekStart: targetWeek + }); + + // Animate transition + requestAnimationFrame(() => { + // Slide out current week + (currentWeekContainer as HTMLElement).style.transform = direction === 'next' ? 'translateX(-100%)' : 'translateX(100%)'; + (currentWeekContainer as HTMLElement).style.opacity = '0.5'; + + // Slide in new week + newWeekContainer.style.transform = 'translateX(0)'; + + // Clean up after animation + setTimeout(() => { + currentWeekContainer.remove(); + newWeekContainer.style.position = 'relative'; + + // Update currentWeek only after animation is complete + this.currentWeek = new Date(targetWeek); + this.animationQueue--; + + // If this was the last queued animation, ensure we're in sync + if (this.animationQueue === 0) { + this.currentWeek = new Date(this.targetWeek); + } + + // Update week info and notify other managers + this.updateWeekInfo(); + this.eventBus.emit(EventTypes.WEEK_CHANGED, { + weekStart: this.currentWeek, + weekEnd: DateUtils.addDays(this.currentWeek, 6) + }); + + }, 400); // Match CSS transition duration + }); + } + + private updateWeekInfo(): void { + const weekNumber = DateUtils.getWeekNumber(this.currentWeek); + const weekEnd = DateUtils.addDays(this.currentWeek, 6); + const dateRange = DateUtils.formatDateRange(this.currentWeek, weekEnd); + + // Update week info in DOM + const weekNumberElement = document.querySelector('swp-week-number'); + const dateRangeElement = document.querySelector('swp-date-range'); + + if (weekNumberElement) { + weekNumberElement.textContent = `Week ${weekNumber}`; + } + + if (dateRangeElement) { + dateRangeElement.textContent = dateRange; + } + + // Notify other managers about week info update + this.eventBus.emit(EventTypes.WEEK_INFO_UPDATED, { + weekNumber, + dateRange, + weekStart: this.currentWeek, + weekEnd + }); + } + + /** + * Get current week start date + */ + getCurrentWeek(): Date { + return new Date(this.currentWeek); + } + + /** + * Get target week (where navigation is heading) + */ + getTargetWeek(): Date { + return new Date(this.targetWeek); + } + + /** + * Check if navigation animation is in progress + */ + isAnimating(): boolean { + return this.animationQueue > 0; + } + + /** + * Force navigation to specific week without animation + */ + setWeek(weekStart: Date): void { + this.currentWeek = new Date(weekStart); + this.targetWeek = new Date(weekStart); + this.updateWeekInfo(); + + this.eventBus.emit(EventTypes.WEEK_CHANGED, { + weekStart: this.currentWeek, + weekEnd: DateUtils.addDays(this.currentWeek, 6) + }); + } +} \ No newline at end of file diff --git a/src/managers/ViewManager.ts b/src/managers/ViewManager.ts new file mode 100644 index 0000000..298cd96 --- /dev/null +++ b/src/managers/ViewManager.ts @@ -0,0 +1,174 @@ +import { EventBus } from '../core/EventBus'; +import { CalendarView, IEventBus } from '../types/CalendarTypes'; +import { calendarConfig } from '../core/CalendarConfig'; +import { EventTypes } from '../constants/EventTypes'; + +/** + * ViewManager - Håndterer skift mellem dag/uge/måned visninger + * Arbejder med custom tags fra POC design + */ +export class ViewManager { + private eventBus: IEventBus; + private currentView: CalendarView = 'week'; + + constructor(eventBus: IEventBus) { + this.eventBus = eventBus; + this.setupEventListeners(); + } + + private setupEventListeners(): void { + this.eventBus.on(EventTypes.CALENDAR_INITIALIZED, () => { + this.initializeView(); + }); + + this.eventBus.on(EventTypes.VIEW_CHANGE_REQUESTED, (event: Event) => { + const customEvent = event as CustomEvent; + const { currentView } = customEvent.detail; + this.changeView(currentView); + }); + + this.eventBus.on(EventTypes.DATE_CHANGED, () => { + this.refreshCurrentView(); + }); + + // Setup view button handlers + this.setupViewButtonHandlers(); + } + + private setupViewButtonHandlers(): void { + const viewButtons = document.querySelectorAll('swp-view-button[data-view]'); + viewButtons.forEach(button => { + button.addEventListener('click', (event) => { + event.preventDefault(); + const view = button.getAttribute('data-view') as CalendarView; + if (view && this.isValidView(view)) { + this.changeView(view); + } + }); + }); + } + + private initializeView(): void { + this.renderTimeAxis(); + this.renderWeekHeaders(); + this.renderDayColumns(); + this.updateViewButtons(); + + this.eventBus.emit(EventTypes.VIEW_RENDERED, { + view: this.currentView + }); + } + + private changeView(newView: CalendarView): void { + if (newView === this.currentView) return; + + const previousView = this.currentView; + this.currentView = newView; + + console.log(`ViewManager: Changing view from ${previousView} to ${newView}`); + + this.updateViewButtons(); + + this.eventBus.emit(EventTypes.VIEW_CHANGED, { + previousView, + currentView: newView + }); + } + + private renderTimeAxis(): void { + const timeAxis = document.querySelector('swp-time-axis'); + if (!timeAxis) return; + + const startHour = calendarConfig.get('dayStartHour'); + const endHour = calendarConfig.get('dayEndHour'); + + timeAxis.innerHTML = ''; + + for (let hour = startHour; hour <= endHour; hour++) { + const marker = document.createElement('swp-hour-marker'); + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); + marker.textContent = `${displayHour} ${period}`; + timeAxis.appendChild(marker); + } + } + + private renderWeekHeaders(): void { + const weekHeader = document.querySelector('swp-week-header'); + if (!weekHeader) return; + + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + weekHeader.innerHTML = ''; + + for (let i = 0; i < 7; i++) { + const header = document.createElement('swp-day-header'); + header.innerHTML = ` + ${days[i]} + ${i + 1} + `; + header.dataset.dayIndex = i.toString(); + + // Check if today (this will be updated by NavigationManager later) + if (i === 1) { // Mock today as Monday for now + header.setAttribute('data-today', 'true'); + } + + weekHeader.appendChild(header); + } + } + + private renderDayColumns(): void { + const dayColumns = document.querySelector('swp-day-columns'); + if (!dayColumns) return; + + dayColumns.innerHTML = ''; + + for (let i = 0; i < 7; i++) { + const column = document.createElement('swp-day-column'); + column.dataset.dayIndex = i.toString(); + + const eventsLayer = document.createElement('swp-events-layer'); + column.appendChild(eventsLayer); + + dayColumns.appendChild(column); + } + } + + private updateViewButtons(): void { + const viewButtons = document.querySelectorAll('swp-view-button[data-view]'); + viewButtons.forEach(button => { + const buttonView = button.getAttribute('data-view') as CalendarView; + if (buttonView === this.currentView) { + button.setAttribute('data-active', 'true'); + } else { + button.removeAttribute('data-active'); + } + }); + } + + private refreshCurrentView(): void { + this.renderWeekHeaders(); + this.renderDayColumns(); + + this.eventBus.emit(EventTypes.VIEW_RENDERED, { + view: this.currentView + }); + } + + private isValidView(view: string): view is CalendarView { + return ['day', 'week', 'month'].includes(view); + } + + public getCurrentView(): CalendarView { + return this.currentView; + } + + public refresh(): void { + this.refreshCurrentView(); + } + + public destroy(): void { + // Event listeners bliver automatisk fjernet af EventBus + } +} \ No newline at end of file diff --git a/src/types/CalendarTypes.ts b/src/types/CalendarTypes.ts new file mode 100644 index 0000000..f412d0b --- /dev/null +++ b/src/types/CalendarTypes.ts @@ -0,0 +1,103 @@ +// Calendar type definitions + +export type ViewType = 'day' | 'week' | 'month'; +export type CalendarView = ViewType; // Alias for compatibility + +export type EventType = 'meeting' | 'meal' | 'work' | 'milestone'; + +export type SyncStatus = 'synced' | 'pending' | 'error'; + +export interface CalendarEvent { + id: string; + title: string; + start: string; // ISO 8601 + end: string; // ISO 8601 + type: EventType; + allDay: boolean; + syncStatus: SyncStatus; + recurringId?: string; + resources?: string[]; + metadata?: Record; +} + +export interface CalendarConfig { + // View settings + view: ViewType; + weekDays: number; // 4-7 days for week view + firstDayOfWeek: number; // 0 = Sunday, 1 = Monday + + // Time settings + dayStartHour: number; // Calendar starts at hour + dayEndHour: number; // Calendar ends at hour + workStartHour: number; // Work hours start + workEndHour: number; // Work hours end + snapInterval: number; // Minutes: 5, 10, 15, 30, 60 + + // Display settings + hourHeight: number; // Pixels per hour + showCurrentTime: boolean; + showWorkHours: boolean; + + // Interaction settings + allowDrag: boolean; + allowResize: boolean; + allowCreate: boolean; + + // API settings + apiEndpoint: string; + dateFormat: string; + timeFormat: string; + + // Feature flags + enableSearch: boolean; + enableTouch: boolean; + + // Event defaults + defaultEventDuration: number; // Minutes + minEventDuration: number; // Minutes + maxEventDuration: number; // Minutes +} + +export interface EventLogEntry { + type: string; + detail: any; + timestamp: number; +} + +export interface ListenerEntry { + eventType: string; + handler: EventListener; + options?: AddEventListenerOptions; +} + +export interface IEventBus { + on(eventType: string, handler: EventListener, options?: AddEventListenerOptions): () => void; + once(eventType: string, handler: EventListener): () => void; + off(eventType: string, handler: EventListener): void; + emit(eventType: string, detail?: any): boolean; + getEventLog(eventType?: string): EventLogEntry[]; + setDebug(enabled: boolean): void; + destroy(): void; +} + +export interface GridPosition { + minutes: number; + time: string; + y: number; +} + +export interface Period { + start: string; + end: string; + view: ViewType; +} + +export interface EventData { + events: CalendarEvent[]; + meta: { + start: string; + end: string; + view: ViewType; + total: number; + }; +} \ No newline at end of file diff --git a/src/utils/DateUtils.ts b/src/utils/DateUtils.ts new file mode 100644 index 0000000..93ffc8d --- /dev/null +++ b/src/utils/DateUtils.ts @@ -0,0 +1,230 @@ +// Date and time utility functions + +/** + * Date and time utility functions + */ +export class DateUtils { + /** + * Get start of week for a given date + */ + static getWeekStart(date: Date, firstDayOfWeek: number = 1): Date { + const d = new Date(date); + const day = d.getDay(); + const diff = (day - firstDayOfWeek + 7) % 7; + d.setDate(d.getDate() - diff); + d.setHours(0, 0, 0, 0); + return d; + } + + /** + * Get end of week for a given date + */ + static getWeekEnd(date: Date, firstDayOfWeek: number = 1): Date { + const start = this.getWeekStart(date, firstDayOfWeek); + const end = new Date(start); + end.setDate(end.getDate() + 6); + end.setHours(23, 59, 59, 999); + return end; + } + + /** + * Format date to YYYY-MM-DD + */ + static formatDate(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + } + + /** + * Format time to HH:MM + */ + static formatTime(date: Date): string { + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + } + + /** + * Format time to 12-hour format + */ + static formatTime12(date: Date): string { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + + return `${displayHours}:${String(minutes).padStart(2, '0')} ${period}`; + } + + /** + * Convert minutes since midnight to time string + */ + static minutesToTime(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + + return `${displayHours}:${String(mins).padStart(2, '0')} ${period}`; + } + + /** + * Convert time string to minutes since midnight + */ + static timeToMinutes(timeStr: string): number { + const [time] = timeStr.split('T').pop()!.split('.'); + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; + } + + /** + * Get minutes since start of day + */ + static getMinutesSinceMidnight(date: Date | string): number { + const d = typeof date === 'string' ? new Date(date) : date; + return d.getHours() * 60 + d.getMinutes(); + } + + /** + * Calculate duration in minutes between two dates + */ + static getDurationMinutes(start: Date | string, end: Date | string): number { + const startDate = typeof start === 'string' ? new Date(start) : start; + const endDate = typeof end === 'string' ? new Date(end) : end; + return Math.floor((endDate.getTime() - startDate.getTime()) / 60000); + } + + /** + * Check if date is today + */ + static isToday(date: Date): boolean { + const today = new Date(); + return date.toDateString() === today.toDateString(); + } + + /** + * Check if two dates are on the same day + */ + static isSameDay(date1: Date, date2: Date): boolean { + return date1.toDateString() === date2.toDateString(); + } + + /** + * Check if event spans multiple days + */ + static isMultiDay(start: Date | string, end: Date | string): boolean { + const startDate = typeof start === 'string' ? new Date(start) : start; + const endDate = typeof end === 'string' ? new Date(end) : end; + return !this.isSameDay(startDate, endDate); + } + + /** + * Get day name + */ + static getDayName(date: Date, format: 'short' | 'long' = 'short'): string { + const days = { + short: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + long: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + }; + return days[format][date.getDay()]; + } + + /** + * Add days to date + */ + static addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } + + /** + * Add minutes to date + */ + static addMinutes(date: Date, minutes: number): Date { + const result = new Date(date); + result.setMinutes(result.getMinutes() + minutes); + return result; + } + + /** + * Snap time to nearest interval + */ + static snapToInterval(date: Date, intervalMinutes: number): Date { + const minutes = date.getMinutes(); + const snappedMinutes = Math.round(minutes / intervalMinutes) * intervalMinutes; + const result = new Date(date); + result.setMinutes(snappedMinutes); + result.setSeconds(0); + result.setMilliseconds(0); + return result; + } + + /** + * Get current time in minutes since day start + */ + static getCurrentTimeMinutes(dayStartHour: number = 0): number { + const now = new Date(); + const minutesSinceMidnight = now.getHours() * 60 + now.getMinutes(); + return minutesSinceMidnight - (dayStartHour * 60); + } + + /** + * Format duration to human readable string + */ + static formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes} min`; + } + + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (mins === 0) { + return `${hours} hour${hours > 1 ? 's' : ''}`; + } + + return `${hours} hour${hours > 1 ? 's' : ''} ${mins} min`; + } + + /** + * Get ISO week number for a given date + */ + static getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); + } + + /** + * Get month names array + */ + static getMonthNames(format: 'short' | 'long' = 'short'): string[] { + const months = { + short: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + long: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + }; + return months[format]; + } + + /** + * Format date range for display (e.g., "Jan 15 - 21, 2024" or "Jan 15 - Feb 2, 2024") + */ + static formatDateRange(startDate: Date, endDate: Date): string { + const monthNames = this.getMonthNames('short'); + + const startMonth = monthNames[startDate.getMonth()]; + const endMonth = monthNames[endDate.getMonth()]; + const startDay = startDate.getDate(); + const endDay = endDate.getDate(); + const startYear = startDate.getFullYear(); + const endYear = endDate.getFullYear(); + + if (startMonth === endMonth && startYear === endYear) { + return `${startMonth} ${startDay} - ${endDay}, ${startYear}`; + } else if (startYear !== endYear) { + return `${startMonth} ${startDay}, ${startYear} - ${endMonth} ${endDay}, ${endYear}`; + } else { + return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${startYear}`; + } + } +} \ No newline at end of file diff --git a/src/utils/PositionUtils.ts b/src/utils/PositionUtils.ts new file mode 100644 index 0000000..f8ec61e --- /dev/null +++ b/src/utils/PositionUtils.ts @@ -0,0 +1,291 @@ +import { CalendarConfig } from '../core/CalendarConfig.js'; + +/** + * PositionUtils - Utility funktioner til pixel/minut konvertering + * Håndterer positionering og størrelse beregninger for calendar events + */ +export class PositionUtils { + private config: CalendarConfig; + + constructor(config: CalendarConfig) { + this.config = config; + } + + /** + * Konverter minutter til pixels + */ + public minutesToPixels(minutes: number): number { + const pixelsPerHour = this.config.get('hourHeight'); + return (minutes / 60) * pixelsPerHour; + } + + /** + * Konverter pixels til minutter + */ + public pixelsToMinutes(pixels: number): number { + const pixelsPerHour = this.config.get('hourHeight'); + return (pixels / pixelsPerHour) * 60; + } + + /** + * Konverter tid (HH:MM) til pixels fra dag start + */ + public timeToPixels(timeString: string): number { + const [hours, minutes] = timeString.split(':').map(Number); + const totalMinutes = (hours * 60) + minutes; + const dayStartMinutes = this.config.get('dayStartHour') * 60; + const minutesFromDayStart = totalMinutes - dayStartMinutes; + + return this.minutesToPixels(minutesFromDayStart); + } + + /** + * Konverter Date object til pixels fra dag start + */ + public dateToPixels(date: Date): number { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const totalMinutes = (hours * 60) + minutes; + const dayStartMinutes = this.config.get('dayStartHour') * 60; + const minutesFromDayStart = totalMinutes - dayStartMinutes; + + return this.minutesToPixels(minutesFromDayStart); + } + + /** + * Konverter pixels til tid (HH:MM format) + */ + public pixelsToTime(pixels: number): string { + const minutes = this.pixelsToMinutes(pixels); + const dayStartMinutes = this.config.get('dayStartHour') * 60; + const totalMinutes = dayStartMinutes + minutes; + + const hours = Math.floor(totalMinutes / 60); + const mins = Math.round(totalMinutes % 60); + + return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`; + } + + /** + * Beregn event position og størrelse + */ + public calculateEventPosition(startTime: string | Date, endTime: string | Date): { + top: number; + height: number; + duration: number; + } { + let startPixels: number; + let endPixels: number; + + if (typeof startTime === 'string') { + startPixels = this.timeToPixels(startTime); + } else { + startPixels = this.dateToPixels(startTime); + } + + if (typeof endTime === 'string') { + endPixels = this.timeToPixels(endTime); + } else { + endPixels = this.dateToPixels(endTime); + } + + const height = Math.max(endPixels - startPixels, this.getMinimumEventHeight()); + const duration = this.pixelsToMinutes(height); + + return { + top: startPixels, + height, + duration + }; + } + + /** + * Snap position til grid interval + */ + public snapToGrid(pixels: number): number { + const snapInterval = this.config.get('snapInterval'); + const snapPixels = this.minutesToPixels(snapInterval); + + return Math.round(pixels / snapPixels) * snapPixels; + } + + /** + * Snap tid til interval + */ + public snapTimeToInterval(timeString: string): string { + const [hours, minutes] = timeString.split(':').map(Number); + const totalMinutes = (hours * 60) + minutes; + const snapInterval = this.config.get('snapInterval'); + + const snappedMinutes = Math.round(totalMinutes / snapInterval) * snapInterval; + const snappedHours = Math.floor(snappedMinutes / 60); + const remainingMinutes = snappedMinutes % 60; + + return `${snappedHours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}`; + } + + /** + * Beregn kolonne position for overlappende events + */ + public calculateColumnPosition(eventIndex: number, totalColumns: number, containerWidth: number): { + left: number; + width: number; + } { + const columnWidth = containerWidth / totalColumns; + const left = eventIndex * columnWidth; + + // Lav lidt margin mellem kolonnerne + const margin = 2; + const adjustedWidth = columnWidth - margin; + + return { + left: left + (margin / 2), + width: Math.max(adjustedWidth, 50) // Minimum width + }; + } + + /** + * Check om to events overlapper i tid + */ + public eventsOverlap( + start1: string | Date, + end1: string | Date, + start2: string | Date, + end2: string | Date + ): boolean { + const pos1 = this.calculateEventPosition(start1, end1); + const pos2 = this.calculateEventPosition(start2, end2); + + const event1End = pos1.top + pos1.height; + const event2End = pos2.top + pos2.height; + + return !(event1End <= pos2.top || event2End <= pos1.top); + } + + /** + * Beregn Y position fra mouse/touch koordinat + */ + public getPositionFromCoordinate(clientY: number, containerElement: HTMLElement): number { + const rect = containerElement.getBoundingClientRect(); + const relativeY = clientY - rect.top; + + // Snap til grid + return this.snapToGrid(relativeY); + } + + /** + * Beregn tid fra mouse/touch koordinat + */ + public getTimeFromCoordinate(clientY: number, containerElement: HTMLElement): string { + const position = this.getPositionFromCoordinate(clientY, containerElement); + return this.pixelsToTime(position); + } + + /** + * Valider at tid er inden for arbejdstimer + */ + public isWithinWorkHours(timeString: string): boolean { + const [hours] = timeString.split(':').map(Number); + return hours >= this.config.get('workStartHour') && hours < this.config.get('workEndHour'); + } + + /** + * Valider at tid er inden for dag grænser + */ + public isWithinDayBounds(timeString: string): boolean { + const [hours] = timeString.split(':').map(Number); + return hours >= this.config.get('dayStartHour') && hours < this.config.get('dayEndHour'); + } + + /** + * Hent minimum event højde i pixels + */ + public getMinimumEventHeight(): number { + // Minimum 15 minutter + return this.minutesToPixels(15); + } + + /** + * Hent maksimum event højde i pixels (hele dagen) + */ + public getMaximumEventHeight(): number { + const dayDurationHours = this.config.get('dayEndHour') - this.config.get('dayStartHour'); + return dayDurationHours * this.config.get('hourHeight'); + } + + /** + * Beregn total kalender højde + */ + public getTotalCalendarHeight(): number { + return this.getMaximumEventHeight(); + } + + /** + * Konverter ISO datetime til lokal tid string + */ + public isoToTimeString(isoString: string): string { + const date = new Date(isoString); + const hours = date.getHours(); + const minutes = date.getMinutes(); + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + /** + * Konverter lokal tid string til ISO datetime for i dag + */ + public timeStringToIso(timeString: string, date: Date = new Date()): string { + const [hours, minutes] = timeString.split(':').map(Number); + const newDate = new Date(date); + newDate.setHours(hours, minutes, 0, 0); + + return newDate.toISOString(); + } + + /** + * Beregn event varighed i minutter + */ + public calculateDuration(startTime: string | Date, endTime: string | Date): number { + let startMs: number; + let endMs: number; + + if (typeof startTime === 'string') { + startMs = new Date(startTime).getTime(); + } else { + startMs = startTime.getTime(); + } + + if (typeof endTime === 'string') { + endMs = new Date(endTime).getTime(); + } else { + endMs = endTime.getTime(); + } + + return Math.round((endMs - startMs) / (1000 * 60)); // Minutter + } + + /** + * Format varighed til læsbar tekst + */ + public formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes} min`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + + if (remainingMinutes === 0) { + return `${hours} time${hours !== 1 ? 'r' : ''}`; + } + + return `${hours}t ${remainingMinutes}m`; + } + + /** + * Opdater konfiguration + */ + public updateConfig(newConfig: CalendarConfig): void { + this.config = newConfig; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a0a5008 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "outDir": "./js", + "rootDir": "./src", + "sourceMap": true, + "inlineSourceMap": false, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "js" + ] +} \ No newline at end of file diff --git a/wwwroot/css/calendar-base-css.css b/wwwroot/css/calendar-base-css.css new file mode 100644 index 0000000..73d2e10 --- /dev/null +++ b/wwwroot/css/calendar-base-css.css @@ -0,0 +1,190 @@ +/* styles/base.css */ + +/* CSS Reset and Base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* CSS Variables */ +:root { + /* Grid measurements */ + --hour-height: 60px; + --minute-height: 1px; + --snap-interval: 15; + + /* Time boundaries */ + --day-start-hour: 7; + --day-end-hour: 19; + --work-start-hour: 8; + --work-end-hour: 17; + + /* Colors */ + --color-primary: #2196f3; + --color-secondary: #ff9800; + --color-success: #4caf50; + --color-danger: #f44336; + --color-warning: #ff9800; + + /* Grid colors */ + --color-grid-line: #e0e0e0; + --color-grid-line-light: rgba(0, 0, 0, 0.05); + --color-work-hours: rgba(0, 100, 0, 0.02); + --color-current-time: #ff0000; + + /* Event colors */ + --color-event-meeting: #e3f2fd; + --color-event-meeting-border: #2196f3; + --color-event-meal: #fff3e0; + --color-event-meal-border: #ff9800; + --color-event-work: #f3e5f5; + --color-event-work-border: #9c27b0; + --color-event-milestone: #e8f5e9; + --color-event-milestone-border: #4caf50; + + /* UI colors */ + --color-background: #ffffff; + --color-surface: #f5f5f5; + --color-text: #333333; + --color-text-secondary: #666666; + --color-border: #e0e0e0; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 20px rgba(0, 0, 0, 0.1); + --shadow-popup: 0 4px 20px rgba(0, 0, 0, 0.15); + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; + --transition-slow: 500ms ease; + + /* Z-index layers */ + --z-grid: 1; + --z-event: 10; + --z-event-hover: 20; + --z-drag-ghost: 30; + --z-current-time: 40; + --z-popup: 100; + --z-loading: 200; +} + +/* Base styles */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--color-text); + background-color: var(--color-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom elements default display */ +swp-calendar, +swp-calendar-nav, +swp-calendar-container, +swp-time-axis, +swp-week-header, +swp-scrollable-content, +swp-time-grid, +swp-day-columns, +swp-day-column, +swp-events-layer, +swp-event, +swp-allday-container, +swp-loading-overlay, +swp-event-popup { + display: block; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--color-surface); +} + +::-webkit-scrollbar-thumb { + background: #bbb; + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: #999; +} + +/* Selection styling */ +::selection { + background-color: rgba(33, 150, 243, 0.2); + color: inherit; +} + +/* Focus styles */ +:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +/* Utility classes */ +.hidden { + display: none !important; +} + +.invisible { + visibility: hidden !important; +} + +.transparent { + opacity: 0 !important; +} + +/* Animations */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes pulse { + 0% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} \ No newline at end of file diff --git a/wwwroot/css/calendar-components-css.css b/wwwroot/css/calendar-components-css.css new file mode 100644 index 0000000..ed97b2f --- /dev/null +++ b/wwwroot/css/calendar-components-css.css @@ -0,0 +1,184 @@ +/* styles/components/navigation.css */ + +/* Navigation bar */ +swp-calendar-nav { + display: flex; + align-items: center; + gap: 24px; + padding: 12px 16px; + background: var(--color-background); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +/* Navigation groups */ +swp-nav-group { + display: flex; + align-items: center; + gap: 4px; +} + +/* Navigation buttons */ +swp-nav-button { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: 1px solid var(--color-border); + background: var(--color-background); + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition-fast); + min-width: 40px; + height: 36px; + + &:hover { + background: var(--color-surface); + border-color: var(--color-text-secondary); + } + + &:active { + transform: translateY(1px); + } + + /* Icon buttons */ + svg { + width: 20px; + height: 20px; + stroke-width: 2; + } + + /* Today button */ + &[data-action="today"] { + min-width: 70px; + } +} + +/* View selector */ +swp-view-selector { + display: flex; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + overflow: hidden; +} + +swp-view-button { + padding: 8px 16px; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition-fast); + position: relative; + + &:not(:last-child) { + border-right: 1px solid var(--color-border); + } + + &:hover:not([disabled]) { + background: rgba(0, 0, 0, 0.05); + } + + &[data-active="true"] { + background: var(--color-primary); + color: white; + + &:hover { + background: var(--color-primary); + } + } + + &[disabled] { + opacity: 0.5; + cursor: not-allowed; + } +} + +/* Search container */ +swp-search-container { + margin-left: auto; + display: flex; + align-items: center; + position: relative; + + swp-search-icon { + position: absolute; + left: 12px; + pointer-events: none; + color: var(--color-text-secondary); + + svg { + width: 16px; + height: 16px; + } + } + + input[type="search"] { + padding: 8px 36px 8px 36px; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-surface); + font-size: 0.875rem; + width: 250px; + transition: all var(--transition-fast); + + &::-webkit-search-cancel-button { + display: none; + } + + &:focus { + outline: none; + border-color: var(--color-primary); + background: var(--color-background); + width: 300px; + } + + &::placeholder { + color: var(--color-text-secondary); + } + } + + swp-search-clear { + position: absolute; + right: 8px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 50%; + transition: all var(--transition-fast); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + + svg { + width: 14px; + height: 14px; + stroke: var(--color-text-secondary); + } + + &[hidden] { + display: none; + } + } +} + +/* Calendar search active state */ +swp-calendar[data-searching="true"] { + swp-event { + opacity: 0.15; + transition: opacity var(--transition-normal); + + &[data-search-match="true"] { + opacity: 1; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.3); + } + } +} \ No newline at end of file diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css new file mode 100644 index 0000000..39bbe5f --- /dev/null +++ b/wwwroot/css/calendar-events-css.css @@ -0,0 +1,263 @@ +/* styles/components/events.css */ + +/* Event base styles */ +swp-event { + position: absolute; + border-radius: 4px; + overflow: hidden; + cursor: move; + transition: box-shadow var(--transition-fast), transform var(--transition-fast); + z-index: var(--z-event); + + /* CSS-based positioning */ + top: calc(var(--start-minutes) * var(--minute-height)); + height: calc(var(--duration-minutes) * var(--minute-height)); + + /* Event types */ + &[data-type="meeting"] { + background: var(--color-event-meeting); + border-left: 4px solid var(--color-event-meeting-border); + } + + &[data-type="meal"] { + background: var(--color-event-meal); + border-left: 4px solid var(--color-event-meal-border); + } + + &[data-type="work"] { + background: var(--color-event-work); + border-left: 4px solid var(--color-event-work-border); + } + + &[data-type="milestone"] { + background: var(--color-event-milestone); + border-left: 4px solid var(--color-event-milestone-border); + } + + /* Hover state */ + &:hover { + box-shadow: var(--shadow-md); + transform: scale(1.02); + z-index: var(--z-event-hover); + + swp-resize-handle { + opacity: 1; + } + } + + /* Active/selected state */ + &[data-selected="true"] { + box-shadow: 0 0 0 2px var(--color-primary); + z-index: var(--z-event-hover); + } + + /* Dragging state */ + &[data-dragging="true"] { + opacity: 0.5; + cursor: grabbing; + z-index: var(--z-drag-ghost); + + &::before { + content: ''; + position: absolute; + inset: -2px; + border: 2px solid var(--color-primary); + border-radius: 6px; + pointer-events: none; + } + } + + /* Resizing state */ + &[data-resizing="true"] { + opacity: 0.8; + + swp-resize-handle { + opacity: 1; + + &::before, + &::after { + background: var(--color-primary); + } + } + } + + /* Sync status indicators */ + &[data-sync-status="pending"] { + &::after { + content: ''; + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background: var(--color-warning); + border-radius: 50%; + animation: pulse 2s infinite; + } + } + + &[data-sync-status="error"] { + &::after { + content: ''; + position: absolute; + top: 4px; + right: 4px; + width: 8px; + height: 8px; + background: var(--color-danger); + border-radius: 50%; + } + } +} + +/* Event header */ +swp-event-header { + padding: 8px 12px 4px; + + swp-event-time { + display: block; + font-size: 0.875rem; + font-weight: 500; + opacity: 0.8; + } +} + +/* Event body */ +swp-event-body { + padding: 0 12px 8px; + + swp-event-title { + display: block; + font-size: 0.875rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + + /* Multi-line ellipsis */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +/* Resize handles */ +swp-resize-handle { + position: absolute; + left: 8px; + right: 8px; + height: 4px; + opacity: 0; + transition: opacity var(--transition-fast); + + /* The two lines */ + &::before, + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 1px; + background: rgba(0, 0, 0, 0.3); + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } + + /* Hit area */ + swp-handle-hitarea { + position: absolute; + left: -8px; + right: -8px; + top: -6px; + bottom: -6px; + cursor: ns-resize; + } + + &[data-position="top"] { + top: 4px; + } + + &[data-position="bottom"] { + bottom: 4px; + } +} + +/* Multi-day events */ +swp-multi-day-event { + position: relative; + height: 28px; + margin: 2px 4px; + padding: 0 8px; + border-radius: 4px; + display: flex; + align-items: center; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + /* Event type colors */ + &[data-type="milestone"] { + background: var(--color-event-milestone); + color: var(--color-event-milestone-border); + } + + /* Continuation indicators */ + &[data-continues-before="true"] { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: 0; + padding-left: 20px; + + &::before { + content: '◀'; + position: absolute; + left: 4px; + opacity: 0.6; + font-size: 0.75rem; + } + } + + &[data-continues-after="true"] { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-right: 0; + padding-right: 20px; + + &::after { + content: '▶'; + position: absolute; + right: 4px; + opacity: 0.6; + font-size: 0.75rem; + } + } + + &:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + } +} + +/* Event creation preview */ +swp-event-preview { + position: absolute; + left: 8px; + right: 8px; + background: rgba(33, 150, 243, 0.1); + border: 2px dashed var(--color-primary); + border-radius: 4px; + pointer-events: none; + + /* Position via CSS variables */ + top: calc(var(--preview-start) * var(--minute-height)); + height: calc(var(--preview-duration) * var(--minute-height)); +} \ No newline at end of file diff --git a/wwwroot/css/calendar-layout-css.css b/wwwroot/css/calendar-layout-css.css new file mode 100644 index 0000000..74b4b9e --- /dev/null +++ b/wwwroot/css/calendar-layout-css.css @@ -0,0 +1,257 @@ +/* styles/layout.css */ + +/* Main calendar container */ +swp-calendar { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--color-background); + position: relative; +} + +/* Calendar container grid */ +swp-calendar-container { + flex: 1; + display: grid; + grid-template-columns: 60px 1fr; + grid-template-rows: auto 1fr; + overflow: hidden; + position: relative; +} + +/* Time axis (left side) */ +swp-time-axis { + grid-column: 1; + grid-row: 2; + background: var(--color-surface); + border-right: 1px solid var(--color-border); + position: relative; + z-index: 2; +} + +swp-hour-marker { + height: var(--hour-height); + padding: 8px; + font-size: 0.75rem; + color: var(--color-text-secondary); + display: flex; + align-items: flex-start; + position: relative; + + /* Hour line extending into calendar */ + &::after { + content: ''; + position: absolute; + top: 0; + left: 100%; + width: 100vw; + height: 1px; + background: var(--color-grid-line); + pointer-events: none; + } +} + +/* Week header */ +swp-week-header { + grid-column: 2; + grid-row: 1; + display: grid; + grid-template-columns: repeat(var(--week-days, 7), 1fr); + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 3; +} + +swp-day-header { + padding: 12px; + text-align: center; + border-right: 1px solid var(--color-grid-line); + + &:last-child { + border-right: none; + } + + swp-day-name { + display: block; + font-weight: 500; + font-size: 0.875rem; + color: var(--color-text-secondary); + } + + swp-day-date { + display: block; + font-size: 1.25rem; + font-weight: 600; + margin-top: 4px; + } + + /* Today indicator */ + &[data-today="true"] { + swp-day-date { + color: var(--color-primary); + background: rgba(33, 150, 243, 0.1); + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + margin: 4px auto 0; + } + } +} + +/* Scrollable content */ +swp-scrollable-content { + grid-column: 2; + grid-row: 2; + overflow-y: auto; + overflow-x: hidden; + scroll-behavior: smooth; + position: relative; +} + +/* All-day events container */ +swp-allday-container { + position: sticky; + top: 0; + background: var(--color-background); + border-bottom: 1px solid var(--color-border); + min-height: 0; + z-index: 2; + + &:not(:empty) { + padding: 8px 0; + } +} + +/* Time grid */ +swp-time-grid { + position: relative; + height: calc(var(--total-hours, 12) * var(--hour-height)); + + /* Work hours background */ + &::before { + content: ''; + position: absolute; + top: calc((var(--work-start-hour) - var(--day-start-hour)) * var(--hour-height)); + height: calc((var(--work-end-hour) - var(--work-start-hour)) * var(--hour-height)); + left: 0; + right: 0; + background: var(--color-work-hours); + pointer-events: none; + } +} + +/* Grid lines */ +swp-grid-lines { + position: absolute; + inset: 0; + pointer-events: none; + + /* 15-minute intervals */ + background-image: repeating-linear-gradient( + to bottom, + transparent, + transparent calc(var(--hour-height) / 4 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) / 4 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) / 4) + ); + + /* Show stronger lines when dragging */ + &[data-dragging="true"] { + background-image: repeating-linear-gradient( + to bottom, + transparent, + transparent calc(var(--hour-height) / 4 - 1px), + rgba(33, 150, 243, 0.2) calc(var(--hour-height) / 4 - 1px), + rgba(33, 150, 243, 0.2) calc(var(--hour-height) / 4) + ); + } +} + +/* Day columns */ +swp-day-columns { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(var(--week-days, 7), 1fr); +} + +swp-day-column { + position: relative; + border-right: 1px solid var(--color-grid-line); + + &:last-child { + border-right: none; + } + + /* Hover effect for empty slots */ + &:hover { + background: rgba(0, 0, 0, 0.01); + } +} + +/* Events layer */ +swp-events-layer { + position: absolute; + inset: 0; + + /* Layout modes */ + &[data-layout="overlap"] { + swp-event { + width: calc(100% - 16px); + left: 8px; + } + } + + &[data-layout="side-by-side"] { + swp-event { + width: calc(var(--event-width, 100%) - 16px); + left: calc(8px + var(--event-offset, 0px)); + } + } +} + +/* Current time indicator */ +swp-current-time-indicator { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--color-current-time); + pointer-events: none; + z-index: var(--z-current-time); + + /* Time label */ + &::before { + content: attr(data-time); + position: absolute; + left: -55px; + top: -10px; + background: var(--color-current-time); + color: white; + padding: 2px 6px; + font-size: 0.75rem; + border-radius: 3px; + white-space: nowrap; + } + + /* Animated dot */ + &::after { + content: ''; + position: absolute; + right: -4px; + top: -4px; + width: 10px; + height: 10px; + background: var(--color-current-time); + border-radius: 50%; + box-shadow: 0 0 0 2px rgba(255, 0, 0, 0.3); + } + + /* Position based on current time */ + top: calc(var(--current-minutes) * var(--minute-height)); +} \ No newline at end of file diff --git a/wwwroot/css/calendar-popup-css.css b/wwwroot/css/calendar-popup-css.css new file mode 100644 index 0000000..44484be --- /dev/null +++ b/wwwroot/css/calendar-popup-css.css @@ -0,0 +1,190 @@ +/* styles/components/popup.css */ + +/* Event popup */ +swp-event-popup { + position: fixed; + background: #f9f5f0; + border-radius: 8px; + box-shadow: var(--shadow-popup); + padding: 16px; + min-width: 300px; + z-index: var(--z-popup); + animation: fadeIn var(--transition-fast); + + /* Chevron arrow */ + &::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + background: inherit; + transform: rotate(45deg); + top: 50%; + margin-top: -8px; + } + + /* Right-side popup (arrow on left) */ + &[data-align="right"] { + &::before { + left: -8px; + box-shadow: -2px 2px 4px rgba(0, 0, 0, 0.1); + } + } + + /* Left-side popup (arrow on right) */ + &[data-align="left"] { + &::before { + right: -8px; + box-shadow: 2px -2px 4px rgba(0, 0, 0, 0.1); + } + } + + &[hidden] { + display: none; + } +} + +/* Popup header */ +swp-popup-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + gap: 16px; +} + +swp-popup-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + line-height: 1.4; + flex: 1; +} + +/* Popup actions */ +swp-popup-actions { + display: flex; + gap: 4px; +} + +swp-action-button { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + transition: background var(--transition-fast); + color: var(--color-text-secondary); + + &:hover { + background: rgba(0, 0, 0, 0.05); + color: var(--color-text); + } + + &:active { + background: rgba(0, 0, 0, 0.1); + } + + svg { + width: 16px; + height: 16px; + } + + /* Specific button styles */ + &[data-action="delete"]:hover { + color: var(--color-danger); + } + + &[data-action="close"]:hover { + background: rgba(0, 0, 0, 0.1); + } +} + +/* Popup content */ +swp-popup-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +swp-time-info { + display: flex; + align-items: center; + gap: 12px; + color: var(--color-text-secondary); + font-size: 0.875rem; + + swp-icon { + font-size: 1.25rem; + color: var(--color-secondary); + } +} + +/* Loading overlay */ +swp-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-loading); + backdrop-filter: blur(2px); + + &[hidden] { + display: none; + } +} + +swp-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--color-surface); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Snap indicator */ +swp-snap-indicator { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--color-primary); + pointer-events: none; + opacity: 0; + transition: opacity var(--transition-fast); + z-index: var(--z-drag-ghost); + + &[data-active="true"] { + opacity: 1; + } + + &::before { + content: attr(data-time); + position: absolute; + right: 8px; + top: -24px; + background: var(--color-primary); + color: white; + padding: 2px 8px; + font-size: 0.75rem; + border-radius: 3px; + white-space: nowrap; + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + swp-event-popup { + min-width: 250px; + max-width: calc(100vw - 32px); + } + + swp-popup-title { + font-size: 1rem; + } +} \ No newline at end of file diff --git a/wwwroot/css/calendar.css b/wwwroot/css/calendar.css new file mode 100644 index 0000000..5a3c11f --- /dev/null +++ b/wwwroot/css/calendar.css @@ -0,0 +1,498 @@ +/* Base CSS */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Grid measurements */ + --hour-height: 60px; + --minute-height: 1px; + --snap-interval: 15; + + /* Time boundaries */ + --day-start-hour: 7; + --day-end-hour: 19; + --work-start-hour: 8; + --work-end-hour: 17; + + /* Colors */ + --color-primary: #2196f3; + --color-grid-line: #e0e0e0; + --color-grid-line-light: rgba(0, 0, 0, 0.03); + --color-work-hours: rgba(0, 100, 0, 0.02); + --color-current-time: #ff0000; + + /* Event colors */ + --color-event-meeting: #e3f2fd; + --color-event-meeting-border: #2196f3; + --color-event-meal: #fff3e0; + --color-event-meal-border: #ff9800; + --color-event-work: #f3e5f5; + --color-event-work-border: #9c27b0; + + /* UI colors */ + --color-background: #ffffff; + --color-surface: #f5f5f5; + --color-text: #333333; + --color-text-secondary: #666666; + --color-border: #e0e0e0; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--color-text); + background-color: var(--color-surface); +} + +/* Custom elements default display */ +swp-calendar, +swp-calendar-nav, +swp-calendar-container, +swp-time-axis, +swp-week-header, +swp-scrollable-content, +swp-time-grid, +swp-day-columns, +swp-day-column, +swp-events-layer, +swp-event, +swp-loading-overlay, +swp-week-container, +swp-grid-lines { + display: block; +} + +/* Main calendar container */ +swp-calendar { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--color-background); + position: relative; +} + +/* Navigation bar */ +swp-calendar-nav { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 20px; + padding: 12px 16px; + background: var(--color-background); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +swp-nav-group { + display: flex; + align-items: center; + gap: 4px; +} + +swp-nav-button { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: 1px solid var(--color-border); + background: var(--color-background); + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 150ms ease; + min-width: 40px; + height: 36px; +} + +swp-nav-button:hover { + background: var(--color-surface); + border-color: var(--color-text-secondary); +} + +/* Search container */ +swp-search-container { + display: flex; + align-items: center; + position: relative; + justify-self: end; +} + +swp-search-icon { + position: absolute; + left: 12px; + pointer-events: none; + color: var(--color-text-secondary); + display: flex; + align-items: center; +} + +swp-search-icon svg { + width: 16px; + height: 16px; +} + +swp-search-container input[type="search"] { + padding: 8px 36px 8px 36px; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-surface); + font-size: 0.875rem; + width: 200px; + transition: all 150ms ease; +} + +swp-search-container input[type="search"]::-webkit-search-cancel-button { + display: none; +} + +swp-search-container input[type="search"]:focus { + outline: none; + border-color: var(--color-primary); + background: var(--color-background); + width: 250px; + box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); +} + +swp-search-container input[type="search"]::placeholder { + color: var(--color-text-secondary); +} + +swp-search-clear { + position: absolute; + right: 8px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 50%; + transition: all 150ms ease; + color: var(--color-text-secondary); +} + +swp-search-clear:hover { + background: rgba(0, 0, 0, 0.1); +} + +swp-search-clear svg { + width: 12px; + height: 12px; +} + +swp-search-clear[hidden] { + display: none; +} + +swp-view-button { + padding: 8px 16px; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 150ms ease; +} + +swp-view-button:not(:last-child) { + border-right: 1px solid var(--color-border); +} + +swp-view-button[data-active="true"] { + background: var(--color-primary); + color: white; +} + +/* Calendar container grid */ +swp-calendar-container { + flex: 1; + display: grid; + grid-template-columns: 60px 1fr; + grid-template-rows: 1fr; + overflow: hidden; + position: relative; +} + +/* Week container for sliding */ +swp-week-container { + grid-column: 2; + display: grid; + grid-template-rows: auto 1fr; + position: relative; + width: 100%; + transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1); +} + +swp-week-container.slide-out-left { + transform: translateX(-100%); +} + +swp-week-container.slide-out-right { + transform: translateX(100%); +} + +swp-week-container.slide-in-left { + transform: translateX(-100%); +} + +swp-week-container.slide-in-right { + transform: translateX(100%); +} + +/* Time axis */ +swp-time-axis { + grid-column: 1; + grid-row: 1; + background: var(--color-surface); + border-right: 1px solid var(--color-border); + position: sticky; + left: 0; + z-index: 4; + padding-top: 80px; /* Match header height */ +} + +swp-hour-marker { + height: var(--hour-height); + padding: 0 8px 8px 8px; + font-size: 0.75rem; + color: var(--color-text-secondary); + display: flex; + align-items: flex-start; + position: relative; +} + +swp-hour-marker::after { + content: ''; + position: absolute; + top: 0; + left: 100%; + width: 100vw; + height: 1px; + background: var(--color-grid-line); + pointer-events: none; +} + +/* Week header */ +swp-week-header { + display: grid; + grid-template-columns: repeat(7, 1fr); + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 3; + height: 80px; /* Fixed height */ +} + +swp-day-header { + padding: 12px; + text-align: center; + border-right: 1px solid var(--color-grid-line); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +swp-day-header:last-child { + border-right: none; +} + +swp-day-name { + display: block; + font-weight: 500; + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +swp-day-date { + display: block; + font-size: 1.25rem; + font-weight: 600; + margin-top: 4px; +} + +swp-day-header[data-today="true"] swp-day-date { + color: var(--color-primary); + background: rgba(33, 150, 243, 0.1); + border-radius: 50%; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + margin: 4px auto 0; +} + +/* Scrollable content */ +swp-scrollable-content { + overflow-y: auto; + overflow-x: hidden; + scroll-behavior: smooth; + position: relative; +} + +/* Time grid */ +swp-time-grid { + position: relative; + height: calc(12 * var(--hour-height)); +} + +swp-time-grid::before { + content: ''; + position: absolute; + top: calc((var(--work-start-hour) - var(--day-start-hour)) * var(--hour-height)); + height: calc((var(--work-end-hour) - var(--work-start-hour)) * var(--hour-height)); + left: 0; + right: 0; + background: var(--color-work-hours); + pointer-events: none; +} + +/* Grid lines */ +swp-grid-lines { + position: absolute; + inset: 0; + pointer-events: none; + background-image: repeating-linear-gradient( + to bottom, + transparent, + transparent calc(var(--hour-height) / 4 - 1px), + rgba(0, 0, 0, 0.03) calc(var(--hour-height) / 4 - 1px), + rgba(0, 0, 0, 0.03) calc(var(--hour-height) / 4) + ); +} + +/* Day columns */ +swp-day-columns { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +swp-day-column { + position: relative; + border-right: 1px solid var(--color-grid-line); +} + +swp-day-column:last-child { + border-right: none; +} + +swp-events-layer { + position: absolute; + inset: 0; +} + +/* Events */ +swp-event { + position: absolute; + border-radius: 4px; + overflow: hidden; + cursor: move; + transition: box-shadow 150ms ease, transform 150ms ease; + z-index: 10; + left: 1px; + right: 1px; + padding: 8px; +} + +swp-event[data-type="meeting"] { + background: var(--color-event-meeting); + border-left: 4px solid var(--color-event-meeting-border); +} + +swp-event[data-type="meal"] { + background: var(--color-event-meal); + border-left: 4px solid var(--color-event-meal-border); +} + +swp-event[data-type="work"] { + background: var(--color-event-work); + border-left: 4px solid var(--color-event-work-border); +} + +swp-event:hover { + box-shadow: var(--shadow-md); + transform: scale(1.02); + z-index: 20; +} + +swp-event-time { + display: block; + font-size: 0.875rem; + font-weight: 500; + opacity: 0.8; + margin-bottom: 4px; +} + +swp-event-title { + display: block; + font-size: 0.875rem; + line-height: 1.3; +} + +/* Loading */ +swp-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} + +swp-loading-overlay[hidden] { + display: none; +} + +swp-spinner { + width: 40px; + height: 40px; + border: 3px solid #f3f3f3; + border-top: 3px solid var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Week info styles */ +swp-week-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +swp-week-number { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); +} + +swp-date-range { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +swp-view-selector { + display: flex; + border: 1px solid var(--color-border); + border-radius: 4px; + overflow: hidden; +} \ No newline at end of file diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..14d57f6 --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,122 @@ + + + + + + Calendar Plantempus - Week View + + + + + + + + + + + + + + + Today + + + + Week 3 + Jan 15 - Jan 21, 2024 + + + + + + + + + + + + + + + Day + Week + Month + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file