New renders+css

This commit is contained in:
Janus C. H. Knudsen 2025-12-06 01:22:04 +01:00
parent 73e284660f
commit b3f47e93e8
22 changed files with 763 additions and 3 deletions

View file

@ -280,4 +280,5 @@ if (document.readyState === 'loading') {
initializeCalendar().catch(error => {
console.error('Calendar initialization failed:', error);
});
}
}

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

View file

@ -0,0 +1,6 @@
import { RenderContext } from './RenderContext';
export interface IGroupingRenderer {
readonly type: string;
render(context: RenderContext): void;
}

View file

@ -0,0 +1,7 @@
export interface IGroupingStore<T = unknown> {
getByIds(ids: string[]): T[];
}
export interface IStoreRegistry {
get(type: string): IGroupingStore;
}

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

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

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

@ -0,0 +1,7 @@
/**
* V2 Calendar - Standalone Entry Point
* No dependencies on existing calendar system
*/
// Re-export everything from index
export * from './index';

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

View file

@ -0,0 +1 @@
export { DateRenderer, IDateService, defaultDateService } from './DateRenderer';

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

View file

@ -0,0 +1 @@
export { EventRenderer, IEventData, IEventStore } from './EventRenderer';

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

View file

@ -0,0 +1 @@
export { ResourceRenderer } from './ResourceRenderer';

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

View file

@ -0,0 +1 @@
export { TeamRenderer } from './TeamRenderer';

14
src/v2/index.ts Normal file
View 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';