diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b8def76..5472cb5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "WebFetch(domain:web.dev)", "WebFetch(domain:caniuse.com)", "WebFetch(domain:blog.rasc.ch)", - "WebFetch(domain:developer.chrome.com)" + "WebFetch(domain:developer.chrome.com)", + "Bash(npx tsc:*)" ], "deny": [], "ask": [] diff --git a/build.js b/build.js index 573846d..b021cae 100644 --- a/build.js +++ b/build.js @@ -32,7 +32,7 @@ async function renameFiles(dir) { // Build with esbuild async function build() { try { - + // Main calendar bundle (with DI) await esbuild.build({ entryPoints: ['src/index.ts'], bundle: true, @@ -46,6 +46,20 @@ async function build() { plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true, performanceLogging: true })] }); + // V2 standalone bundle (no DI, no dependencies on main calendar) + await esbuild.build({ + entryPoints: ['src/v2/entry.ts'], + bundle: true, + outfile: 'wwwroot/js/calendar-v2.js', + format: 'esm', + sourcemap: 'inline', + target: 'es2020', + minify: false, + keepNames: true, + platform: 'browser' + }); + + console.log('V2 bundle created: wwwroot/js/calendar-v2.js'); } catch (error) { console.error('Build failed:', error); diff --git a/src/index.ts b/src/index.ts index 5757592..1b8f91d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,4 +280,5 @@ if (document.readyState === 'loading') { initializeCalendar().catch(error => { console.error('Calendar initialization failed:', error); }); -} \ No newline at end of file +} + diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts new file mode 100644 index 0000000..b36aa6b --- /dev/null +++ b/src/v2/core/CalendarOrchestrator.ts @@ -0,0 +1,147 @@ +import { ViewConfig, GroupingConfig } from './ViewConfig'; +import { RenderContext } from './RenderContext'; +import { RendererRegistry } from './RendererRegistry'; +import { IStoreRegistry } from './IGroupingStore'; + +interface HierarchyNode { + type: string; + id: string; + data: unknown; + parentId?: string; + children: HierarchyNode[]; +} + +interface GroupingData { + items: { id: string; data: unknown }[]; + byParent: Map | null; +} + +export class CalendarOrchestrator { + constructor( + private rendererRegistry: RendererRegistry, + private storeRegistry: IStoreRegistry + ) {} + + async render(viewConfig: ViewConfig, container: HTMLElement): Promise { + const headerContainer = container.querySelector('swp-calendar-header') as HTMLElement; + const columnContainer = container.querySelector('swp-day-columns') as HTMLElement; + if (!headerContainer || !columnContainer) { + throw new Error('Missing swp-calendar-header or swp-day-columns'); + } + + const groupingData = this.fetchAllData(viewConfig.groupings); + const hierarchy = this.buildHierarchy(viewConfig.groupings, groupingData); + const totalColumns = this.countLeaves(hierarchy); + + container.style.setProperty('--grid-columns', String(totalColumns)); + + const types = viewConfig.groupings.map(g => g.type); + headerContainer.dataset.levels = types.join(' '); + + headerContainer.innerHTML = ''; + columnContainer.innerHTML = ''; + + 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 + }); + } + + private fetchAllData(groupings: GroupingConfig[]): Map { + const result = new Map(); + + for (const g of groupings) { + if (g.type === 'date') { + result.set(g.type, { + items: g.values.map(v => ({ id: v, data: { id: v } })), + byParent: null + }); + continue; + } + + const rawItems = this.storeRegistry.get(g.type).getByIds(g.values); + const items = rawItems.map((item: any) => ({ id: item.id, data: item })); + const byParent = g.parentKey + ? this.groupBy(items, item => (item.data as any)[g.parentKey!]) + : null; + + result.set(g.type, { items, byParent }); + } + + return result; + } + + private buildHierarchy( + groupings: GroupingConfig[], + data: Map, + level = 0, + parentId?: string + ): HierarchyNode[] { + if (level >= groupings.length) return []; + + const g = groupings[level]; + const gData = data.get(g.type); + if (!gData) return []; + + const items = parentId && gData.byParent + ? gData.byParent.get(parentId) ?? [] + : gData.items; + + return items.map(item => ({ + type: g.type, + id: item.id, + data: item.data, + parentId, + children: this.buildHierarchy(groupings, data, level + 1, item.id) + })); + } + + private groupBy(items: T[], keyFn: (item: T) => string): Map { + const map = new Map(); + for (const item of items) { + const key = keyFn(item); + if (key == null) continue; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(item); + } + return map; + } + + private countLeaves(nodes: HierarchyNode[]): number { + return nodes.reduce((sum, n) => + sum + (n.children.length ? this.countLeaves(n.children) : 1), 0); + } + + private renderHierarchy( + nodes: HierarchyNode[], + headerContainer: HTMLElement, + columnContainer: HTMLElement, + headerRow = 1 + ): void { + for (const node of nodes) { + const renderer = this.rendererRegistry.get(node.type); + const colspan = this.countLeaves([node]) || 1; + + renderer?.render({ + headerContainer, + columnContainer, + values: [node.id], + headerRow, + columnIndex: 0, // Not used - grid auto-places + colspan, + parentId: node.parentId + } as RenderContext); + + if (node.children.length) { + this.renderHierarchy(node.children, headerContainer, columnContainer, headerRow + 1); + } + } + } +} diff --git a/src/v2/core/IGroupingRenderer.ts b/src/v2/core/IGroupingRenderer.ts new file mode 100644 index 0000000..392e381 --- /dev/null +++ b/src/v2/core/IGroupingRenderer.ts @@ -0,0 +1,6 @@ +import { RenderContext } from './RenderContext'; + +export interface IGroupingRenderer { + readonly type: string; + render(context: RenderContext): void; +} diff --git a/src/v2/core/IGroupingStore.ts b/src/v2/core/IGroupingStore.ts new file mode 100644 index 0000000..0878905 --- /dev/null +++ b/src/v2/core/IGroupingStore.ts @@ -0,0 +1,7 @@ +export interface IGroupingStore { + getByIds(ids: string[]): T[]; +} + +export interface IStoreRegistry { + get(type: string): IGroupingStore; +} diff --git a/src/v2/core/RenderContext.ts b/src/v2/core/RenderContext.ts new file mode 100644 index 0000000..841789e --- /dev/null +++ b/src/v2/core/RenderContext.ts @@ -0,0 +1,9 @@ +export interface RenderContext { + headerContainer: HTMLElement; + columnContainer: HTMLElement; + values: string[]; + headerRow: number; + colspan: number; + parentId?: string; + columnIndex: number; // Kept for interface compatibility, but not used by renderers +} diff --git a/src/v2/core/RendererRegistry.ts b/src/v2/core/RendererRegistry.ts new file mode 100644 index 0000000..e592568 --- /dev/null +++ b/src/v2/core/RendererRegistry.ts @@ -0,0 +1,9 @@ +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/core/StoreRegistry.ts b/src/v2/core/StoreRegistry.ts new file mode 100644 index 0000000..1762057 --- /dev/null +++ b/src/v2/core/StoreRegistry.ts @@ -0,0 +1,15 @@ +import { IGroupingStore, IStoreRegistry } from './IGroupingStore'; + +export class StoreRegistry implements IStoreRegistry { + private stores = new Map(); + + register(type: string, store: IGroupingStore): void { + this.stores.set(type, store); + } + + get(type: string): IGroupingStore { + const store = this.stores.get(type); + if (!store) throw new Error(`No store for type: ${type}`); + return store; + } +} diff --git a/src/v2/core/ViewConfig.ts b/src/v2/core/ViewConfig.ts new file mode 100644 index 0000000..99535e2 --- /dev/null +++ b/src/v2/core/ViewConfig.ts @@ -0,0 +1,16 @@ +export interface ViewTemplate { + id: string; + name: string; + groupingTypes: string[]; +} + +export interface ViewConfig { + templateId: string; + groupings: GroupingConfig[]; +} + +export interface GroupingConfig { + type: string; + values: string[]; + parentKey?: string; +} diff --git a/src/v2/entry.ts b/src/v2/entry.ts new file mode 100644 index 0000000..566cb54 --- /dev/null +++ b/src/v2/entry.ts @@ -0,0 +1,7 @@ +/** + * V2 Calendar - Standalone Entry Point + * No dependencies on existing calendar system + */ + +// Re-export everything from index +export * from './index'; diff --git a/src/v2/features/date/DateRenderer.ts b/src/v2/features/date/DateRenderer.ts new file mode 100644 index 0000000..d0d50af --- /dev/null +++ b/src/v2/features/date/DateRenderer.ts @@ -0,0 +1,38 @@ +import { IGroupingRenderer } from '../../core/IGroupingRenderer'; +import { RenderContext } from '../../core/RenderContext'; + +export interface IDateService { + parseISO(dateStr: string): Date; + getDayName(date: Date, format: 'short' | 'long'): string; +} + +export const defaultDateService: IDateService = { + parseISO: (str) => new Date(str), + getDayName: (date, format) => date.toLocaleDateString('da-DK', { weekday: format }) +}; + +export class DateRenderer implements IGroupingRenderer { + readonly type = 'date'; + + constructor(private dateService: IDateService = defaultDateService) {} + + render(context: RenderContext): void { + for (const dateStr of context.values) { + const date = this.dateService.parseISO(dateStr); + + const headerCell = document.createElement('swp-day-header'); + headerCell.dataset.date = dateStr; + headerCell.innerHTML = ` + ${this.dateService.getDayName(date, 'short')} + ${date.getDate()} + `; + context.headerContainer.appendChild(headerCell); + + const column = document.createElement('swp-day-column'); + column.dataset.date = dateStr; + if (context.parentId) column.dataset.parentId = context.parentId; + column.innerHTML = ''; + context.columnContainer.appendChild(column); + } + } +} diff --git a/src/v2/features/date/index.ts b/src/v2/features/date/index.ts new file mode 100644 index 0000000..8db3850 --- /dev/null +++ b/src/v2/features/date/index.ts @@ -0,0 +1 @@ +export { DateRenderer, IDateService, defaultDateService } from './DateRenderer'; diff --git a/src/v2/features/event/EventRenderer.ts b/src/v2/features/event/EventRenderer.ts new file mode 100644 index 0000000..43d243f --- /dev/null +++ b/src/v2/features/event/EventRenderer.ts @@ -0,0 +1,71 @@ +import { IGroupingRenderer } from '../../core/IGroupingRenderer'; +import { RenderContext } from '../../core/RenderContext'; + +export interface IEventData { + id: string; + title: string; + start: Date; + end: Date; + type?: string; + allDay?: boolean; +} + +export interface IEventStore { + getByDateAndResource(date: string, resourceId?: string): Promise; +} + +export class EventRenderer implements IGroupingRenderer { + readonly type = 'event'; + + constructor( + private eventStore: IEventStore, + private hourHeight = 60, + private dayStartHour = 6 + ) {} + + render(context: RenderContext): void { + this.renderAsync(context); + } + + private async renderAsync(context: RenderContext): Promise { + const columns = context.columnContainer.querySelectorAll('swp-day-column'); + + for (const column of columns) { + const dateStr = column.dataset.date; + if (!dateStr) continue; + + const eventsLayer = column.querySelector('swp-events-layer'); + if (!eventsLayer) continue; + + 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); + } + } + } + + 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) + }; + } + + private formatTime(d: Date): string { + return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; + } +} diff --git a/src/v2/features/event/index.ts b/src/v2/features/event/index.ts new file mode 100644 index 0000000..78d57c0 --- /dev/null +++ b/src/v2/features/event/index.ts @@ -0,0 +1 @@ +export { EventRenderer, IEventData, IEventStore } from './EventRenderer'; diff --git a/src/v2/features/resource/ResourceRenderer.ts b/src/v2/features/resource/ResourceRenderer.ts new file mode 100644 index 0000000..b7b9fb4 --- /dev/null +++ b/src/v2/features/resource/ResourceRenderer.ts @@ -0,0 +1,16 @@ +import { IGroupingRenderer } from '../../core/IGroupingRenderer'; +import { RenderContext } from '../../core/RenderContext'; + +export class ResourceRenderer implements IGroupingRenderer { + readonly type = 'resource'; + + render(context: RenderContext): void { + for (const resourceId of context.values) { + const cell = document.createElement('swp-resource-header'); + cell.dataset.resourceId = resourceId; + cell.textContent = resourceId; + if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`; + context.headerContainer.appendChild(cell); + } + } +} diff --git a/src/v2/features/resource/index.ts b/src/v2/features/resource/index.ts new file mode 100644 index 0000000..3bbd0d9 --- /dev/null +++ b/src/v2/features/resource/index.ts @@ -0,0 +1 @@ +export { ResourceRenderer } from './ResourceRenderer'; diff --git a/src/v2/features/team/TeamRenderer.ts b/src/v2/features/team/TeamRenderer.ts new file mode 100644 index 0000000..c1e1d8e --- /dev/null +++ b/src/v2/features/team/TeamRenderer.ts @@ -0,0 +1,16 @@ +import { IGroupingRenderer } from '../../core/IGroupingRenderer'; +import { RenderContext } from '../../core/RenderContext'; + +export class TeamRenderer implements IGroupingRenderer { + readonly type = 'team'; + + render(context: RenderContext): void { + for (const teamId of context.values) { + const cell = document.createElement('swp-team-header'); + cell.dataset.teamId = teamId; + cell.textContent = teamId; + if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`; + context.headerContainer.appendChild(cell); + } + } +} diff --git a/src/v2/features/team/index.ts b/src/v2/features/team/index.ts new file mode 100644 index 0000000..a994520 --- /dev/null +++ b/src/v2/features/team/index.ts @@ -0,0 +1 @@ +export { TeamRenderer } from './TeamRenderer'; diff --git a/src/v2/index.ts b/src/v2/index.ts new file mode 100644 index 0000000..e835144 --- /dev/null +++ b/src/v2/index.ts @@ -0,0 +1,14 @@ +// Core exports +export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; +export { RenderContext } from './core/RenderContext'; +export { IGroupingRenderer } from './core/IGroupingRenderer'; +export { IGroupingStore, IStoreRegistry } from './core/IGroupingStore'; +export { RendererRegistry } from './core/RendererRegistry'; +export { StoreRegistry } from './core/StoreRegistry'; +export { CalendarOrchestrator } from './core/CalendarOrchestrator'; + +// Feature exports +export { DateRenderer, IDateService, defaultDateService } from './features/date'; +export { EventRenderer, IEventData, IEventStore } from './features/event'; +export { ResourceRenderer } from './features/resource'; +export { TeamRenderer } from './features/team'; diff --git a/wwwroot/css/calendar-v2.css b/wwwroot/css/calendar-v2.css new file mode 100644 index 0000000..e852901 --- /dev/null +++ b/wwwroot/css/calendar-v2.css @@ -0,0 +1,238 @@ +:root { + --hour-height: 60px; + --header-height: 60px; + --time-axis-width: 60px; + --grid-columns: 5; + --color-border: #e0e0e0; + --color-surface: #fff; + --color-text: #333; + --color-text-secondary: #666; + --color-primary: #1976d2; + --color-hour-line: #e0e0e0; + --color-current-time: #f44336; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f5f5f5; +} + +.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); +} + +swp-nav-button: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; +} + +swp-header-spacer { + grid-column: 1; + grid-row: 1; + background: var(--color-surface); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} + +/* Time axis */ +swp-time-axis { + grid-column: 1; + grid-row: 2; + border-right: 1px solid var(--color-border); + background: var(--color-surface); + overflow: hidden; +} + +swp-time-axis-content { + display: flex; + flex-direction: column; +} + +swp-hour-marker { + height: var(--hour-height); + padding: 4px 8px; + font-size: 11px; + color: var(--color-text-secondary); + text-align: right; +} + +/* Grid container */ +swp-grid-container { + grid-column: 2; + grid-row: 1 / 3; + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; +} + +/* Header */ +swp-calendar-header { + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); + grid-auto-rows: auto; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +/* Single level: date only */ +swp-calendar-header[data-levels="date"] > swp-day-header { grid-row: 1; } + +/* Two levels: resource + date */ +swp-calendar-header[data-levels="resource date"] > swp-resource-header { grid-row: 1; } +swp-calendar-header[data-levels="resource date"] > swp-day-header { grid-row: 2; } + +/* Three levels: team + resource + date */ +swp-calendar-header[data-levels="team resource date"] > swp-team-header { grid-row: 1; } +swp-calendar-header[data-levels="team resource date"] > swp-resource-header { grid-row: 2; } +swp-calendar-header[data-levels="team resource date"] > swp-day-header { grid-row: 3; } + +swp-day-header { + padding: 8px; + text-align: center; + border-right: 1px solid var(--color-border); +} + +swp-day-header:last-child { border-right: none; } + +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; +} + +swp-team-header { + padding: 10px; + text-align: center; + background: #e3f2fd; + color: #1565c0; + font-weight: 500; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} + +swp-resource-header { + padding: 8px; + text-align: center; + background: #fafafa; + font-size: 13px; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} + +/* Scrollable content */ +swp-scrollable-content { + overflow: auto; +} + +swp-time-grid { + position: relative; + min-height: calc(15 * var(--hour-height)); +} + +swp-grid-lines { + position: absolute; + inset: 0; + background: 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) + ); +} + +swp-day-columns { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(var(--grid-columns), 1fr); +} + +swp-day-column { + position: relative; + border-right: 1px solid var(--color-border); +} + +swp-day-column:last-child { border-right: none; } + +swp-events-layer { + position: absolute; + inset: 0; +} + +/* Events */ +swp-event { + position: absolute; + background: var(--color-primary); + color: white; + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + overflow: hidden; +} + +swp-event-time { + display: block; + font-size: 10px; + opacity: 0.9; +} + +swp-event-title { + display: block; + font-weight: 500; +} diff --git a/wwwroot/v2.html b/wwwroot/v2.html new file mode 100644 index 0000000..7fa53ab --- /dev/null +++ b/wwwroot/v2.html @@ -0,0 +1,131 @@ + + + + + + Calendar V2 + + + +
+ + + Datoer + Resources + Teams + + V2 + + + + + + + + + + + + + + + + + + + + +
+ + + +