New renders+css
This commit is contained in:
parent
73e284660f
commit
b3f47e93e8
22 changed files with 763 additions and 3 deletions
|
|
@ -280,4 +280,5 @@ if (document.readyState === 'loading') {
|
|||
initializeCalendar().catch(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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue