Initial commit: Calendar Plantempus project setup with TypeScript, ASP.NET Core, and event-driven architecture

This commit is contained in:
Janus Knudsen 2025-07-24 22:17:38 +02:00
commit f06c02121c
38 changed files with 8233 additions and 0 deletions

32
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1 @@
{"mcpServers":{}}

9
CalendarServer.csproj Normal file
View 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
View 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
View 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
View 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();

View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

File diff suppressed because it is too large Load diff

435
package-lock.json generated Normal file
View 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
View 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"
}
}

View 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
View 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
View 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
View 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();
}

View 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 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 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
View 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();
}
}

View 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 = [];
}
}

View 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
View 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;
}
}

View 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
View 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
View 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
View 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
View 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
View 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"
]
}

View 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);
}
}

View 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);
}
}
}

View 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));
}

View 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));
}

View 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
View 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
View 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>