From 23fcaa998548ecfdff2ec475572fb30911d9d2d3 Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Mon, 8 Dec 2025 20:05:32 +0100 Subject: [PATCH] wip --- src/v2/V2CompositionRoot.ts | 12 +- src/v2/core/CalendarOrchestrator.ts | 34 ++- src/v2/core/RendererRegistry.ts | 9 - src/v2/features/event/EventRenderer.ts | 175 +++++++++---- src/v2/features/event/index.ts | 2 +- src/v2/index.ts | 3 +- src/v2/utils/PositionUtils.ts | 58 +++++ wwwroot/css/v2/calendar-v2-base.css | 61 +++++ wwwroot/css/v2/calendar-v2-events.css | 338 ++++++++++++++++++++++++ wwwroot/css/v2/calendar-v2-layout.css | 278 ++++++++++++++++++++ wwwroot/css/v2/calendar-v2.css | 6 + wwwroot/data/mock-events.json | 348 +------------------------ wwwroot/v2.html | 2 +- 13 files changed, 900 insertions(+), 426 deletions(-) delete mode 100644 src/v2/core/RendererRegistry.ts create mode 100644 src/v2/utils/PositionUtils.ts create mode 100644 wwwroot/css/v2/calendar-v2-base.css create mode 100644 wwwroot/css/v2/calendar-v2-events.css create mode 100644 wwwroot/css/v2/calendar-v2-layout.css create mode 100644 wwwroot/css/v2/calendar-v2.css diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index e168035..eda37be 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -6,7 +6,6 @@ import { DateService } from './core/DateService'; import { ITimeFormatConfig } from './core/ITimeFormatConfig'; import { ResourceRenderer } from './features/resource/ResourceRenderer'; import { TeamRenderer } from './features/team/TeamRenderer'; -import { RendererRegistry } from './core/RendererRegistry'; import { CalendarOrchestrator } from './core/CalendarOrchestrator'; import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; import { ScrollManager } from './core/ScrollManager'; @@ -32,6 +31,9 @@ import { MockEventRepository } from './repositories/MockEventRepository'; // Workers import { DataSeeder } from './workers/DataSeeder'; +// Features +import { EventRenderer } from './features/event/EventRenderer'; + const defaultTimeFormatConfig: ITimeFormatConfig = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, use24HourFormat: true, @@ -70,14 +72,14 @@ export function createV2Container(): Container { // Workers builder.registerType(DataSeeder).as(); - // Renderers - registreres som IGroupingRenderer + // Features + builder.registerType(EventRenderer).as(); + + // Renderers - registreres som IGroupingRenderer (array injection til CalendarOrchestrator) builder.registerType(DateRenderer).as(); builder.registerType(ResourceRenderer).as(); builder.registerType(TeamRenderer).as(); - // RendererRegistry modtager IGroupingRenderer[] automatisk (array injection) - builder.registerType(RendererRegistry).as(); - // Stores - registreres som IGroupingStore builder.registerType(MockTeamStore).as(); builder.registerType(MockResourceStore).as(); diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts index ff8e035..a69b934 100644 --- a/src/v2/core/CalendarOrchestrator.ts +++ b/src/v2/core/CalendarOrchestrator.ts @@ -1,7 +1,8 @@ import { ViewConfig, GroupingConfig } from './ViewConfig'; import { RenderContext } from './RenderContext'; -import { RendererRegistry } from './RendererRegistry'; +import { IGroupingRenderer } from './IGroupingRenderer'; import { IGroupingStore } from './IGroupingStore'; +import { EventRenderer } from '../features/event/EventRenderer'; interface HierarchyNode { type: string; @@ -18,10 +19,15 @@ interface GroupingData { export class CalendarOrchestrator { constructor( - private rendererRegistry: RendererRegistry, - private stores: IGroupingStore[] + private renderers: IGroupingRenderer[], + private stores: IGroupingStore[], + private eventRenderer: EventRenderer ) {} + private getRenderer(type: string): IGroupingRenderer | undefined { + return this.renderers.find(r => r.type === type); + } + private getStore(type: string): IGroupingStore | undefined { return this.stores.find(s => s.type === type); } @@ -47,15 +53,17 @@ export class CalendarOrchestrator { this.renderHierarchy(hierarchy, headerContainer, columnContainer); - const eventRenderer = this.rendererRegistry.get('event'); - eventRenderer?.render({ - headerContainer, - columnContainer, - values: [], - headerRow: viewConfig.groupings.length + 1, - columnIndex: 1, - colspan: 1 - }); + // Render events from IndexedDB + const visibleDates = this.extractVisibleDates(viewConfig); + await this.eventRenderer.render(container, visibleDates); + } + + /** + * Extract visible dates from view config + */ + private extractVisibleDates(viewConfig: ViewConfig): string[] { + const dateGrouping = viewConfig.groupings.find(g => g.type === 'date'); + return dateGrouping?.values || []; } private fetchAllData(groupings: GroupingConfig[]): Map { @@ -132,7 +140,7 @@ export class CalendarOrchestrator { headerRow = 1 ): void { for (const node of nodes) { - const renderer = this.rendererRegistry.get(node.type); + const renderer = this.getRenderer(node.type); const colspan = this.countLeaves([node]) || 1; renderer?.render({ diff --git a/src/v2/core/RendererRegistry.ts b/src/v2/core/RendererRegistry.ts deleted file mode 100644 index e592568..0000000 --- a/src/v2/core/RendererRegistry.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IGroupingRenderer } from './IGroupingRenderer'; - -export class RendererRegistry { - constructor(private renderers: IGroupingRenderer[]) {} - - get(type: string): IGroupingRenderer | undefined { - return this.renderers.find(r => r.type === type); - } -} diff --git a/src/v2/features/event/EventRenderer.ts b/src/v2/features/event/EventRenderer.ts index 43d243f..2ec8d3b 100644 --- a/src/v2/features/event/EventRenderer.ts +++ b/src/v2/features/event/EventRenderer.ts @@ -1,71 +1,140 @@ -import { IGroupingRenderer } from '../../core/IGroupingRenderer'; -import { RenderContext } from '../../core/RenderContext'; +import { ICalendarEvent } from '../../types/CalendarTypes'; +import { EventService } from '../../storage/events/EventService'; +import { calculateEventPosition, getDateKey, formatTimeRange, GridConfig } from '../../utils/PositionUtils'; -export interface IEventData { - id: string; - title: string; - start: Date; - end: Date; - type?: string; - allDay?: boolean; -} +/** + * EventRenderer - Renders calendar events to the DOM + * + * CLEAN approach: + * - Only data-id attribute on event element + * - innerHTML contains only visible content + * - Event data retrieved via EventService when needed + */ +export class EventRenderer { + private readonly gridConfig: GridConfig = { + dayStartHour: 6, + dayEndHour: 18, + hourHeight: 64 + }; -export interface IEventStore { - getByDateAndResource(date: string, resourceId?: string): Promise; -} + constructor(private eventService: EventService) {} -export class EventRenderer implements IGroupingRenderer { - readonly type = 'event'; + /** + * Render events for visible dates into day columns + */ + async render(container: HTMLElement, visibleDates: string[]): Promise { + // Get date range for query + const startDate = new Date(visibleDates[0]); + const endDate = new Date(visibleDates[visibleDates.length - 1]); + endDate.setHours(23, 59, 59, 999); - constructor( - private eventStore: IEventStore, - private hourHeight = 60, - private dayStartHour = 6 - ) {} + // Fetch events from IndexedDB + const events = await this.eventService.getByDateRange(startDate, endDate); - render(context: RenderContext): void { - this.renderAsync(context); - } + // Group events by date + const eventsByDate = this.groupEventsByDate(events); - private async renderAsync(context: RenderContext): Promise { - const columns = context.columnContainer.querySelectorAll('swp-day-column'); + // Find day columns + const dayColumns = container.querySelector('swp-day-columns'); + if (!dayColumns) return; - for (const column of columns) { - const dateStr = column.dataset.date; - if (!dateStr) continue; + const columns = dayColumns.querySelectorAll('swp-day-column'); - const eventsLayer = column.querySelector('swp-events-layer'); - if (!eventsLayer) continue; + // Render events into columns + columns.forEach((column, index) => { + const dateKey = visibleDates[index]; + const dateEvents = eventsByDate.get(dateKey) || []; - const events = await this.eventStore.getByDateAndResource(dateStr, column.dataset.parentId); - - for (const event of events) { - if (event.allDay) continue; - - const { top, height } = this.calculatePosition(event.start, event.end); - const el = document.createElement('swp-event'); - el.dataset.eventId = event.id; - el.dataset.type = event.type || 'work'; - el.style.cssText = `position:absolute;top:${top}px;height:${height}px;left:2px;right:2px`; - el.innerHTML = ` - ${this.formatTime(event.start)} - ${this.formatTime(event.end)} - ${event.title} - `; - eventsLayer.appendChild(el); + // Get or create events layer + let eventsLayer = column.querySelector('swp-events-layer'); + if (!eventsLayer) { + eventsLayer = document.createElement('swp-events-layer'); + column.appendChild(eventsLayer); } + + // Clear existing events + eventsLayer.innerHTML = ''; + + // Render each event + dateEvents.forEach(event => { + if (!event.allDay) { + const eventElement = this.createEventElement(event); + eventsLayer!.appendChild(eventElement); + } + }); + }); + } + + /** + * Group events by their date key + */ + private groupEventsByDate(events: ICalendarEvent[]): Map { + const map = new Map(); + + events.forEach(event => { + const dateKey = getDateKey(event.start); + const existing = map.get(dateKey) || []; + existing.push(event); + map.set(dateKey, existing); + }); + + return map; + } + + /** + * Create a single event element + * + * CLEAN approach: + * - Only data-id for lookup + * - Visible content in innerHTML only + */ + private createEventElement(event: ICalendarEvent): HTMLElement { + const element = document.createElement('swp-event'); + + // Only essential data attribute + element.dataset.id = event.id; + + // Calculate position + const position = calculateEventPosition(event.start, event.end, this.gridConfig); + element.style.top = `${position.top}px`; + element.style.height = `${position.height}px`; + + // Color class based on event type + const colorClass = this.getColorClass(event); + if (colorClass) { + element.classList.add(colorClass); } + + // Visible content only + element.innerHTML = ` + ${formatTimeRange(event.start, event.end)} + ${this.escapeHtml(event.title)} + ${event.description ? `${this.escapeHtml(event.description)}` : ''} + `; + + return element; } - private calculatePosition(start: Date, end: Date) { - const startMin = start.getHours() * 60 + start.getMinutes() - this.dayStartHour * 60; - const endMin = end.getHours() * 60 + end.getMinutes() - this.dayStartHour * 60; - return { - top: (startMin / 60) * this.hourHeight, - height: Math.max(((endMin - startMin) / 60) * this.hourHeight, 15) + /** + * Get color class based on event type + */ + private getColorClass(event: ICalendarEvent): string { + const typeColors: Record = { + 'customer': 'is-blue', + 'vacation': 'is-green', + 'break': 'is-amber', + 'meeting': 'is-purple', + 'blocked': 'is-red' }; + return typeColors[event.type] || 'is-blue'; } - private formatTime(d: Date): string { - return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; + /** + * Escape HTML to prevent XSS + */ + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; } } diff --git a/src/v2/features/event/index.ts b/src/v2/features/event/index.ts index 78d57c0..7b8f118 100644 --- a/src/v2/features/event/index.ts +++ b/src/v2/features/event/index.ts @@ -1 +1 @@ -export { EventRenderer, IEventData, IEventStore } from './EventRenderer'; +export { EventRenderer } from './EventRenderer'; diff --git a/src/v2/index.ts b/src/v2/index.ts index 0027d47..7ddcc28 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -3,7 +3,6 @@ export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; export { RenderContext } from './core/RenderContext'; export { IGroupingRenderer } from './core/IGroupingRenderer'; export { IGroupingStore } from './core/IGroupingStore'; -export { RendererRegistry } from './core/RendererRegistry'; export { CalendarOrchestrator } from './core/CalendarOrchestrator'; export { NavigationAnimator } from './core/NavigationAnimator'; @@ -11,7 +10,7 @@ export { NavigationAnimator } from './core/NavigationAnimator'; export { DateRenderer } from './features/date'; export { DateService } from './core/DateService'; export { ITimeFormatConfig } from './core/ITimeFormatConfig'; -export { EventRenderer, IEventData, IEventStore } from './features/event'; +export { EventRenderer } from './features/event'; export { ResourceRenderer } from './features/resource'; export { TeamRenderer } from './features/team'; export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; diff --git a/src/v2/utils/PositionUtils.ts b/src/v2/utils/PositionUtils.ts new file mode 100644 index 0000000..02bed4f --- /dev/null +++ b/src/v2/utils/PositionUtils.ts @@ -0,0 +1,58 @@ +/** + * PositionUtils - Event position calculations + * + * Converts between time and pixel positions for calendar events. + */ + +export interface EventPosition { + top: number; // pixels from day start + height: number; // pixels +} + +export interface GridConfig { + dayStartHour: number; + dayEndHour: number; + hourHeight: number; +} + +/** + * Calculate pixel position for an event based on its times + */ +export function calculateEventPosition( + start: Date, + end: Date, + config: GridConfig +): EventPosition { + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + + const dayStartMinutes = config.dayStartHour * 60; + const minuteHeight = config.hourHeight / 60; + + const top = (startMinutes - dayStartMinutes) * minuteHeight; + const height = (endMinutes - startMinutes) * minuteHeight; + + return { top, height }; +} + +/** + * Get the date key (YYYY-MM-DD) for a Date object + */ +export function getDateKey(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Format time range for display (e.g., "10:00 - 11:30") + */ +export function formatTimeRange(start: Date, end: Date): string { + const formatTime = (d: Date) => { + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return `${h}:${m}`; + }; + return `${formatTime(start)} - ${formatTime(end)}`; +} diff --git a/wwwroot/css/v2/calendar-v2-base.css b/wwwroot/css/v2/calendar-v2-base.css new file mode 100644 index 0000000..7fa60c1 --- /dev/null +++ b/wwwroot/css/v2/calendar-v2-base.css @@ -0,0 +1,61 @@ +/* V2 Base - Shared variables */ + +:root { + /* Grid measurements */ + --hour-height: 64px; + --time-axis-width: 60px; + --grid-columns: 7; + --day-column-min-width: 200px; + --day-start-hour: 6; + --day-end-hour: 18; + --header-height: 70px; + + /* Colors - UI */ + --color-border: #e0e0e0; + --color-surface: #fff; + --color-background: #f5f5f5; + --color-text: #333333; + --color-text-secondary: #666; + --color-primary: #1976d2; + + /* Colors - Grid */ + --color-hour-line: rgba(0, 0, 0, 0.2); + --color-grid-line-light: rgba(0, 0, 0, 0.05); + + /* Named color palette for events (fra V1) */ + --b-color-red: #e53935; + --b-color-pink: #d81b60; + --b-color-magenta: #c200c2; + --b-color-purple: #8e24aa; + --b-color-violet: #5e35b1; + --b-color-deep-purple: #4527a0; + --b-color-indigo: #3949ab; + --b-color-blue: #1e88e5; + --b-color-light-blue: #03a9f4; + --b-color-cyan: #3bc9db; + --b-color-teal: #00897b; + --b-color-green: #43a047; + --b-color-light-green: #8bc34a; + --b-color-lime: #c0ca33; + --b-color-yellow: #fdd835; + --b-color-amber: #ffb300; + --b-color-orange: #fb8c00; + --b-color-deep-orange: #f4511e; + + /* Base mix for color-mix() function */ + --b-mix: #fff; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + + /* Transitions */ + --transition-fast: 150ms ease; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-background); +} diff --git a/wwwroot/css/v2/calendar-v2-events.css b/wwwroot/css/v2/calendar-v2-events.css new file mode 100644 index 0000000..d6c8295 --- /dev/null +++ b/wwwroot/css/v2/calendar-v2-events.css @@ -0,0 +1,338 @@ +/* V2 Events - Event styling (from V1 calendar-events-css.css) */ + +/* Event base styles */ +swp-day-columns swp-event { + --b-text: var(--color-text); + + position: absolute; + border-radius: 3px; + overflow: hidden; + cursor: pointer; + transition: background-color 200ms ease, box-shadow 150ms ease, transform 150ms ease; + z-index: 10; + left: 2px; + right: 2px; + font-size: 12px; + padding: 4px 6px; + + /* Color system using color-mix() */ + background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); + color: var(--b-text); + border-left: 4px solid var(--b-primary); + + /* Enable container queries for responsive layout */ + container-type: size; + container-name: event; + + /* CSS Grid layout for time, title, and description */ + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr; + gap: 2px 4px; + align-items: start; + + /* Dragging state */ + &.dragging { + position: absolute; + z-index: 999999; + opacity: 0.8; + left: 2px; + right: 2px; + width: auto; + } + + /* Hover state */ + &:hover { + background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); + } +} + +swp-day-columns swp-event:hover { + z-index: 20; +} + +/* Resize handle - actual draggable element */ +swp-resize-handle { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 15px; + cursor: ns-resize; + z-index: 25; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 150ms ease; +} + +/* Show handle on hover */ +swp-day-columns swp-event:hover swp-resize-handle { + opacity: 1; +} + +/* Handle visual indicator (grip lines) */ +swp-resize-handle::before { + content: ''; + width: 30px; + height: 4px; + background: rgba(255, 255, 255, 0.9); + border-radius: 2px; + box-shadow: + 0 -2px 0 rgba(255, 255, 255, 0.9), + 0 2px 0 rgba(255, 255, 255, 0.9), + 0 0 4px rgba(0, 0, 0, 0.2); +} + +/* Global resizing state */ +.swp--resizing { + user-select: none !important; + cursor: ns-resize !important; +} + +.swp--resizing * { + cursor: ns-resize !important; +} + +swp-day-columns swp-event-time { + grid-column: 1; + grid-row: 1; + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; +} + +swp-day-columns swp-event-title { + grid-column: 2; + grid-row: 1; + font-size: 0.875rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +swp-day-columns swp-event-description { + grid-column: 1 / -1; + grid-row: 2; + display: block; + font-size: 0.875rem; + opacity: 0.8; + line-height: 1.3; + overflow: hidden; + word-wrap: break-word; + + /* Ensure description fills available height for gradient effect */ + min-height: 100%; + align-self: stretch; + + /* Fade-out effect for long descriptions */ + -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); + mask-image: linear-gradient(to bottom, black 70%, transparent 100%); +} + +/* Container queries for height-based layout */ + +/* Hide description when event is too short (< 30px) */ +@container event (height < 30px) { + swp-day-columns swp-event-description { + display: none; + } +} + +/* Full description for tall events (>= 100px) */ +@container event (height >= 100px) { + swp-day-columns swp-event-description { + max-height: none; + } +} + +/* Multi-day events */ +swp-multi-day-event { + position: relative; + height: 28px; + margin: 2px 4px; + padding: 0 8px; + border-radius: 4px; + display: flex; + align-items: center; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + /* Color system using color-mix() */ + --b-text: var(--color-text); + background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); + color: var(--b-text); + border-left: 4px solid var(--b-primary); + + &:hover { + background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); + } + + /* Continuation indicators */ + &[data-continues-before="true"] { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: 0; + padding-left: 20px; + + &::before { + content: '\25C0'; + position: absolute; + left: 4px; + opacity: 0.6; + font-size: 0.75rem; + } + } + + &[data-continues-after="true"] { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-right: 0; + padding-right: 20px; + + &::after { + content: '\25B6'; + position: absolute; + right: 4px; + opacity: 0.6; + font-size: 0.75rem; + } + } + + &:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); + } +} + +/* All-day events */ +swp-allday-event { + --b-text: var(--color-text); + background-color: color-mix(in srgb, var(--b-primary) 10%, var(--b-mix)); + color: var(--b-text); + border-left: 4px solid var(--b-primary); + cursor: pointer; + transition: background-color 200ms ease; + + &:hover { + background-color: color-mix(in srgb, var(--b-primary) 15%, var(--b-mix)); + } +} + +/* Event creation preview */ +swp-event-preview { + position: absolute; + left: 8px; + right: 8px; + background: rgba(33, 150, 243, 0.1); + border: 2px dashed var(--color-primary); + border-radius: 4px; + + /* Position via CSS variables */ + top: calc(var(--preview-start) * var(--minute-height)); + height: calc(var(--preview-duration) * var(--minute-height)); +} + +/* Event filtering styles */ +/* When filter is active, all events are dimmed by default */ +swp-events-layer[data-filter-active="true"] swp-event { + opacity: 0.2; + transition: opacity 200ms ease; +} + +/* Events that match the filter stay normal */ +swp-events-layer[data-filter-active="true"] swp-event[data-matches="true"] { + opacity: 1; +} + +/* Event overlap styling */ +/* Event group container for column sharing */ +swp-event-group { + position: absolute; + display: grid; + gap: 2px; + left: 2px; + right: 2px; + z-index: 10; +} + +/* Grid column configurations */ +swp-event-group.cols-2 { + grid-template-columns: 1fr 1fr; +} + +swp-event-group.cols-3 { + grid-template-columns: 1fr 1fr 1fr; +} + +swp-event-group.cols-4 { + grid-template-columns: 1fr 1fr 1fr 1fr; +} + +/* Stack levels using margin-left */ +swp-event-group.stack-level-0 { + margin-left: 0px; +} + +swp-event-group.stack-level-1 { + margin-left: 15px; +} + +swp-event-group.stack-level-2 { + margin-left: 30px; +} + +swp-event-group.stack-level-3 { + margin-left: 45px; +} + +swp-event-group.stack-level-4 { + margin-left: 60px; +} + +/* Shadow for stacked events (level 1+) */ +swp-event[data-stack-link]:not([data-stack-link*='"stackLevel":0']), +swp-event-group[data-stack-link]:not([data-stack-link*='"stackLevel":0']) swp-event { + box-shadow: + 0 -1px 2px rgba(0, 0, 0, 0.1), + 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Child events within grid */ +swp-event-group swp-event { + position: relative; + left: 0; + right: 0; +} + +/* All-day event transition for smooth repositioning */ +swp-allday-container swp-event.transitioning { + transition: grid-area 200ms ease-out, grid-row 200ms ease-out, grid-column 200ms ease-out; +} + +/* Color utility classes */ +.is-red { --b-primary: var(--b-color-red); } +.is-pink { --b-primary: var(--b-color-pink); } +.is-magenta { --b-primary: var(--b-color-magenta); } +.is-purple { --b-primary: var(--b-color-purple); } +.is-violet { --b-primary: var(--b-color-violet); } +.is-deep-purple { --b-primary: var(--b-color-deep-purple); } +.is-indigo { --b-primary: var(--b-color-indigo); } +.is-blue { --b-primary: var(--b-color-blue); } +.is-light-blue { --b-primary: var(--b-color-light-blue); } +.is-cyan { --b-primary: var(--b-color-cyan); } +.is-teal { --b-primary: var(--b-color-teal); } +.is-green { --b-primary: var(--b-color-green); } +.is-light-green { --b-primary: var(--b-color-light-green); } +.is-lime { --b-primary: var(--b-color-lime); } +.is-yellow { --b-primary: var(--b-color-yellow); } +.is-amber { --b-primary: var(--b-color-amber); } +.is-orange { --b-primary: var(--b-color-orange); } +.is-deep-orange { --b-primary: var(--b-color-deep-orange); } diff --git a/wwwroot/css/v2/calendar-v2-layout.css b/wwwroot/css/v2/calendar-v2-layout.css new file mode 100644 index 0000000..3cb1913 --- /dev/null +++ b/wwwroot/css/v2/calendar-v2-layout.css @@ -0,0 +1,278 @@ +/* V2 Layout - Calendar structure, grid, navigation */ + +.calendar-wrapper { + height: 100vh; + display: flex; + flex-direction: column; +} + +swp-calendar { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + background: var(--color-surface); +} + +/* Nav */ +swp-calendar-nav { + display: flex; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + align-items: center; +} + +swp-nav-button { + padding: 8px 16px; + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: pointer; + background: var(--color-surface); + + &:hover { background: #f0f0f0; } +} + +swp-week-info { + margin-left: auto; + text-align: right; + + swp-week-number { + font-weight: 600; + display: block; + } + + swp-date-range { + font-size: 12px; + color: var(--color-text-secondary); + } +} + +/* Container */ +swp-calendar-container { + display: grid; + grid-template-columns: var(--time-axis-width) 1fr; + grid-template-rows: auto 1fr; + overflow: hidden; + height: 100%; +} + +/* Time axis */ +swp-time-axis { + grid-column: 1; + grid-row: 1 / 3; + display: grid; + grid-template-rows: auto 1fr; + border-right: 1px solid var(--color-border); + background: var(--color-surface); + overflow: hidden; +} + +swp-header-spacer { + border-bottom: 1px solid var(--color-border); +} + +swp-header-drawer { + display: block; + height: 0; + overflow: hidden; + background: #fafafa; + border-bottom: 1px solid var(--color-border); +} + +swp-time-axis-content { + display: flex; + flex-direction: column; + position: relative; +} + +swp-hour-marker { + height: var(--hour-height); + padding: 4px 8px; + font-size: 11px; + color: var(--color-text-secondary); + text-align: right; + position: relative; + + &::after { + content: ''; + position: absolute; + top: -1px; + right: 0; + width: 5px; + height: 1px; + background: var(--color-hour-line); + } + + &:first-child::after { + display: none; + } +} + +/* Grid container */ +swp-grid-container { + grid-column: 2; + grid-row: 1 / 3; + display: grid; + grid-template-rows: subgrid; + overflow: hidden; +} + +/* Viewport/Track for slide animation */ +swp-header-viewport { + overflow: hidden; +} + +swp-content-viewport { + overflow: hidden; + min-height: 0; +} + +swp-header-track { + display: flex; + + > swp-calendar-header { flex: 0 0 100%; } +} + +swp-content-track { + display: flex; + height: 100%; + + > swp-scrollable-content { + flex: 0 0 100%; + height: 100%; + } +} + +/* Header */ +swp-calendar-header { + display: grid; + grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr)); + min-width: calc(var(--grid-columns) * var(--day-column-min-width)); + grid-auto-rows: auto; + background: var(--color-surface); + overflow-y: scroll; + overflow-x: hidden; + + &::-webkit-scrollbar { background: transparent; } + &::-webkit-scrollbar-thumb { background: transparent; } + + &[data-levels="date"] > swp-day-header { grid-row: 1; } + + &[data-levels="resource date"] { + > swp-resource-header { grid-row: 1; } + > swp-day-header { grid-row: 2; } + } + + &[data-levels="team resource date"] { + > swp-team-header { grid-row: 1; } + > swp-resource-header { grid-row: 2; } + > swp-day-header { grid-row: 3; } + } +} + +swp-day-header, +swp-resource-header, +swp-team-header { + padding: 8px; + text-align: center; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} + +swp-team-header { + background: #e3f2fd; + color: #1565c0; + font-weight: 500; +} + +swp-resource-header { + background: #fafafa; + font-size: 13px; +} + +swp-day-header { + swp-day-name { + display: block; + font-size: 11px; + color: var(--color-text-secondary); + text-transform: uppercase; + } + + swp-day-date { + display: block; + font-size: 24px; + font-weight: 300; + } +} + +/* Scrollable content */ +swp-scrollable-content { + display: block; + overflow: auto; +} + +swp-time-grid { + display: block; + position: relative; + min-height: calc((var(--day-end-hour) - var(--day-start-hour)) * var(--hour-height)); + min-width: calc(var(--grid-columns) * var(--day-column-min-width)); + + /* Timelinjer */ + &::after { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + background-image: repeating-linear-gradient( + to bottom, + transparent, + transparent calc(var(--hour-height) - 1px), + var(--color-hour-line) calc(var(--hour-height) - 1px), + var(--color-hour-line) var(--hour-height) + ); + pointer-events: none; + } +} + +/* Kvarterlinjer - 3 linjer per time (15, 30, 45 min) */ +swp-grid-lines { + display: block; + position: absolute; + inset: 0; + z-index: 1; + background-image: repeating-linear-gradient( + to bottom, + transparent 0, + transparent calc(var(--hour-height) / 4 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) / 4 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) / 4), + transparent calc(var(--hour-height) / 4), + transparent calc(var(--hour-height) / 2 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) / 2 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) / 2), + transparent calc(var(--hour-height) / 2), + transparent calc(var(--hour-height) * 3 / 4 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) * 3 / 4 - 1px), + var(--color-grid-line-light) calc(var(--hour-height) * 3 / 4), + transparent calc(var(--hour-height) * 3 / 4), + transparent var(--hour-height) + ); +} + +swp-day-columns { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(var(--grid-columns), minmax(var(--day-column-min-width), 1fr)); + min-width: calc(var(--grid-columns) * var(--day-column-min-width)); +} + +swp-day-column { + position: relative; + border-right: 1px solid var(--color-border); +} + +swp-events-layer { + position: absolute; + inset: 0; +} diff --git a/wwwroot/css/v2/calendar-v2.css b/wwwroot/css/v2/calendar-v2.css new file mode 100644 index 0000000..5a90f07 --- /dev/null +++ b/wwwroot/css/v2/calendar-v2.css @@ -0,0 +1,6 @@ +/* V2 Calendar - Entry point */ +/* Modular CSS architecture: one file per feature */ + +@import 'calendar-v2-base.css'; +@import 'calendar-v2-layout.css'; +@import 'calendar-v2-events.css'; diff --git a/wwwroot/data/mock-events.json b/wwwroot/data/mock-events.json index a34c713..66d800e 100644 --- a/wwwroot/data/mock-events.json +++ b/wwwroot/data/mock-events.json @@ -1,352 +1,16 @@ [ { - "id": "RES-NOV22-001", + "id": "RES-DEC08-001", "title": "Balayage", - "start": "2025-11-22T09:00:00Z", - "end": "2025-11-22T11:00:00Z", + "description": "Test event for V2 rendering", + "start": "2025-12-08T09:00:00Z", + "end": "2025-12-08T11:00:00Z", "type": "customer", "allDay": false, - "bookingId": "BOOK-NOV22-001", + "bookingId": "BOOK-DEC08-001", "resourceId": "EMP001", "customerId": "CUST001", "syncStatus": "synced", "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV22-002", - "title": "Herreklipning", - "start": "2025-11-22T09:30:00Z", - "end": "2025-11-22T10:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "indigo" } - }, - { - "id": "RES-NOV22-003", - "title": "Farvning", - "start": "2025-11-22T10:00:00Z", - "end": "2025-11-22T12:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "pink" } - }, - { - "id": "RES-NOV22-004", - "title": "Styling", - "start": "2025-11-22T13:00:00Z", - "end": "2025-11-22T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP001", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "purple" } - }, - { - "id": "RES-NOV22-005", - "title": "Vask og føn", - "start": "2025-11-22T11:00:00Z", - "end": "2025-11-22T11:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "light-green" } - }, - { - "id": "RES-NOV22-006", - "title": "Klipning dame", - "start": "2025-11-22T14:00:00Z", - "end": "2025-11-22T15:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "teal" } - }, - { - "id": "RES-NOV23-001", - "title": "Permanent", - "start": "2025-11-23T09:00:00Z", - "end": "2025-11-23T11:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 150, "color": "pink" } - }, - { - "id": "RES-NOV23-002", - "title": "Skæg trimning", - "start": "2025-11-23T10:00:00Z", - "end": "2025-11-23T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "indigo" } - }, - { - "id": "RES-NOV23-003", - "title": "Highlights", - "start": "2025-11-23T12:00:00Z", - "end": "2025-11-23T14:00:00Z", - "type": "customer", - "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV23-004", - "title": "Assistance", - "start": "2025-11-23T13:00:00Z", - "end": "2025-11-23T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT002", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "orange" } - }, - { - "id": "RES-NOV24-001", - "title": "Bryllupsfrisure", - "start": "2025-11-24T08:00:00Z", - "end": "2025-11-24T10:00:00Z", - "type": "customer", - "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV24-002", - "title": "Ombre", - "start": "2025-11-24T10:00:00Z", - "end": "2025-11-24T12:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 150, "color": "pink" } - }, - { - "id": "RES-NOV24-003", - "title": "Fade klipning", - "start": "2025-11-24T11:00:00Z", - "end": "2025-11-24T11:45:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 45, "color": "indigo" } - }, - { - "id": "RES-NOV24-004", - "title": "Klipning og vask", - "start": "2025-11-24T14:00:00Z", - "end": "2025-11-24T15:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "teal" } - }, - { - "id": "RES-NOV24-005", - "title": "Grundklipning elev", - "start": "2025-11-24T13:00:00Z", - "end": "2025-11-24T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "light-green" } - }, - { - "id": "RES-NOV25-001", - "title": "Balayage kort hår", - "description": "Daily team sync - status updates", - "start": "2025-11-25T09:00:00Z", - "end": "2025-11-25T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP001", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "purple" } - }, - { - "id": "RES-NOV25-002", - "title": "Extensions", - "start": "2025-11-25T11:00:00Z", - "end": "2025-11-25T14:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 180, "color": "pink" } - }, - { - "id": "RES-NOV25-003", - "title": "Herreklipning + skæg", - "start": "2025-11-25T09:00:00Z", - "end": "2025-11-25T10:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "indigo" } - }, - { - "id": "RES-NOV25-004", - "title": "Styling special", - "start": "2025-11-25T15:00:00Z", - "end": "2025-11-25T16:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "teal" } - }, - { - "id": "RES-NOV25-005", - "title": "Praktik vask", - "start": "2025-11-25T10:00:00Z", - "end": "2025-11-25T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT002", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "orange" } - }, - { - "id": "RES-NOV26-001", - "title": "Farvekorrektion", - "start": "2025-11-26T09:00:00Z", - "end": "2025-11-26T12:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP001", - "syncStatus": "synced", - "metadata": { "duration": 180, "color": "purple" } - }, - { - "id": "RES-NOV26-002", - "title": "Keratinbehandling", - "start": "2025-11-26T10:00:00Z", - "end": "2025-11-26T12:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 150, "color": "pink" } - }, - { - "id": "RES-NOV26-003", - "title": "Skin fade", - "start": "2025-11-26T13:00:00Z", - "end": "2025-11-26T13:45:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 45, "color": "indigo" } - }, - { - "id": "RES-NOV26-004", - "title": "Dameklipning lang", - "start": "2025-11-26T14:00:00Z", - "end": "2025-11-26T15:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "teal" } - }, - { - "id": "RES-NOV26-005", - "title": "Føntørring træning", - "start": "2025-11-26T11:00:00Z", - "end": "2025-11-26T12:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "light-green" } - }, - { - "id": "RES-NOV27-001", - "title": "Full color", - "start": "2025-11-27T09:00:00Z", - "end": "2025-11-27T11:00:00Z", - "type": "customer", - "allDay": false, - "bookingId": "BOOK-NOV22-001", - "resourceId": "EMP001", - "customerId": "CUST001", - "syncStatus": "synced", - "metadata": { "duration": 120, "color": "purple" } - }, - { - "id": "RES-NOV27-002", - "title": "Babylights", - "start": "2025-11-27T12:00:00Z", - "end": "2025-11-27T15:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP002", - "syncStatus": "synced", - "metadata": { "duration": 180, "color": "pink" } - }, - { - "id": "RES-NOV27-003", - "title": "Klassisk herreklip", - "start": "2025-11-27T10:00:00Z", - "end": "2025-11-27T10:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP003", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "indigo" } - }, - { - "id": "RES-NOV27-004", - "title": "Klipning + styling", - "start": "2025-11-27T11:00:00Z", - "end": "2025-11-27T12:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "EMP004", - "syncStatus": "synced", - "metadata": { "duration": 90, "color": "teal" } - }, - { - "id": "RES-NOV27-005", - "title": "Vask assistance", - "start": "2025-11-27T14:00:00Z", - "end": "2025-11-27T14:30:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT001", - "syncStatus": "synced", - "metadata": { "duration": 30, "color": "light-green" } - }, - { - "id": "RES-NOV27-006", - "title": "Observation", - "start": "2025-11-27T15:00:00Z", - "end": "2025-11-27T16:00:00Z", - "type": "customer", - "allDay": false, - "resourceId": "STUDENT002", - "syncStatus": "synced", - "metadata": { "duration": 60, "color": "orange" } } -] \ No newline at end of file +] diff --git a/wwwroot/v2.html b/wwwroot/v2.html index c4414ba..c1fd702 100644 --- a/wwwroot/v2.html +++ b/wwwroot/v2.html @@ -4,7 +4,7 @@ Calendar V2 - +