Initial commit: Calendar Plantempus project setup with TypeScript, ASP.NET Core, and event-driven architecture
This commit is contained in:
commit
f06c02121c
38 changed files with 8233 additions and 0 deletions
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
1
.roo/mcp.json
Normal file
1
.roo/mcp.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"mcpServers":{}}
|
||||
9
CalendarServer.csproj
Normal file
9
CalendarServer.csproj
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
20
Program.cs
Normal file
20
Program.cs
Normal file
|
|
@ -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");
|
||||
177
README.md
Normal file
177
README.md
Normal file
|
|
@ -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]
|
||||
58
build.js
Normal file
58
build.js
Normal file
|
|
@ -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();
|
||||
460
calendar-complete-specification.md
Normal file
460
calendar-complete-specification.md
Normal file
|
|
@ -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<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
189
calendar-config.js
Normal file
189
calendar-config.js
Normal file
|
|
@ -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();
|
||||
385
calendar-data-manager.js
Normal file
385
calendar-data-manager.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
231
calendar-date-utils.js
Normal file
231
calendar-date-utils.js
Normal file
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
73
calendar-event-types.js
Normal file
73
calendar-event-types.js
Normal file
|
|
@ -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'
|
||||
};
|
||||
118
calendar-eventbus.js
Normal file
118
calendar-eventbus.js
Normal file
|
|
@ -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();
|
||||
334
calendar-grid-manager.js
Normal file
334
calendar-grid-manager.js
Normal file
|
|
@ -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 = `
|
||||
<swp-day-name>${this.getDayName(date)}</swp-day-name>
|
||||
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||
`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
1066
calendar-poc-single-file.html
Normal file
1066
calendar-poc-single-file.html
Normal file
File diff suppressed because it is too large
Load diff
435
package-lock.json
generated
Normal file
435
package-lock.json
generated
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
98
src/constants/EventTypes.ts
Normal file
98
src/constants/EventTypes.ts
Normal file
|
|
@ -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];
|
||||
191
src/core/CalendarConfig.ts
Normal file
191
src/core/CalendarConfig.ts
Normal file
|
|
@ -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<K extends keyof ICalendarConfig>(key: K): ICalendarConfig[K] {
|
||||
return this.config[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a config value
|
||||
*/
|
||||
set<K extends keyof ICalendarConfig>(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<ICalendarConfig>): 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<ViewType, ViewSettings> = {
|
||||
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();
|
||||
103
src/core/EventBus.ts
Normal file
103
src/core/EventBus.ts
Normal file
|
|
@ -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<ListenerEntry> = 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();
|
||||
50
src/index.ts
Normal file
50
src/index.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
256
src/managers/CalendarManager.ts
Normal file
256
src/managers/CalendarManager.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
414
src/managers/DataManager.ts
Normal file
414
src/managers/DataManager.ts
Normal file
|
|
@ -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<CalendarEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, EventData> = 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<EventData> {
|
||||
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<CalendarEvent> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<EventType, string[]> = {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
227
src/managers/EventManager.ts
Normal file
227
src/managers/EventManager.ts
Normal file
|
|
@ -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, 'id'>): 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>): 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 = [];
|
||||
}
|
||||
}
|
||||
177
src/managers/EventRenderer.ts
Normal file
177
src/managers/EventRenderer.ts
Normal file
|
|
@ -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<number, CalendarEvent[]> {
|
||||
const grouped: Record<number, CalendarEvent[]> = {};
|
||||
|
||||
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 = `
|
||||
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
|
||||
<swp-event-title>${event.title}</swp-event-title>
|
||||
`;
|
||||
|
||||
// Add event listeners
|
||||
this.addEventListeners(eventElement, event);
|
||||
|
||||
container.appendChild(eventElement);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
348
src/managers/GridManager.ts
Normal file
348
src/managers/GridManager.ts
Normal file
|
|
@ -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 = `
|
||||
<swp-day-name>${this.getDayName(date)}</swp-day-name>
|
||||
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||
`;
|
||||
(header as any).dataset.date = this.formatDate(date);
|
||||
(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;
|
||||
}
|
||||
}
|
||||
239
src/managers/NavigationManager.ts
Normal file
239
src/managers/NavigationManager.ts
Normal file
|
|
@ -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 = `
|
||||
<swp-week-header></swp-week-header>
|
||||
<swp-scrollable-content>
|
||||
<swp-time-grid>
|
||||
<swp-grid-lines></swp-grid-lines>
|
||||
<swp-day-columns></swp-day-columns>
|
||||
</swp-time-grid>
|
||||
</swp-scrollable-content>
|
||||
`;
|
||||
|
||||
// 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
174
src/managers/ViewManager.ts
Normal file
174
src/managers/ViewManager.ts
Normal file
|
|
@ -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 = `
|
||||
<swp-day-name>${days[i]}</swp-day-name>
|
||||
<swp-day-date>${i + 1}</swp-day-date>
|
||||
`;
|
||||
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
|
||||
}
|
||||
}
|
||||
103
src/types/CalendarTypes.ts
Normal file
103
src/types/CalendarTypes.ts
Normal file
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
230
src/utils/DateUtils.ts
Normal file
230
src/utils/DateUtils.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
291
src/utils/PositionUtils.ts
Normal file
291
src/utils/PositionUtils.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
190
wwwroot/css/calendar-base-css.css
Normal file
190
wwwroot/css/calendar-base-css.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
184
wwwroot/css/calendar-components-css.css
Normal file
184
wwwroot/css/calendar-components-css.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
263
wwwroot/css/calendar-events-css.css
Normal file
263
wwwroot/css/calendar-events-css.css
Normal file
|
|
@ -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));
|
||||
}
|
||||
257
wwwroot/css/calendar-layout-css.css
Normal file
257
wwwroot/css/calendar-layout-css.css
Normal file
|
|
@ -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));
|
||||
}
|
||||
190
wwwroot/css/calendar-popup-css.css
Normal file
190
wwwroot/css/calendar-popup-css.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
498
wwwroot/css/calendar.css
Normal file
498
wwwroot/css/calendar.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
122
wwwroot/index.html
Normal file
122
wwwroot/index.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calendar Plantempus - Week View</title>
|
||||
|
||||
<!-- CSS Files -->
|
||||
<link rel="stylesheet" href="css/calendar.css">
|
||||
|
||||
<!-- Additional styles for view selector -->
|
||||
<style>
|
||||
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 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;
|
||||
}
|
||||
|
||||
/* Week info display */
|
||||
swp-week-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
justify-self: start;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
swp-week-number {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
swp-date-range {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<swp-calendar data-view="week" data-week-days="7" data-snap-interval="15">
|
||||
<!-- Navigation Bar -->
|
||||
<swp-calendar-nav>
|
||||
<swp-nav-group>
|
||||
<swp-nav-button data-action="prev">←</swp-nav-button>
|
||||
<swp-nav-button data-action="next">→</swp-nav-button>
|
||||
<swp-nav-button data-action="today">Today</swp-nav-button>
|
||||
</swp-nav-group>
|
||||
|
||||
<swp-week-info>
|
||||
<swp-week-number>Week 3</swp-week-number>
|
||||
<swp-date-range>Jan 15 - Jan 21, 2024</swp-date-range>
|
||||
</swp-week-info>
|
||||
|
||||
<swp-search-container>
|
||||
<swp-search-icon>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||
<circle cx="6" cy="6" r="5" stroke="currentColor" fill="none" stroke-width="1.5"/>
|
||||
<path d="M10 10l4 4" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</swp-search-icon>
|
||||
<input type="search" placeholder="Search events..." />
|
||||
<swp-search-clear hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16">
|
||||
<path d="M5 5l6 6M11 5l-6 6" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</swp-search-clear>
|
||||
</swp-search-container>
|
||||
|
||||
<swp-view-selector>
|
||||
<swp-view-button data-view="day">Day</swp-view-button>
|
||||
<swp-view-button data-view="week" data-active="true">Week</swp-view-button>
|
||||
<swp-view-button data-view="month" disabled>Month</swp-view-button>
|
||||
</swp-view-selector>
|
||||
</swp-calendar-nav>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<swp-calendar-container>
|
||||
<swp-time-axis></swp-time-axis>
|
||||
|
||||
<swp-week-container>
|
||||
<swp-week-header></swp-week-header>
|
||||
|
||||
<swp-scrollable-content>
|
||||
<swp-time-grid>
|
||||
<swp-grid-lines></swp-grid-lines>
|
||||
<swp-day-columns></swp-day-columns>
|
||||
</swp-time-grid>
|
||||
</swp-scrollable-content>
|
||||
</swp-week-container>
|
||||
</swp-calendar-container>
|
||||
|
||||
<swp-loading-overlay hidden>
|
||||
<swp-spinner></swp-spinner>
|
||||
</swp-loading-overlay>
|
||||
</swp-calendar>
|
||||
|
||||
<!-- JavaScript Bundle -->
|
||||
<script type="module" src="js/calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue