Cleanup test files and move to another folder

This commit is contained in:
Janus C. H. Knudsen 2025-11-05 00:07:19 +01:00
parent 8456d8aa28
commit 9c765b35ab
28 changed files with 0 additions and 1981 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

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

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

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

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

View file

@ -0,0 +1,115 @@
// 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
});
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();

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

View file

@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Plantempus - Month View</title>
<style>
/* Use existing Calendar Plantempus variables and styling */
:root {
--hour-height: 60px;
--minute-height: 1px;
--snap-interval: 15;
--day-column-min-width: 140px;
--week-days: 7;
--header-height: 80px;
--color-primary: #2196f3;
--color-secondary: #ff9800;
--color-success: #4caf50;
--color-warning: #ff5722;
--color-error: #f44336;
--color-text: #2c3e50;
--color-text-secondary: #6c757d;
--color-border: #e0e0e0;
--color-background: #ffffff;
--color-surface: #f8f9fa;
--color-hover: #f0f0f0;
--transition-fast: 0.15s ease;
--border-radius: 4px;
--box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--color-surface);
color: var(--color-text);
line-height: 1.5;
}
/* Month grid container - matches existing swp-calendar-container */
.month-container {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
overflow: hidden;
margin: 20px;
}
/* Month grid layout with week numbers */
.month-grid {
display: grid;
grid-template-columns: 40px repeat(7, 1fr); /* Small column for week numbers + 7 days */
grid-template-rows: 40px repeat(6, 1fr);
min-height: 600px;
}
/* Week number header */
.week-header {
grid-column: 1;
grid-row: 1;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
height: 40px;
}
/* Day headers - only day names, right aligned, smaller height */
.month-day-header {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 12px;
font-weight: 600;
color: var(--color-text-secondary);
font-size: 0.875rem;
height: 40px;
}
.month-day-header:last-child {
border-right: none;
}
/* Week number cells */
.week-number {
grid-column: 1;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
}
/* Month day cells - similar to existing day columns */
.month-day-cell {
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
padding: 8px;
background: var(--color-background);
transition: background-color var(--transition-fast);
position: relative;
min-height: 100px;
cursor: pointer;
}
.month-day-cell:hover {
background: var(--color-hover);
}
.month-day-cell:last-child {
border-right: none;
}
.month-day-cell.other-month {
background: var(--color-surface);
color: var(--color-text-secondary);
}
.month-day-cell.today {
background: #f0f8ff;
border-left: 3px solid var(--color-primary);
}
.month-day-cell.weekend {
background: #fafbfc;
}
.month-day-number {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.875rem;
}
.month-day-cell.today .month-day-number {
color: var(--color-primary);
font-weight: 700;
}
/* Month events styling - compact version of existing events */
.month-events {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 70px;
overflow: hidden;
}
.month-event {
background: #e3f2fd;
color: var(--color-primary);
padding: 1px 4px;
border-radius: 2px;
font-size: 10px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border-left: 2px solid var(--color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.month-event:hover {
transform: translateY(-1px);
}
/* Event categories - using existing color scheme */
.month-event.category-meeting {
background: #e8f5e8;
color: var(--color-success);
border-left-color: var(--color-success);
}
.month-event.category-deadline {
background: #ffebee;
color: var(--color-error);
border-left-color: var(--color-error);
}
.month-event.category-work {
background: #fff8e1;
color: var(--color-secondary);
border-left-color: var(--color-secondary);
}
.month-event.category-personal {
background: #f3e5f5;
color: #7b1fa2;
border-left-color: #9c27b0;
}
.month-event-more {
background: var(--color-surface);
color: var(--color-text-secondary);
padding: 1px 4px;
border-radius: 2px;
font-size: 9px;
text-align: center;
cursor: pointer;
border: 1px dashed var(--color-border);
margin-top: 1px;
}
.month-event-more:hover {
background: var(--color-hover);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.month-container {
margin: 10px;
}
.month-grid {
grid-template-columns: 30px repeat(7, 1fr);
min-height: 400px;
}
.month-day-cell {
min-height: 60px;
padding: 4px;
}
.month-day-header {
padding: 8px 4px;
font-size: 0.75rem;
}
.month-event {
font-size: 9px;
padding: 1px 3px;
}
.month-events {
max-height: 40px;
}
.week-number {
font-size: 0.6rem;
}
}
</style>
</head>
<body>
<div class="month-container">
<div class="month-grid">
<!-- Week number header -->
<div class="week-header">Uge</div>
<!-- Day headers - only day names, right aligned -->
<div class="month-day-header">Man</div>
<div class="month-day-header">Tir</div>
<div class="month-day-header">Ons</div>
<div class="month-day-header">Tor</div>
<div class="month-day-header">Fre</div>
<div class="month-day-header">Lør</div>
<div class="month-day-header">Søn</div>
<!-- Week 1 -->
<div class="week-number">1</div>
<div class="month-day-cell other-month">
<div class="month-day-number">30</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">31</div>
<div class="month-events">
<div class="month-event category-personal">Nytår</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">1</div>
<div class="month-events">
<div class="month-event category-personal">Nytårsdag</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">2</div>
<div class="month-events">
<div class="month-event category-work">Tilbage på arbejde</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">3</div>
<div class="month-events">
<div class="month-event category-meeting">Team møde</div>
<div class="month-event category-work">Review</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">4</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">5</div>
<div class="month-events">
<div class="month-event category-personal">Familie</div>
</div>
</div>
<!-- Week 2 -->
<div class="week-number">2</div>
<div class="month-day-cell">
<div class="month-day-number">6</div>
<div class="month-events">
<div class="month-event category-meeting">Stand-up</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">7</div>
<div class="month-events">
<div class="month-event category-deadline">Rapport</div>
<div class="month-event category-meeting">1:1</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">8</div>
<div class="month-events">
<div class="month-event category-work">Code review</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">9</div>
<div class="month-events">
<div class="month-event category-meeting">Planning</div>
<div class="month-event category-work">Design</div>
<div class="month-event-more">+1</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">10</div>
<div class="month-events">
<div class="month-event category-work">Sprint review</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">11</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">12</div>
<div class="month-events">
<div class="month-event category-personal">Brunch</div>
</div>
</div>
<!-- Week 3 -->
<div class="week-number">3</div>
<div class="month-day-cell">
<div class="month-day-number">13</div>
<div class="month-events">
<div class="month-event category-meeting">All hands</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">14</div>
<div class="month-events">
<div class="month-event category-work">Deploy</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">15</div>
<div class="month-events">
<div class="month-event category-deadline">Presentation</div>
<div class="month-event category-meeting">Retro</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">16</div>
<div class="month-events">
<div class="month-event category-work">Bug triage</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">17</div>
<div class="month-events">
<div class="month-event category-meeting">Planning</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">18</div>
<div class="month-events">
<div class="month-event category-personal">Koncert</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">19</div>
</div>
<!-- Week 4 - Today -->
<div class="week-number">4</div>
<div class="month-day-cell today">
<div class="month-day-number">20</div>
<div class="month-events">
<div class="month-event category-meeting">Status møde</div>
<div class="month-event category-work">Refactoring</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">21</div>
<div class="month-events">
<div class="month-event category-deadline">Beta release</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">22</div>
<div class="month-events">
<div class="month-event category-meeting">Architecture</div>
<div class="month-event category-work">Performance</div>
<div class="month-event category-deadline">Docs</div>
<div class="month-event-more">+2</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">23</div>
<div class="month-events">
<div class="month-event category-work">Cleanup</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">24</div>
<div class="month-events">
<div class="month-event category-meeting">Weekly sync</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">25</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">26</div>
<div class="month-events">
<div class="month-event category-personal">Fødselsdag</div>
</div>
</div>
<!-- Week 5 -->
<div class="week-number">5</div>
<div class="month-day-cell">
<div class="month-day-number">27</div>
<div class="month-events">
<div class="month-event category-meeting">Q1 planning</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">28</div>
<div class="month-events">
<div class="month-event category-work">Tech talks</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">29</div>
<div class="month-events">
<div class="month-event category-deadline">Monthly report</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">30</div>
<div class="month-events">
<div class="month-event category-meeting">Team building</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">31</div>
<div class="month-events">
<div class="month-event category-deadline">End of month</div>
</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">1</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">2</div>
</div>
<!-- Week 6 -->
<div class="week-number">6</div>
<div class="month-day-cell other-month">
<div class="month-day-number">3</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">4</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">5</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">6</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">7</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">8</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">9</div>
</div>
</div>
</div>
<script>
// Add click handlers for events and days
document.addEventListener('click', function(e) {
if (e.target.classList.contains('month-event')) {
console.log('Event clicked:', e.target.textContent);
}
if (e.target.classList.contains('month-event-more')) {
console.log('Show more events');
}
if (e.target.classList.contains('month-day-cell') || e.target.closest('.month-day-cell')) {
const cell = e.target.closest('.month-day-cell');
const dayNumber = cell.querySelector('.month-day-number').textContent;
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,848 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Plantempus - Month View Expanded</title>
<style>
/* Use existing Calendar Plantempus variables and styling */
:root {
/* Event duration sizing */
--pixels-per-hour: 30px;
--min-event-height: 15px; /* 30 min minimum */
--color-primary: #2196f3;
--color-secondary: #ff9800;
--color-success: #4caf50;
--color-warning: #ff5722;
--color-error: #f44336;
--color-text: #2c3e50;
--color-text-secondary: #6c757d;
--color-border: #e0e0e0;
--color-background: #ffffff;
--color-surface: #f8f9fa;
--color-hover: #f0f0f0;
--transition-fast: 0.15s ease;
--border-radius: 4px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--color-surface);
color: var(--color-text);
line-height: 1.5;
}
/* Month grid container */
.month-container {
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
overflow: hidden;
margin: 20px;
}
/* Month grid layout - rows auto-size based on content */
.month-grid {
display: grid;
grid-template-columns: 40px repeat(7, 1fr);
grid-template-rows: 40px repeat(6, auto);
min-height: 600px;
}
/* Week number header */
.week-header {
grid-column: 1;
grid-row: 1;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
height: 40px;
}
/* Day headers */
.month-day-header {
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 12px;
font-weight: 600;
color: var(--color-text-secondary);
font-size: 0.875rem;
height: 40px;
}
.month-day-header:last-child {
border-right: none;
}
/* Week number cells */
.week-number {
grid-column: 1;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: start;
justify-content: center;
padding-top: 8px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
}
/* Month day cells */
.month-day-cell {
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
padding: 8px;
background: var(--color-background);
transition: background-color var(--transition-fast);
min-height: 80px;
cursor: pointer;
}
.month-day-cell:hover {
background: var(--color-hover);
}
.month-day-cell:last-child {
border-right: none;
}
.month-day-cell.other-month {
background: var(--color-surface);
color: var(--color-text-secondary);
}
.month-day-cell.today {
background: #f0f8ff;
border-left: 3px solid var(--color-primary);
}
.month-day-cell.weekend {
background: #fafbfc;
}
.month-day-number {
font-weight: 600;
margin-bottom: 6px;
font-size: 0.875rem;
}
.month-day-cell.today .month-day-number {
color: var(--color-primary);
font-weight: 700;
}
/* Events container */
.month-events {
display: flex;
flex-direction: column;
gap: 3px;
}
/* Individual events with duration-based minimum height */
.month-event {
background: #e3f2fd;
color: var(--color-text);
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
transition: all var(--transition-fast);
border-left: 3px solid var(--color-primary);
/* Min-height set inline based on duration */
display: flex;
flex-direction: column;
gap: 2px;
}
/* Event time range */
.event-time-range {
font-size: 11px;
color: var(--color-text-secondary);
font-weight: 500;
}
/* Event title */
.event-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text);
line-height: 1.3;
}
/* Event subtitle/description */
.event-subtitle {
font-size: 11px;
color: var(--color-text-secondary);
font-weight: 400;
line-height: 1.3;
}
.month-event:hover {
transform: translateX(2px);
background: #d1e7fd;
}
/* Event categories */
.month-event.category-meeting {
background: #e8f5e8;
border-left-color: var(--color-success);
}
.month-event.category-meeting:hover {
background: #d4edd4;
}
.month-event.category-deadline {
background: #ffebee;
border-left-color: var(--color-error);
}
.month-event.category-deadline:hover {
background: #ffd6dc;
}
.month-event.category-work {
background: #fff8e1;
border-left-color: var(--color-secondary);
}
.month-event.category-work:hover {
background: #ffedcc;
}
.month-event.category-personal {
background: #f3e5f5;
border-left-color: #9c27b0;
}
.month-event.category-personal:hover {
background: #e8d1ec;
}
/* All-day events */
.month-event.all-day {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-left: none;
}
.month-event.all-day .event-time-range,
.month-event.all-day .event-title,
.month-event.all-day .event-subtitle {
color: white;
}
.month-event.all-day:hover {
background: linear-gradient(135deg, #5a72e8 0%, #6b42a0 100%);
}
/* Short events (30 min) - compact layout */
.month-event.short-event {
flex-direction: row;
align-items: center;
gap: 8px;
}
.month-event.short-event .event-time-range {
flex-shrink: 0;
}
.month-event.short-event .event-title {
font-size: 11px;
}
.month-event.short-event .event-subtitle {
display: none;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.month-container {
margin: 10px;
}
.month-grid {
grid-template-columns: 30px repeat(7, 1fr);
min-height: 500px;
}
.month-day-cell {
padding: 4px;
min-height: 60px;
}
.month-day-header {
padding: 8px 4px;
font-size: 0.75rem;
}
.month-event {
padding: 4px 6px;
}
.event-title {
font-size: 11px;
}
.event-time-range,
.event-subtitle {
font-size: 10px;
}
.week-number {
font-size: 0.6rem;
}
:root {
--pixels-per-hour: 20px;
}
}
</style>
</head>
<body>
<div class="month-container">
<div class="month-grid">
<!-- Week number header -->
<div class="week-header">Uge</div>
<!-- Day headers -->
<div class="month-day-header">Man</div>
<div class="month-day-header">Tir</div>
<div class="month-day-header">Ons</div>
<div class="month-day-header">Tor</div>
<div class="month-day-header">Fre</div>
<div class="month-day-header">Lør</div>
<div class="month-day-header">Søn</div>
<!-- Week 1 -->
<div class="week-number">1</div>
<div class="month-day-cell other-month">
<div class="month-day-number">30</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">31</div>
<div class="month-events">
<div class="month-event category-personal" style="min-height: 60px;">
<div class="event-time-range">20:00 - 22:00</div>
<div class="event-title">Nytårsaften</div>
<div class="event-subtitle">Fest med familie og venner</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">1</div>
<div class="month-events">
<div class="month-event category-personal all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">Nytårsdag</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">2</div>
<div class="month-events">
<div class="month-event category-work" style="min-height: 30px;">
<div class="event-time-range">09:00 - 10:00</div>
<div class="event-title">Tilbage på arbejde</div>
<div class="event-subtitle">Opstart efter ferien</div>
</div>
<div class="month-event category-meeting short-event" style="min-height: 15px;">
<div class="event-time-range">10:00 - 10:30</div>
<div class="event-title">Kick-off møde</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">3</div>
<div class="month-events">
<div class="month-event category-meeting" style="min-height: 45px;">
<div class="event-time-range">09:00 - 10:30</div>
<div class="event-title">Team møde</div>
<div class="event-subtitle">Q1 planning og mål</div>
</div>
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">11:00 - 13:00</div>
<div class="event-title">Projekt review</div>
<div class="event-subtitle">Gennemgang af December projekter og status på Q1 initiativer</div>
</div>
<div class="month-event category-deadline short-event" style="min-height: 15px;">
<div class="event-time-range">15:00 - 15:30</div>
<div class="event-title">Sprint deadline</div>
</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">4</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">5</div>
<div class="month-events">
<div class="month-event category-personal" style="min-height: 75px;">
<div class="event-time-range">18:00 - 20:30</div>
<div class="event-title">Familie middag</div>
<div class="event-subtitle">Hos mormor og morfar</div>
</div>
</div>
</div>
<!-- Week 2 -->
<div class="week-number">2</div>
<div class="month-day-cell">
<div class="month-day-number">6</div>
<div class="month-events">
<div class="month-event category-meeting short-event" style="min-height: 7.5px;">
<div class="event-time-range">09:15 - 09:30</div>
<div class="event-title">Stand-up</div>
</div>
<div class="month-event category-work" style="min-height: 45px;">
<div class="event-time-range">10:00 - 11:30</div>
<div class="event-title">Code review</div>
<div class="event-subtitle">Frontend refactoring PR</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">7</div>
<div class="month-events">
<div class="month-event category-deadline" style="min-height: 30px;">
<div class="event-time-range">12:00 - 13:00</div>
<div class="event-title">Rapport deadline</div>
<div class="event-subtitle">Månedlig status rapport</div>
</div>
<div class="month-event category-meeting" style="min-height: 30px;">
<div class="event-time-range">14:00 - 15:00</div>
<div class="event-title">1:1 med chef</div>
<div class="event-subtitle">Karriere udvikling</div>
</div>
<div class="month-event category-work short-event" style="min-height: 15px;">
<div class="event-time-range">15:30 - 16:00</div>
<div class="event-title">Design review</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">8</div>
<div class="month-events">
<div class="month-event category-work" style="min-height: 30px;">
<div class="event-time-range">10:00 - 11:00</div>
<div class="event-title">Feature demo</div>
<div class="event-subtitle">Ny dashboard funktionalitet</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">9</div>
<div class="month-events">
<div class="month-event category-meeting" style="min-height: 60px;">
<div class="event-time-range">09:00 - 11:00</div>
<div class="event-title">Planning møde</div>
<div class="event-subtitle">Sprint 23 planning og estimering</div>
</div>
<div class="month-event category-work" style="min-height: 45px;">
<div class="event-time-range">11:00 - 12:30</div>
<div class="event-title">Design session</div>
<div class="event-subtitle">UX workshop for ny feature</div>
</div>
<div class="month-event category-deadline short-event" style="min-height: 15px;">
<div class="event-time-range">14:00 - 14:30</div>
<div class="event-title">Release notes</div>
</div>
<div class="month-event category-meeting" style="min-height: 30px;">
<div class="event-time-range">15:00 - 16:00</div>
<div class="event-title">Tech sync</div>
<div class="event-subtitle">Arkitektur diskussion</div>
</div>
<div class="month-event category-personal short-event" style="min-height: 15px;">
<div class="event-time-range">17:00 - 17:30</div>
<div class="event-title">Tandlæge</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">10</div>
<div class="month-events">
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">13:00 - 15:00</div>
<div class="event-title">Sprint review</div>
<div class="event-subtitle">Demo af Sprint 22 leverancer til stakeholders</div>
</div>
<div class="month-event category-meeting" style="min-height: 30px;">
<div class="event-time-range">15:00 - 16:00</div>
<div class="event-title">Retrospective</div>
<div class="event-subtitle">Sprint 22 læringer</div>
</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">11</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">12</div>
<div class="month-events">
<div class="month-event category-personal" style="min-height: 60px;">
<div class="event-time-range">11:00 - 13:00</div>
<div class="event-title">Brunch</div>
<div class="event-subtitle">Med gamle venner fra uni</div>
</div>
</div>
</div>
<!-- Week 3 -->
<div class="week-number">3</div>
<div class="month-day-cell">
<div class="month-day-number">13</div>
<div class="month-events">
<div class="month-event category-meeting all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">All hands møde</div>
<div class="event-subtitle">Quarterly business update</div>
</div>
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">14:00 - 16:00</div>
<div class="event-title">Architecture planning</div>
<div class="event-subtitle">Microservices migration strategi</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">14</div>
<div class="month-events">
<div class="month-event category-work" style="min-height: 90px;">
<div class="event-time-range">10:00 - 13:00</div>
<div class="event-title">Feature deployment</div>
<div class="event-subtitle">Production release og monitoring af ny payment feature</div>
</div>
<div class="month-event category-meeting" style="min-height: 45px;">
<div class="event-time-range">15:00 - 16:30</div>
<div class="event-title">Client call</div>
<div class="event-subtitle">Requirements gathering</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">15</div>
<div class="month-events">
<div class="month-event category-deadline" style="min-height: 60px;">
<div class="event-time-range">10:00 - 12:00</div>
<div class="event-title">Client presentation</div>
<div class="event-subtitle">Q4 results og Q1 roadmap præsentation</div>
</div>
<div class="month-event category-meeting" style="min-height: 30px;">
<div class="event-time-range">14:00 - 15:00</div>
<div class="event-title">Team retrospective</div>
<div class="event-subtitle">Monthly team health check</div>
</div>
<div class="month-event category-work" style="min-height: 30px;">
<div class="event-time-range">16:00 - 17:00</div>
<div class="event-title">Bug triage</div>
<div class="event-subtitle">Priority 1 issues</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">16</div>
<div class="month-events">
<div class="month-event category-work" style="min-height: 45px;">
<div class="event-time-range">11:00 - 12:30</div>
<div class="event-title">Performance review</div>
<div class="event-subtitle">Database optimization results</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">17</div>
<div class="month-events">
<div class="month-event category-meeting" style="min-height: 90px;">
<div class="event-time-range">09:00 - 12:00</div>
<div class="event-title">Sprint planning</div>
<div class="event-subtitle">Sprint 24 - omfattende planning session med hele teamet</div>
</div>
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">13:00 - 15:00</div>
<div class="event-title">Code cleanup</div>
<div class="event-subtitle">Technical debt reduction</div>
</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">18</div>
<div class="month-events">
<div class="month-event category-personal" style="min-height: 90px;">
<div class="event-time-range">19:00 - 22:00</div>
<div class="event-title">Koncert</div>
<div class="event-subtitle">Royal Arena - med forband og afterparty</div>
</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">19</div>
<div class="month-events"></div>
</div>
<!-- Week 4 - Today -->
<div class="week-number">4</div>
<div class="month-day-cell today">
<div class="month-day-number">20</div>
<div class="month-events">
<div class="month-event category-meeting short-event" style="min-height: 15px;">
<div class="event-time-range">09:00 - 09:30</div>
<div class="event-title">Status møde</div>
</div>
<div class="month-event category-work" style="min-height: 45px;">
<div class="event-time-range">10:30 - 12:00</div>
<div class="event-title">Refactoring session</div>
<div class="event-subtitle">Legacy code modernization</div>
</div>
<div class="month-event category-deadline" style="min-height: 30px;">
<div class="event-time-range">12:00 - 13:00</div>
<div class="event-title">Documentation due</div>
<div class="event-subtitle">API documentation update</div>
</div>
<div class="month-event category-meeting" style="min-height: 45px;">
<div class="event-time-range">14:00 - 15:30</div>
<div class="event-title">Architecture review</div>
<div class="event-subtitle">System design review med senior architects</div>
</div>
<div class="month-event category-work" style="min-height: 30px;">
<div class="event-time-range">15:30 - 16:30</div>
<div class="event-title">Performance testing</div>
<div class="event-subtitle">Load testing results</div>
</div>
<div class="month-event category-personal short-event" style="min-height: 15px;">
<div class="event-time-range">17:00 - 17:30</div>
<div class="event-title">Gym</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">21</div>
<div class="month-events">
<div class="month-event category-deadline" style="min-height: 120px;">
<div class="event-time-range">14:00 - 18:00</div>
<div class="event-title">Beta release</div>
<div class="event-subtitle">Full release process including deployment, smoke testing, monitoring setup og stakeholder notification</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">22</div>
<div class="month-events">
<div class="month-event category-meeting" style="min-height: 45px;">
<div class="event-time-range">09:00 - 10:30</div>
<div class="event-title">Architecture review</div>
<div class="event-subtitle">Final design approval</div>
</div>
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">11:00 - 13:00</div>
<div class="event-title">Performance testing</div>
<div class="event-subtitle">Full regression test suite execution</div>
</div>
<div class="month-event category-deadline" style="min-height: 30px;">
<div class="event-time-range">14:00 - 15:00</div>
<div class="event-title">Documentation deadline</div>
<div class="event-subtitle">User guide completion</div>
</div>
<div class="month-event category-meeting" style="min-height: 45px;">
<div class="event-time-range">15:00 - 16:30</div>
<div class="event-title">Stakeholder meeting</div>
<div class="event-subtitle">Project status update</div>
</div>
<div class="month-event category-work short-event" style="min-height: 15px;">
<div class="event-time-range">16:30 - 17:00</div>
<div class="event-title">Deploy to staging</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">23</div>
<div class="month-events">
<div class="month-event category-work all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">Code cleanup day</div>
<div class="event-subtitle">Team-wide technical debt reduction</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">24</div>
<div class="month-events">
<div class="month-event category-meeting" style="min-height: 30px;">
<div class="event-time-range">14:00 - 15:00</div>
<div class="event-title">Weekly sync</div>
<div class="event-subtitle">Cross-team alignment</div>
</div>
</div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">25</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell weekend">
<div class="month-day-number">26</div>
<div class="month-events">
<div class="month-event category-personal all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">Fødselsdag</div>
</div>
<div class="month-event category-personal" style="min-height: 120px;">
<div class="event-time-range">18:00 - 22:00</div>
<div class="event-title">Fødselsdagsfest</div>
<div class="event-subtitle">Stor fest med familie og venner, middag og underholdning</div>
</div>
</div>
</div>
<!-- Week 5 -->
<div class="week-number">5</div>
<div class="month-day-cell">
<div class="month-day-number">27</div>
<div class="month-events">
<div class="month-event category-meeting all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">Q1 planning - Day 1</div>
<div class="event-subtitle">Quarterly planning workshop</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">28</div>
<div class="month-events">
<div class="month-event category-meeting all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">Q1 planning - Day 2</div>
<div class="event-subtitle">OKR setting og roadmap</div>
</div>
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">14:00 - 16:00</div>
<div class="event-title">Tech talks</div>
<div class="event-subtitle">Knowledge sharing session om nye teknologier</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">29</div>
<div class="month-events">
<div class="month-event category-meeting all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">Q1 planning - Day 3</div>
<div class="event-subtitle">Final alignment og præsentation</div>
</div>
<div class="month-event category-deadline" style="min-height: 60px;">
<div class="event-time-range">16:00 - 18:00</div>
<div class="event-title">Monthly report</div>
<div class="event-subtitle">Januar status rapport og metrics sammensætning</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">30</div>
<div class="month-events">
<div class="month-event category-meeting" style="min-height: 90px;">
<div class="event-time-range">15:00 - 18:00</div>
<div class="event-title">Team building</div>
<div class="event-subtitle">Off-site team building aktivitet med middag</div>
</div>
</div>
</div>
<div class="month-day-cell">
<div class="month-day-number">31</div>
<div class="month-events">
<div class="month-event category-deadline all-day" style="min-height: 30px;">
<div class="event-time-range">Hele dagen</div>
<div class="event-title">End of month</div>
</div>
<div class="month-event category-work" style="min-height: 60px;">
<div class="event-time-range">14:00 - 16:00</div>
<div class="event-title">Month wrap-up</div>
<div class="event-subtitle">Januar review og Februar forberedelse</div>
</div>
</div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">1</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">2</div>
<div class="month-events"></div>
</div>
<!-- Week 6 -->
<div class="week-number">6</div>
<div class="month-day-cell other-month">
<div class="month-day-number">3</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">4</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">5</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">6</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">7</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">8</div>
<div class="month-events"></div>
</div>
<div class="month-day-cell other-month">
<div class="month-day-number">9</div>
<div class="month-events"></div>
</div>
</div>
</div>
<script>
// Click handlers
document.addEventListener('click', function(e) {
if (e.target.closest('.month-event')) {
const event = e.target.closest('.month-event');
console.log('Event clicked:', event.querySelector('.event-title').textContent);
// Remove previous selection
document.querySelectorAll('.month-event').forEach(el => {
el.style.outline = '';
});
// Highlight selected event
event.style.outline = '2px solid var(--color-primary)';
}
if (e.target.closest('.month-day-cell')) {
const cell = e.target.closest('.month-day-cell');
const dayNumber = cell.querySelector('.month-day-number').textContent;
console.log('Day clicked:', dayNumber);
}
});
</script>
</body>
</html>

View file

@ -1,217 +0,0 @@
De 6 vigtigste fund (med fixes)
Gruppering kan “brygge” mellem to grupper uden at merge dem
groupEventsByStartTime finder første eksisterende gruppe med konflikt og lægger eventet deri. Hvis et nyt event konflikter med flere grupper, bliver grupperne ikke merged → inkonsistente “grid”-klumper. Løs: merge alle matchende grupper eller brug union-find/sweep-line konfliktsæt.
EventStackManager
ContainerType er “GRID” for alle grupper >1 — også ved dybe overlaps
decideContainerType returnerer altid 'GRID' når events.length > 1. Det kan være tilsigtet, men så skal du være tryg ved, at lange overlappende events, der kun næsten starter samtidigt, stadig pakkes i kolonner fremfor “stacking”. Overvej: GRID kun når samtidighed er vigtigere end varighed, ellers fald tilbage til STACKING.
EventStackManager
Stack level-algoritmen kan eskalere niveauer unødigt
createOptimizedStackLinks sætter stackLevel = max(overlappende tidligere) + 1. Det er mere “stak-tårn” end “før-ledig-kolonne” og giver højere niveauer end nødvendigt (ikke minimal farvelægning). Løs: interval partitioning med min-heap (giver laveste ledige level).
EventStackManager
Grid-top beregnes fra ét event, men børn positioneres relativt til containerStart
I koordinatoren bruges earliestEvent til top, og renderer bruger earliestEvent.start som containerStart. Det er ok — men sørg for, at earliestEvent garanteret er det tidligste i gruppen og sortér eksplicit inden brug (robusthed mod fremtidige ændringer).
EventLayoutCoordinator
EventRenderer
Drag bruger rå new Date(...) i stedet for DateService
Kan give TZ/DST-glitches. Brug samme parse/logik som resten.
EventRenderer
Ingen reflow af kolonne efter drop
handleDragEnd normaliserer DOM men recalculerer ikke layout → forkert stacking/margin efter flyt. Kald din kolonne-pipeline igen for den berørte kolonne.
EventRenderer
Bonus: getEventsForColumn matcher kun start-dato === kolonnedato; events der krydser midnat forsvinder. Overvej interval-overlap mod døgnets [00:0023:59:59.999].
EventRenderer
Målrettede patches (små og sikre)
A) Merge grupper når et event rammer flere (EventStackManager)
Erstat den nuværende “find første gruppe”-logik med merge af alle matchende:
// inde i groupEventsByStartTime
const matches: number[] = [];
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi];
const conflict = group.events.some(ge => {
const s2s = Math.abs(event.start.getTime() - ge.start.getTime()) / 60000;
if (s2s <= thresholdMinutes) return true;
const e2s = (ge.end.getTime() - event.start.getTime()) / 60000;
if (e2s > 0 && e2s <= thresholdMinutes) return true;
const rev = (event.end.getTime() - ge.start.getTime()) / 60000;
if (rev > 0 && rev <= thresholdMinutes) return true;
return false;
});
if (conflict) matches.push(gi);
}
if (matches.length === 0) {
groups.push({ events: [event], containerType: 'NONE', startTime: event.start });
} else {
// merge alle matchende grupper + dette event
const base = matches[0];
groups[base].events.push(event);
for (let i = matches.length - 1; i >= 1; i--) {
const idx = matches[i];
groups[base].events.push(...groups[idx].events);
groups.splice(idx, 1);
}
// opdatér startTime til min start
groups[base].startTime = new Date(
Math.min(...groups[base].events.map(e => e.start.getTime()))
);
}
Nu undgår du “brobygning” der splitter reelt sammenhængende grupper.
EventStackManager
B) Minimal stack level med min-heap (EventStackManager)
Udskift level-tildeling med klassisk interval partitioning:
public createOptimizedStackLinks(events: CalendarEvent[]): Map<string, StackLink> {
const res = new Map<string, StackLink>();
if (!events.length) return res;
const sorted = [...events].sort((a,b)=> a.start.getTime() - b.start.getTime());
type Col = { level: number; end: number };
const cols: Col[] = []; // min-heap på end
const push = (c: Col) => { cols.push(c); cols.sort((x,y)=> x.end - y.end); };
for (const ev of sorted) {
const t = ev.start.getTime();
// find første kolonne der er fri
let placed = false;
for (let i = 0; i < cols.length; i++) {
if (cols[i].end <= t) { cols[i].end = ev.end.getTime(); res.set(ev.id, { stackLevel: cols[i].level }); placed = true; break; }
}
if (!placed) { const level = cols.length; push({ level, end: ev.end.getTime() }); res.set(ev.id, { stackLevel: level }); }
}
// evt. byg prev/next separat hvis nødvendigt
return res;
}
Dette giver laveste ledige niveau og undgår “trappetårne”.
EventStackManager
C) Konsolidér margin/zIndex + brug DateService i drag (EventRenderer)
Lad StackManager styre marginLeft konsekvent (og undgå magic numbers):
// renderGridGroup
groupElement.style.top = `${gridGroup.position.top}px`;
this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel); // i stedet for *15
this.stackManager.applyStackLinkToElement(groupElement, { stackLevel: gridGroup.stackLevel });
EventRenderer
Brug DateService i drag:
public handleDragMove(payload: DragMoveEventPayload): void {
if (!this.draggedClone || !payload.columnBounds) return;
const swp = this.draggedClone as SwpEventElement;
const colDate = this.dateService.parseISODate?.(payload.columnBounds.date) ?? new Date(payload.columnBounds.date);
swp.updatePosition(colDate, payload.snappedY);
}
public handleColumnChange(e: DragColumnChangeEventPayload): void {
if (!this.draggedClone) return;
const layer = e.newColumn.element.querySelector('swp-events-layer');
if (layer && this.draggedClone.parentElement !== layer) {
layer.appendChild(this.draggedClone);
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const swp = this.draggedClone as SwpEventElement;
const colDate = this.dateService.parseISODate?.(e.newColumn.date) ?? new Date(e.newColumn.date);
swp.updatePosition(colDate, currentTop);
}
}
EventRenderer
D) Reflow efter drop (EventRenderer)
Genberegn layout for den berørte kolonne:
public handleDragEnd(id: string, original: HTMLElement, clone: HTMLElement, finalColumn: ColumnBounds): void {
if (!clone || !original) { console.warn('Missing clone/original'); return; }
this.fadeOutAndRemove(original);
const cid = clone.dataset.eventId;
if (cid && cid.startsWith('clone-')) clone.dataset.eventId = cid.replace('clone-','');
clone.classList.remove('dragging');
const layer = finalColumn.element.querySelector('swp-events-layer') as HTMLElement | null;
if (layer) {
// 1) Hent kolonnens events fra din model/state (inkl. opdateret event)
const columnEvents: CalendarEvent[] = /* ... */;
// 2) Ryd
layer.querySelectorAll('swp-event, swp-event-group').forEach(el => el.remove());
// 3) Render igen via layout
this.renderColumnEvents(columnEvents, layer);
}
this.draggedClone = null;
this.originalEvent = null;
}
EventRenderer
E) Døgn-overlap i kolonnefilter (EventRenderer)
Hvis ønsket (ellers behold din nuværende):
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const d = column.dataset.date; if (!d) return [];
const start = this.dateService.parseISODate(`${d}T00:00:00`);
const end = this.dateService.parseISODate(`${d}T23:59:59.999`);
return events.filter(ev => ev.start < end && ev.end > start);
}
EventRenderer
F) Eksplicit “earliest” i GRID (Coordinator)
Gør det robust i tilfælde af usorteret input:
const earliestEvent = [...gridCandidates].sort((a,b)=> a.start.getTime()-b.start.getTime())[0];
const pos = PositionUtils.calculateEventPosition(earliestEvent.start, earliestEvent.end);
EventLayoutCoordinator
Mini-noter
allocateColumns er O(n²); det er fint for typiske dagvisninger. Hvis I ser >100 events/kolonne, kan I optimere med sweep-line + min-heap.
EventLayoutCoordinator
Overvej at lade koordinatoren returnere rene layout-maps (id → {level, z, margin}) og holde DOM-påføring 100% i renderer — det gør DnD-”reflow” enklere at teste.
EventLayoutCoordinator
EventRenderer

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 1: No Overlap</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 1: No Overlap</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Three sequential events with no time overlap. All events should have stack level 0 since they don't conflict.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=0 (stacked)<br>
Event C: stackLevel=0 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S1A" data-title="Scenario 1: Event A" data-start="2025-10-06T08:00:00.000Z" data-end="2025-10-06T09:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 1px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 1: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S1B" data-title="Scenario 1: Event B" data-start="2025-10-06T09:00:00.000Z" data-end="2025-10-06T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 81px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 1: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S1C" data-title="Scenario 1: Event C" data-start="2025-10-06T10:00:00.000Z" data-end="2025-10-06T11:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 161px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 1: Event C</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-1',
expected: [
{ eventId: 'S1A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1B', stackLevel: 0, type: 'stacked' },
{ eventId: 'S1C', stackLevel: 0, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 10: Four Column Grid</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 10: Four Column Grid</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Four events all starting at exactly the same time (14:00). Tests maximum column sharing with a 4-column grid layout.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 4 columns at stackLevel=0<br>
Event A: in grid<br>
Event B: in grid<br>
Event C: in grid<br>
Event D: in grid
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-4 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 561px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S10A" data-title="Scenario 10: Event A" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event A</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S10B" data-title="Scenario 10: Event B" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event B</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S10C" data-title="Scenario 10: Event C" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event C</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S10D" data-title="Scenario 10: Event D" data-start="2025-10-10T12:00:00.000Z" data-end="2025-10-10T13:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 10: Event D</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-10',
expected: [
{ eventId: 'S10A', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10B', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10C', stackLevel: 0, cols: 4, type: 'grid' },
{ eventId: 'S10D', stackLevel: 0, cols: 4, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 2: Column Sharing (Grid)</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 2: Column Sharing (Grid)</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Two events starting at exactly the same time (10:00). These should be placed in a grid container with 2 columns, allowing them to share horizontal space.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 2 columns at stackLevel=0<br>
Event A: in grid<br>
Event B: in grid
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-2 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 1px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S2A" data-title="Scenario 2: Event A" data-start="2025-10-06T08:00:00.000Z" data-end="2025-10-06T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 2: Event A</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S2B" data-title="Scenario 2: Event B" data-start="2025-10-06T08:00:00.000Z" data-end="2025-10-06T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 2: Event B</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-2',
expected: [
{ eventId: 'S2A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S2B', stackLevel: 0, cols: 2, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 3: Nested Stacking</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 3: Nested Stacking</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Progressive nesting pattern: Event A (09:00-15:00) contains B (10:00-13:00), B contains C (11:00-12:00), and C overlaps with D (12:30-13:30). Tests correct stack level calculation for nested events.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=2 (stacked)<br>
Event D: stackLevel=2 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S3A" data-title="Scenario 3: Event A" data-start="2025-10-07T07:00:00.000Z" data-end="2025-10-07T13:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 1px; height: 357px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">09:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 3: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S3B" data-title="Scenario 3: Event B" data-start="2025-10-07T08:00:00.000Z" data-end="2025-10-07T11:00:00.000Z" data-type="work" data-duration="180" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 81px; height: 217px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="180">10:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 3: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S3C" data-title="Scenario 3: Event C" data-start="2025-10-07T09:00:00.000Z" data-end="2025-10-07T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2}" style="position: absolute; top: 161px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 3: Event C</swp-event-title>
</swp-event>
<swp-event data-event-id="S3D" data-title="Scenario 3: Event D" data-start="2025-10-07T10:30:00.000Z" data-end="2025-10-07T11:30:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 3: Event D</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-3',
expected: [
{ eventId: 'S3A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S3B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S3C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S3D', stackLevel: 2, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 4: Complex Stacking</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 4: Complex Stacking</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Long event A (14:00-20:00) with multiple shorter events (B, C, D) nested inside at different times. Tests multiple stack levels with varying overlap patterns.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=2 (stacked)<br>
Event D: stackLevel=1 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S4A" data-title="Scenario 4: Event A" data-start="2025-10-07T12:00:00.000Z" data-end="2025-10-07T18:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 481px; height: 357px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">14:00 - 20:00</swp-event-time>
<swp-event-title>Scenario 4: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S4B" data-title="Scenario 4: Event B" data-start="2025-10-07T13:00:00.000Z" data-end="2025-10-07T15:00:00.000Z" data-type="work" data-duration="120" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 561px; height: 157px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="120">15:00 - 17:00</swp-event-time>
<swp-event-title>Scenario 4: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S4C" data-title="Scenario 4: Event C" data-start="2025-10-07T13:30:00.000Z" data-end="2025-10-07T14:30:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:2}" style="position: absolute; top: 601px; height: 77px; left: 2px; right: 2px; margin-left: 30px; z-index: 102;">
<swp-event-time data-duration="60">15:30 - 16:30</swp-event-time>
<swp-event-title>Scenario 4: Event C</swp-event-title>
</swp-event>
<swp-event data-event-id="S4D" data-title="Scenario 4: Event D" data-start="2025-10-07T16:00:00.000Z" data-end="2025-10-07T17:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 721px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">18:00 - 19:00</swp-event-time>
<swp-event-title>Scenario 4: Event D</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-4',
expected: [
{ eventId: 'S4A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S4B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S4C', stackLevel: 2, type: 'stacked' },
{ eventId: 'S4D', stackLevel: 1, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 5: Three Column Share</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 5: Three Column Share</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Three events all starting at exactly the same time (10:00). Should create a grid layout with 3 columns.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 3 columns at stackLevel=0<br>
Event A: in grid<br>
Event B: in grid<br>
Event C: in grid
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-3 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 1px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S5A" data-title="Scenario 5: Event A" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 5: Event A</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S5B" data-title="Scenario 5: Event B" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 5: Event B</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S5C" data-title="Scenario 5: Event C" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T09:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 5: Event C</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-5',
expected: [
{ eventId: 'S5A', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5B', stackLevel: 0, cols: 3, type: 'grid' },
{ eventId: 'S5C', stackLevel: 0, cols: 3, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 6: Overlapping Pairs</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 6: Overlapping Pairs</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Two separate pairs of overlapping events: (A, B) and (C, D). Each pair should be independent with their own stack levels.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=0 (stacked)<br>
Event D: stackLevel=1 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S6A" data-title="Scenario 6: Event A" data-start="2025-10-08T08:00:00.000Z" data-end="2025-10-08T10:00:00.000Z" data-type="work" data-duration="120" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 161px; height: 157px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="120">10:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 6: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S6B" data-title="Scenario 6: Event B" data-start="2025-10-08T09:00:00.000Z" data-end="2025-10-08T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 6: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S6C" data-title="Scenario 6: Event C" data-start="2025-10-08T11:00:00.000Z" data-end="2025-10-08T13:00:00.000Z" data-type="work" data-duration="120" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 401px; height: 157px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="120">13:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 6: Event C</swp-event-title>
</swp-event>
<swp-event data-event-id="S6D" data-title="Scenario 6: Event D" data-start="2025-10-08T12:00:00.000Z" data-end="2025-10-08T13:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 481px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">14:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 6: Event D</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-6',
expected: [
{ eventId: 'S6A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S6C', stackLevel: 0, type: 'stacked' },
{ eventId: 'S6D', stackLevel: 1, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 7: Long Event Container</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 7: Long Event Container</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>One long event (A: 09:00-15:00) containing two shorter events (B: 10:00-11:00, C: 12:00-13:00) that don't overlap with each other. B and C should both have the same stack level.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=1 (stacked)<br>
Event C: stackLevel=1 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S7A" data-title="Scenario 7: Event A" data-start="2025-10-09T07:00:00.000Z" data-end="2025-10-09T13:00:00.000Z" data-type="work" data-duration="360" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 1px; height: 357px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="360">09:00 - 15:00</swp-event-time>
<swp-event-title>Scenario 7: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S7B" data-title="Scenario 7: Event B" data-start="2025-10-09T08:00:00.000Z" data-end="2025-10-09T09:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 81px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 7: Event B</swp-event-title>
</swp-event>
<swp-event data-event-id="S7C" data-title="Scenario 7: Event C" data-start="2025-10-09T10:00:00.000Z" data-end="2025-10-09T11:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:1}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 15px; z-index: 101;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 7: Event C</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-7',
expected: [
{ eventId: 'S7A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S7B', stackLevel: 1, type: 'stacked' },
{ eventId: 'S7C', stackLevel: 1, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 8: Edge-Adjacent Events</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 8: Edge-Adjacent Events</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Events that touch but don't overlap: Event A (10:00-11:00) and Event B (11:00-12:00). A ends exactly when B starts, so they should NOT stack.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Event A: stackLevel=0 (stacked)<br>
Event B: stackLevel=0 (stacked)
</div>
</div>
<div class="calendar-column">
<swp-event data-event-id="S8A" data-title="Scenario 8: Event A" data-start="2025-10-09T08:00:00.000Z" data-end="2025-10-09T09:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 241px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">10:00 - 11:00</swp-event-time>
<swp-event-title>Scenario 8: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S8B" data-title="Scenario 8: Event B" data-start="2025-10-09T09:00:00.000Z" data-end="2025-10-09T10:00:00.000Z" data-type="work" data-duration="60" data-stack-link="{&quot;stackLevel&quot;:0}" style="position: absolute; top: 321px; height: 77px; left: 2px; right: 2px; margin-left: 0px; z-index: 100;">
<swp-event-time data-duration="60">11:00 - 12:00</swp-event-time>
<swp-event-title>Scenario 8: Event B</swp-event-title>
</swp-event>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-8',
expected: [
{ eventId: 'S8A', stackLevel: 0, type: 'stacked' },
{ eventId: 'S8B', stackLevel: 0, type: 'stacked' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenario 9: End-to-Start Chain</title>
<link rel="stylesheet" href="scenario-styles.css">
</head>
<body>
<div class="scenario-container">
<a href="../stacking-visualization-new.html" class="back-link">← Back to All Scenarios</a>
<div class="scenario-header">
<h1 class="scenario-title">Scenario 9: End-to-Start Chain</h1>
<div id="test-results"></div>
</div>
<div class="scenario-description">
<h3>Description</h3>
<p>Events linked by end-to-start conflicts within the threshold: Event A (12:00-13:00), Event B (12:30-13:30), and Event C (13:15-15:00). Even though C doesn't start close to A, it starts within threshold before B ends, creating a conflict chain A→B→C.</p>
<div class="expected-result">
<strong>Expected Result:</strong><br>
Grid group with 2 columns at stackLevel=0<br>
Event A: in grid (column 1)<br>
Event B: in grid (column 2)<br>
Event C: in grid (column 1)
</div>
</div>
<div class="calendar-column">
<swp-event-group class="cols-2 stack-level-0" data-stack-link="{&quot;stackLevel&quot;:0}" style="top: 481px; margin-left: 0px; z-index: 100;">
<div style="position: relative;">
<swp-event data-event-id="S9A" data-title="Scenario 9: Event A" data-start="2025-10-09T10:00:00.000Z" data-end="2025-10-09T11:00:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 0px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:00 - 13:00</swp-event-time>
<swp-event-title>Scenario 9: Event A</swp-event-title>
</swp-event>
<swp-event data-event-id="S9C" data-title="Scenario 9: Event C" data-start="2025-10-09T11:15:00.000Z" data-end="2025-10-09T13:00:00.000Z" data-type="work" data-duration="105" style="position: absolute; top: 100px; height: 137px; left: 0px; right: 0px;">
<swp-event-time data-duration="105">13:15 - 15:00</swp-event-time>
<swp-event-title>Scenario 9: Event C</swp-event-title>
</swp-event>
</div>
<div style="position: relative;">
<swp-event data-event-id="S9B" data-title="Scenario 9: Event B" data-start="2025-10-09T10:30:00.000Z" data-end="2025-10-09T11:30:00.000Z" data-type="work" data-duration="60" style="position: absolute; top: 40px; height: 77px; left: 0px; right: 0px;">
<swp-event-time data-duration="60">12:30 - 13:30</swp-event-time>
<swp-event-title>Scenario 9: Event B</swp-event-title>
</swp-event>
</div>
</swp-event-group>
</div>
</div>
<script type="module">
import { ScenarioTestRunner } from './scenario-test-runner.js';
window.scenarioTests = {
id: 'scenario-9',
expected: [
{ eventId: 'S9A', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9B', stackLevel: 0, cols: 2, type: 'grid' },
{ eventId: 'S9C', stackLevel: 0, cols: 2, type: 'grid' }
]
};
</script>
<script type="module" src="scenario-test-runner.js"></script>
</body>
</html>

View file

@ -0,0 +1,162 @@
/* Shared styles for all scenario visualization files */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
}
.scenario-container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1, h2, h3 {
color: #333;
margin-top: 0;
}
.scenario-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e0e0e0;
}
.scenario-title {
margin: 0;
}
.test-badge {
display: inline-block;
margin-left: 15px;
padding: 6px 14px;
border-radius: 6px;
font-weight: bold;
font-size: 14px;
vertical-align: middle;
}
.test-passed {
background: #4caf50;
color: white;
}
.test-failed {
background: #f44336;
color: white;
}
.test-pending {
background: #ff9800;
color: white;
}
.scenario-description {
background: #f8f9fa;
padding: 15px;
border-left: 4px solid #b53f7a;
margin-bottom: 20px;
}
.expected-result {
background: #e8f5e9;
padding: 12px;
border-radius: 4px;
margin: 15px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.calendar-column {
position: relative;
width: 350px;
height: 800px;
border: 2px solid #ddd;
border-radius: 8px;
background: white;
overflow: hidden;
margin: 20px 0;
}
/* Event styling */
swp-event-group {
position: absolute;
left: 0;
right: 0;
display: flex;
gap: 4px;
}
swp-event-group > div {
flex: 1;
min-width: 0;
}
swp-event {
display: block;
padding: 8px;
border-left: 4px solid #b53f7a;
background: #fff3e0;
border-radius: 4px;
font-size: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
swp-event-time {
display: block;
font-weight: bold;
margin-bottom: 4px;
color: #666;
}
swp-event-title {
display: block;
color: #333;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #b53f7a;
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.test-details {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.test-details h4 {
margin-top: 0;
color: #666;
}
.test-result-line {
padding: 4px 0;
}
.test-result-line.passed {
color: #4caf50;
}
.test-result-line.failed {
color: #f44336;
}

View file

@ -0,0 +1,152 @@
/**
* Scenario Test Runner
* Validates that rendered events match expected layout
*/
export class ScenarioTestRunner {
/**
* Validate a scenario's rendered output
* @param {string} scenarioId - e.g., "scenario-1"
* @param {Array} expectedResults - Array of {eventId, stackLevel, cols?, type: 'grid'|'stacked'}
* @returns {object} - {passed: boolean, results: Array, message: string}
*/
static validateScenario(scenarioId, expectedResults) {
const results = [];
let allPassed = true;
for (const expected of expectedResults) {
const result = this.validateEvent(expected.eventId, expected);
results.push(result);
if (!result.passed) {
allPassed = false;
}
}
return {
passed: allPassed,
results,
message: allPassed ? 'All tests passed ✅' : 'Some tests failed ❌'
};
}
/**
* Validate a single event
*/
static validateEvent(eventId, expected) {
const eventEl = document.querySelector(`swp-event[data-event-id="${eventId}"]`);
if (!eventEl) {
return {
passed: false,
eventId,
message: `Event ${eventId} not found in DOM`
};
}
const errors = [];
// Check if in grid group
const gridGroup = eventEl.closest('swp-event-group');
if (expected.type === 'grid') {
if (!gridGroup) {
errors.push(`Expected to be in grid group, but found as stacked event`);
} else {
// Validate grid group properties
const groupStackLevel = this.getStackLevel(gridGroup);
if (groupStackLevel !== expected.stackLevel) {
errors.push(`Grid group stack level: expected ${expected.stackLevel}, got ${groupStackLevel}`);
}
if (expected.cols) {
const cols = this.getColumnCount(gridGroup);
if (cols !== expected.cols) {
errors.push(`Grid columns: expected ${expected.cols}, got ${cols}`);
}
}
}
} else if (expected.type === 'stacked') {
if (gridGroup) {
errors.push(`Expected to be stacked, but found in grid group`);
} else {
// Validate stacked event properties
const stackLevel = this.getStackLevel(eventEl);
if (stackLevel !== expected.stackLevel) {
errors.push(`Stack level: expected ${expected.stackLevel}, got ${stackLevel}`);
}
}
}
return {
passed: errors.length === 0,
eventId,
message: errors.length === 0 ? '✅' : errors.join('; ')
};
}
/**
* Get stack level from element
*/
static getStackLevel(element) {
const stackLink = element.getAttribute('data-stack-link');
if (stackLink) {
try {
const parsed = JSON.parse(stackLink);
return parsed.stackLevel;
} catch (e) {
return null;
}
}
// Try class name fallback
const classMatch = element.className.match(/stack-level-(\d+)/);
return classMatch ? parseInt(classMatch[1]) : null;
}
/**
* Get column count from grid group
*/
static getColumnCount(gridGroup) {
const classMatch = gridGroup.className.match(/cols-(\d+)/);
return classMatch ? parseInt(classMatch[1]) : null;
}
/**
* Display test results in the DOM
*/
static displayResults(containerId, results) {
const container = document.getElementById(containerId);
if (!container) return;
const badge = document.createElement('span');
badge.className = `test-badge ${results.passed ? 'test-passed' : 'test-failed'}`;
badge.textContent = results.message;
container.appendChild(badge);
// Add detailed results
const details = document.createElement('div');
details.className = 'test-details';
details.innerHTML = '<h4>Test Results:</h4>';
results.results.forEach(r => {
const line = document.createElement('div');
line.className = `test-result-line ${r.passed ? 'passed' : 'failed'}`;
line.textContent = `${r.eventId}: ${r.message}`;
details.appendChild(line);
});
container.appendChild(details);
}
}
// Auto-run tests if window.scenarioTests is defined
window.addEventListener('DOMContentLoaded', () => {
if (window.scenarioTests) {
const results = ScenarioTestRunner.validateScenario(
window.scenarioTests.id,
window.scenarioTests.expected
);
ScenarioTestRunner.displayResults('test-results', results);
}
});