Moving away from Azure Devops #1
22 changed files with 763 additions and 3 deletions
|
|
@ -6,7 +6,8 @@
|
||||||
"WebFetch(domain:web.dev)",
|
"WebFetch(domain:web.dev)",
|
||||||
"WebFetch(domain:caniuse.com)",
|
"WebFetch(domain:caniuse.com)",
|
||||||
"WebFetch(domain:blog.rasc.ch)",
|
"WebFetch(domain:blog.rasc.ch)",
|
||||||
"WebFetch(domain:developer.chrome.com)"
|
"WebFetch(domain:developer.chrome.com)",
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
16
build.js
16
build.js
|
|
@ -32,7 +32,7 @@ async function renameFiles(dir) {
|
||||||
// Build with esbuild
|
// Build with esbuild
|
||||||
async function build() {
|
async function build() {
|
||||||
try {
|
try {
|
||||||
|
// Main calendar bundle (with DI)
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
entryPoints: ['src/index.ts'],
|
entryPoints: ['src/index.ts'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|
@ -46,6 +46,20 @@ async function build() {
|
||||||
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true, performanceLogging: true })]
|
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) {
|
} catch (error) {
|
||||||
console.error('Build failed:', error);
|
console.error('Build failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -281,3 +281,4 @@ if (document.readyState === 'loading') {
|
||||||
console.error('Calendar initialization failed:', error);
|
console.error('Calendar initialization failed:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
147
src/v2/core/CalendarOrchestrator.ts
Normal file
147
src/v2/core/CalendarOrchestrator.ts
Normal file
|
|
@ -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<string, { id: string; data: unknown }[]> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CalendarOrchestrator {
|
||||||
|
constructor(
|
||||||
|
private rendererRegistry: RendererRegistry,
|
||||||
|
private storeRegistry: IStoreRegistry
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
|
||||||
|
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<string, GroupingData> {
|
||||||
|
const result = new Map<string, GroupingData>();
|
||||||
|
|
||||||
|
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<string, GroupingData>,
|
||||||
|
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<T>(items: T[], keyFn: (item: T) => string): Map<string, T[]> {
|
||||||
|
const map = new Map<string, T[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/v2/core/IGroupingRenderer.ts
Normal file
6
src/v2/core/IGroupingRenderer.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { RenderContext } from './RenderContext';
|
||||||
|
|
||||||
|
export interface IGroupingRenderer {
|
||||||
|
readonly type: string;
|
||||||
|
render(context: RenderContext): void;
|
||||||
|
}
|
||||||
7
src/v2/core/IGroupingStore.ts
Normal file
7
src/v2/core/IGroupingStore.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface IGroupingStore<T = unknown> {
|
||||||
|
getByIds(ids: string[]): T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStoreRegistry {
|
||||||
|
get(type: string): IGroupingStore;
|
||||||
|
}
|
||||||
9
src/v2/core/RenderContext.ts
Normal file
9
src/v2/core/RenderContext.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
9
src/v2/core/RendererRegistry.ts
Normal file
9
src/v2/core/RendererRegistry.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/v2/core/StoreRegistry.ts
Normal file
15
src/v2/core/StoreRegistry.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { IGroupingStore, IStoreRegistry } from './IGroupingStore';
|
||||||
|
|
||||||
|
export class StoreRegistry implements IStoreRegistry {
|
||||||
|
private stores = new Map<string, IGroupingStore>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/v2/core/ViewConfig.ts
Normal file
16
src/v2/core/ViewConfig.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
7
src/v2/entry.ts
Normal file
7
src/v2/entry.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* V2 Calendar - Standalone Entry Point
|
||||||
|
* No dependencies on existing calendar system
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Re-export everything from index
|
||||||
|
export * from './index';
|
||||||
38
src/v2/features/date/DateRenderer.ts
Normal file
38
src/v2/features/date/DateRenderer.ts
Normal file
|
|
@ -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 = `
|
||||||
|
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
|
||||||
|
<swp-day-date>${date.getDate()}</swp-day-date>
|
||||||
|
`;
|
||||||
|
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 = '<swp-events-layer></swp-events-layer>';
|
||||||
|
context.columnContainer.appendChild(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/v2/features/date/index.ts
Normal file
1
src/v2/features/date/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { DateRenderer, IDateService, defaultDateService } from './DateRenderer';
|
||||||
71
src/v2/features/event/EventRenderer.ts
Normal file
71
src/v2/features/event/EventRenderer.ts
Normal file
|
|
@ -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<IEventData[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
const columns = context.columnContainer.querySelectorAll<HTMLElement>('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 = `
|
||||||
|
<swp-event-time>${this.formatTime(event.start)} - ${this.formatTime(event.end)}</swp-event-time>
|
||||||
|
<swp-event-title>${event.title}</swp-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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/v2/features/event/index.ts
Normal file
1
src/v2/features/event/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { EventRenderer, IEventData, IEventStore } from './EventRenderer';
|
||||||
16
src/v2/features/resource/ResourceRenderer.ts
Normal file
16
src/v2/features/resource/ResourceRenderer.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/v2/features/resource/index.ts
Normal file
1
src/v2/features/resource/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { ResourceRenderer } from './ResourceRenderer';
|
||||||
16
src/v2/features/team/TeamRenderer.ts
Normal file
16
src/v2/features/team/TeamRenderer.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/v2/features/team/index.ts
Normal file
1
src/v2/features/team/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { TeamRenderer } from './TeamRenderer';
|
||||||
14
src/v2/index.ts
Normal file
14
src/v2/index.ts
Normal file
|
|
@ -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';
|
||||||
238
wwwroot/css/calendar-v2.css
Normal file
238
wwwroot/css/calendar-v2.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
131
wwwroot/v2.html
Normal file
131
wwwroot/v2.html
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Calendar V2</title>
|
||||||
|
<link rel="stylesheet" href="css/calendar-v2.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="calendar-wrapper">
|
||||||
|
<swp-calendar>
|
||||||
|
<swp-calendar-nav>
|
||||||
|
<swp-nav-button id="btn-simple">Datoer</swp-nav-button>
|
||||||
|
<swp-nav-button id="btn-resource">Resources</swp-nav-button>
|
||||||
|
<swp-nav-button id="btn-team">Teams</swp-nav-button>
|
||||||
|
<swp-week-info>
|
||||||
|
<swp-week-number>V2</swp-week-number>
|
||||||
|
<swp-date-range id="view-info"></swp-date-range>
|
||||||
|
</swp-week-info>
|
||||||
|
</swp-calendar-nav>
|
||||||
|
|
||||||
|
<swp-calendar-container>
|
||||||
|
<swp-header-spacer></swp-header-spacer>
|
||||||
|
<swp-time-axis>
|
||||||
|
<swp-time-axis-content id="time-axis"></swp-time-axis-content>
|
||||||
|
</swp-time-axis>
|
||||||
|
<swp-grid-container>
|
||||||
|
<swp-calendar-header></swp-calendar-header>
|
||||||
|
<swp-scrollable-content>
|
||||||
|
<swp-time-grid>
|
||||||
|
<swp-grid-lines></swp-grid-lines>
|
||||||
|
<swp-day-columns></swp-day-columns>
|
||||||
|
</swp-time-grid>
|
||||||
|
</swp-scrollable-content>
|
||||||
|
</swp-grid-container>
|
||||||
|
</swp-calendar-container>
|
||||||
|
</swp-calendar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import {
|
||||||
|
CalendarOrchestrator,
|
||||||
|
RendererRegistry,
|
||||||
|
StoreRegistry,
|
||||||
|
DateRenderer,
|
||||||
|
ResourceRenderer,
|
||||||
|
TeamRenderer
|
||||||
|
} from './js/calendar-v2.js';
|
||||||
|
|
||||||
|
const rendererRegistry = new RendererRegistry([
|
||||||
|
new DateRenderer(),
|
||||||
|
new ResourceRenderer(),
|
||||||
|
new TeamRenderer()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mockTeams = [
|
||||||
|
{ id: 'alpha', name: 'Team Alpha' },
|
||||||
|
{ id: 'beta', name: 'Team Beta' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockResources = [
|
||||||
|
{ id: 'alice', name: 'Alice', teamId: 'alpha' },
|
||||||
|
{ id: 'bob', name: 'Bob', teamId: 'alpha' },
|
||||||
|
{ id: 'carol', name: 'Carol', teamId: 'beta' },
|
||||||
|
{ id: 'dave', name: 'Dave', teamId: 'beta' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const storeRegistry = new StoreRegistry();
|
||||||
|
storeRegistry.register('team', { getByIds: ids => mockTeams.filter(t => ids.includes(t.id)) });
|
||||||
|
storeRegistry.register('resource', { getByIds: ids => mockResources.filter(r => ids.includes(r.id)) });
|
||||||
|
|
||||||
|
const orchestrator = new CalendarOrchestrator(rendererRegistry, storeRegistry);
|
||||||
|
const container = document.querySelector('swp-calendar-container');
|
||||||
|
const viewInfo = document.getElementById('view-info');
|
||||||
|
|
||||||
|
function getWeekDates() {
|
||||||
|
const today = new Date();
|
||||||
|
const mon = new Date(today);
|
||||||
|
mon.setDate(today.getDate() - today.getDay() + 1);
|
||||||
|
return Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const d = new Date(mon);
|
||||||
|
d.setDate(mon.getDate() + i);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = getWeekDates();
|
||||||
|
|
||||||
|
const views = {
|
||||||
|
simple: {
|
||||||
|
templateId: 'simple',
|
||||||
|
groupings: [{ type: 'date', values: dates }]
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
templateId: 'resource',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'resource', values: ['alice', 'bob', 'carol'] },
|
||||||
|
{ type: 'date', values: dates.slice(0, 3) }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
templateId: 'team',
|
||||||
|
groupings: [
|
||||||
|
{ type: 'team', values: ['alpha', 'beta'] },
|
||||||
|
{ type: 'resource', values: ['alice', 'bob', 'carol', 'dave'], parentKey: 'teamId' },
|
||||||
|
{ type: 'date', values: dates.slice(0, 3) }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateTimeAxis() {
|
||||||
|
const el = document.getElementById('time-axis');
|
||||||
|
el.innerHTML = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
`<swp-hour-marker>${(6 + i).toString().padStart(2, '0')}:00</swp-hour-marker>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(view, label) {
|
||||||
|
viewInfo.textContent = label;
|
||||||
|
await orchestrator.render(view, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-simple').onclick = () => render(views.simple, '5 datoer');
|
||||||
|
document.getElementById('btn-resource').onclick = () => render(views.resource, '3 resources × 3 datoer');
|
||||||
|
document.getElementById('btn-team').onclick = () => render(views.team, '2 teams × 2 resources × 3 datoer');
|
||||||
|
|
||||||
|
generateTimeAxis();
|
||||||
|
render(views.simple, '5 datoer');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue