Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
9 changed files with 187 additions and 249 deletions
Showing only changes of commit 2ec4b93fa5 - Show all commits

View file

@ -1,5 +1,5 @@
import { Container } from '@novadi/core'; import { Container } from '@novadi/core';
import { IGroupingRenderer } from './core/IGroupingRenderer'; import { Renderer } from './core/IGroupingRenderer';
import { IGroupingStore } from './core/IGroupingStore'; import { IGroupingStore } from './core/IGroupingStore';
import { DateRenderer } from './features/date/DateRenderer'; import { DateRenderer } from './features/date/DateRenderer';
import { DateService } from './core/DateService'; import { DateService } from './core/DateService';
@ -75,10 +75,10 @@ export function createV2Container(): Container {
// Features // Features
builder.registerType(EventRenderer).as<EventRenderer>(); builder.registerType(EventRenderer).as<EventRenderer>();
// Renderers - registreres som IGroupingRenderer (array injection til CalendarOrchestrator) // Renderers - registreres som Renderer (array injection til CalendarOrchestrator)
builder.registerType(DateRenderer).as<IGroupingRenderer>(); builder.registerType(DateRenderer).as<Renderer>();
builder.registerType(ResourceRenderer).as<IGroupingRenderer>(); builder.registerType(ResourceRenderer).as<Renderer>();
builder.registerType(TeamRenderer).as<IGroupingRenderer>(); builder.registerType(TeamRenderer).as<Renderer>();
// Stores - registreres som IGroupingStore // Stores - registreres som IGroupingStore
builder.registerType(MockTeamStore).as<IGroupingStore>(); builder.registerType(MockTeamStore).as<IGroupingStore>();

View file

@ -1,25 +1,14 @@
import { from } from 'ts-linq-light'; import { Renderer, RenderContext } from './IGroupingRenderer';
import { ViewConfig } from './ViewConfig'; import { buildPipeline } from './RenderBuilder';
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
import { IGroupingStore } from './IGroupingStore';
import { RenderBuilder } from './RenderBuilder';
import { EventRenderer } from '../features/event/EventRenderer'; import { EventRenderer } from '../features/event/EventRenderer';
import { ViewConfig } from './ViewConfig';
export class CalendarOrchestrator { export class CalendarOrchestrator {
constructor( constructor(
private renderers: IGroupingRenderer[], private allRenderers: Renderer[],
private stores: IGroupingStore[],
private eventRenderer: EventRenderer private eventRenderer: EventRenderer
) {} ) {}
private getRenderer(type: string): IGroupingRenderer | undefined {
return from(this.renderers).firstOrDefault(r => r.type === type);
}
private getStore(type: string): IGroupingStore | undefined {
return from(this.stores).firstOrDefault(s => s.type === type);
}
async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> { async render(viewConfig: ViewConfig, container: HTMLElement): Promise<void> {
const headerContainer = container.querySelector('swp-calendar-header') as HTMLElement; const headerContainer = container.querySelector('swp-calendar-header') as HTMLElement;
const columnContainer = container.querySelector('swp-day-columns') as HTMLElement; const columnContainer = container.querySelector('swp-day-columns') as HTMLElement;
@ -27,53 +16,42 @@ export class CalendarOrchestrator {
throw new Error('Missing swp-calendar-header or swp-day-columns'); throw new Error('Missing swp-calendar-header or swp-day-columns');
} }
const context: RenderContext = { headerContainer, columnContainer }; // Byg filter fra viewConfig
const filter: Record<string, string[]> = {};
for (const grouping of viewConfig.groupings) {
filter[grouping.type] = grouping.values;
}
// Clear containers const context: RenderContext = { headerContainer, columnContainer, filter };
// Clear
headerContainer.innerHTML = ''; headerContainer.innerHTML = '';
columnContainer.innerHTML = ''; columnContainer.innerHTML = '';
// Set header levels // Vælg renderers baseret på groupings types
const types = from(viewConfig.groupings).select(g => g.type).toArray(); const activeRenderers = this.selectRenderers(viewConfig);
headerContainer.dataset.levels = types.join(' ');
// Byg renderer chain // Beregn total kolonner dynamisk
const builder = new RenderBuilder(context); const totalColumns = this.calculateTotalColumns(viewConfig);
for (const grouping of viewConfig.groupings) {
const renderer = this.getRenderer(grouping.type);
if (renderer) {
const items = this.getItems(grouping.type, viewConfig);
builder.add(renderer, items);
}
}
// Beregn total columns og render
const totalColumns = builder.getTotalCount();
container.style.setProperty('--grid-columns', String(totalColumns)); container.style.setProperty('--grid-columns', String(totalColumns));
builder.build(); // Byg og kør pipeline
const pipeline = buildPipeline(activeRenderers);
pipeline.run(context);
// Render events // Events
const visibleDates = this.extractVisibleDates(viewConfig); const dates = filter['date'] || [];
await this.eventRenderer.render(container, visibleDates); await this.eventRenderer.render(container, dates);
} }
private getItems(type: string, viewConfig: ViewConfig): ReturnType<typeof from> { private selectRenderers(viewConfig: ViewConfig): Renderer[] {
const grouping = from(viewConfig.groupings).firstOrDefault(g => g.type === type); const types = viewConfig.groupings.map(g => g.type);
if (!grouping) return from([]); return this.allRenderers.filter(r => types.includes(r.type));
if (type === 'date') {
return from(grouping.values);
} }
const store = this.getStore(type); private calculateTotalColumns(viewConfig: ViewConfig): number {
if (!store) return from([]); const dateCount = viewConfig.groupings.find(g => g.type === 'date')?.values.length || 1;
const resourceCount = viewConfig.groupings.find(g => g.type === 'resource')?.values.length || 1;
return from(store.getByIds(grouping.values)); return dateCount * resourceCount;
}
private extractVisibleDates(viewConfig: ViewConfig): string[] {
return from(viewConfig.groupings).firstOrDefault(g => g.type === 'date')?.values || [];
} }
} }

View file

@ -1,16 +1,11 @@
import { IEnumerable } from 'ts-linq-light';
import { NextFunction } from './RenderBuilder';
export interface RenderContext { export interface RenderContext {
headerContainer: HTMLElement; headerContainer: HTMLElement;
columnContainer: HTMLElement; columnContainer: HTMLElement;
filter: Record<string, string[]>; // { team: ['alpha'], resource: ['alice', 'bob'], date: [...] }
} }
export interface IGroupingRenderer<T = unknown> { export interface Renderer {
readonly type: string; readonly type: string;
render( next: Renderer | null;
items: IEnumerable<T>, render(context: RenderContext): void;
next: NextFunction,
context: RenderContext
): void;
} }

View file

@ -1,73 +1,20 @@
import { from, IEnumerable } from 'ts-linq-light'; import { Renderer, RenderContext } from './IGroupingRenderer';
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
export interface NextFunction { export interface Pipeline {
count(items: IEnumerable<unknown>): number; run(context: RenderContext): void;
render(items: IEnumerable<unknown>): void;
} }
interface RenderLevel { export function buildPipeline(renderers: Renderer[]): Pipeline {
renderer: IGroupingRenderer; // Link renderers
items: IEnumerable<unknown>; for (let i = 0; i < renderers.length - 1; i++) {
renderers[i].next = renderers[i + 1];
} }
export class RenderBuilder { const first = renderers[0] ?? null;
private levels: RenderLevel[] = [];
constructor(private context: RenderContext) {}
add(renderer: IGroupingRenderer, items: IEnumerable<unknown>): this {
this.levels.push({ renderer, items });
return this;
}
getTotalCount(): number {
if (this.levels.length === 0) return 0;
const chain = this.buildChain(0);
return chain.count(this.levels[0].items);
}
build(): void {
if (this.levels.length === 0) return;
const chain = this.buildChain(0);
chain.render(this.levels[0].items);
}
private buildChain(index: number): NextFunction {
if (index >= this.levels.length) {
// Leaf - ingen flere levels
return {
count: (items) => from(items).count(),
render: () => {}
};
}
const level = this.levels[index];
const nextChain = this.buildChain(index + 1);
return { return {
count: (items) => { run(context: RenderContext) {
let total = 0; if (first) first.render(context);
for (const item of items) {
const childItems = this.getChildItems(index, item);
total += nextChain.count(childItems);
}
return total || from(items).count();
},
render: (items) => {
level.renderer.render(items, nextChain, this.context);
} }
}; };
} }
private getChildItems(levelIndex: number, _parentItem: unknown): IEnumerable<unknown> {
// Returnerer næste levels items - rendereren selv filtrerer baseret på parent
const nextLevel = this.levels[levelIndex + 1];
if (!nextLevel) {
return from([]);
}
return nextLevel.items;
}
}

View file

@ -4,15 +4,15 @@ import { NavigationAnimator } from '../core/NavigationAnimator';
import { DateService } from '../core/DateService'; import { DateService } from '../core/DateService';
import { ScrollManager } from '../core/ScrollManager'; import { ScrollManager } from '../core/ScrollManager';
import { HeaderDrawerManager } from '../core/HeaderDrawerManager'; import { HeaderDrawerManager } from '../core/HeaderDrawerManager';
import { ViewConfig } from '../core/ViewConfig';
import { IndexedDBContext } from '../storage/IndexedDBContext'; import { IndexedDBContext } from '../storage/IndexedDBContext';
import { DataSeeder } from '../workers/DataSeeder'; import { DataSeeder } from '../workers/DataSeeder';
import { ViewConfig } from '../core/ViewConfig';
export class DemoApp { export class DemoApp {
private animator!: NavigationAnimator; private animator!: NavigationAnimator;
private container!: HTMLElement; private container!: HTMLElement;
private weekOffset = 0; private weekOffset = 0;
private views!: Record<string, ViewConfig>; private currentView: 'simple' | 'resource' | 'team' = 'team';
constructor( constructor(
private orchestrator: CalendarOrchestrator, private orchestrator: CalendarOrchestrator,
@ -41,27 +41,6 @@ export class DemoApp {
document.querySelector('swp-content-track') as HTMLElement document.querySelector('swp-content-track') as HTMLElement
); );
// View configs
const dates = this.dateService.getWeekDates();
this.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) }
]
}
};
// Render time axis (06:00 - 18:00) // Render time axis (06:00 - 18:00)
this.timeAxisRenderer.render(document.getElementById('time-axis') as HTMLElement, 6, 18); this.timeAxisRenderer.render(document.getElementById('time-axis') as HTMLElement, 6, 18);
@ -73,36 +52,78 @@ export class DemoApp {
// Setup event handlers // Setup event handlers
this.setupNavigation(); this.setupNavigation();
this.setupViewSwitchers();
this.setupDrawerToggle(); this.setupDrawerToggle();
this.setupViewSwitching();
// Initial render // Initial render
this.orchestrator.render(this.views.simple, this.container); this.render();
}
private async render(): Promise<void> {
const viewConfig = this.buildViewConfig();
await this.orchestrator.render(viewConfig, this.container);
}
private buildViewConfig(): ViewConfig {
const dates = this.dateService.getWeekDates(this.weekOffset);
switch (this.currentView) {
case 'simple':
return {
templateId: 'simple',
groupings: [
{ type: 'date', values: dates }
]
};
case 'resource':
return {
templateId: 'resource',
groupings: [
{ type: 'resource', values: ['res1', 'res2', 'res3'] },
{ type: 'date', values: dates }
]
};
case 'team':
return {
templateId: 'team',
groupings: [
{ type: 'team', values: ['team1', 'team2'] },
{ type: 'resource', values: ['res1', 'res2', 'res3'] },
{ type: 'date', values: dates }
]
};
}
} }
private setupNavigation(): void { private setupNavigation(): void {
document.getElementById('btn-prev')!.onclick = () => { document.getElementById('btn-prev')!.onclick = () => {
this.weekOffset--; this.weekOffset--;
this.views.simple.groupings[0].values = this.dateService.getWeekDates(this.weekOffset); this.animator.slide('right', () => this.render());
this.animator.slide('right', () => this.orchestrator.render(this.views.simple, this.container));
}; };
document.getElementById('btn-next')!.onclick = () => { document.getElementById('btn-next')!.onclick = () => {
this.weekOffset++; this.weekOffset++;
this.views.simple.groupings[0].values = this.dateService.getWeekDates(this.weekOffset); this.animator.slide('left', () => this.render());
this.animator.slide('left', () => this.orchestrator.render(this.views.simple, this.container));
}; };
} }
private setupViewSwitchers(): void { private setupViewSwitching(): void {
document.getElementById('btn-simple')!.onclick = () => document.getElementById('btn-simple')?.addEventListener('click', () => {
this.animator.slide('right', () => this.orchestrator.render(this.views.simple, this.container)); this.currentView = 'simple';
this.render();
});
document.getElementById('btn-resource')!.onclick = () => document.getElementById('btn-resource')?.addEventListener('click', () => {
this.animator.slide('left', () => this.orchestrator.render(this.views.resource, this.container)); this.currentView = 'resource';
this.render();
});
document.getElementById('btn-team')!.onclick = () => document.getElementById('btn-team')?.addEventListener('click', () => {
this.animator.slide('left', () => this.orchestrator.render(this.views.team, this.container)); this.currentView = 'team';
this.render();
});
} }
private setupDrawerToggle(): void { private setupDrawerToggle(): void {

View file

@ -1,36 +1,38 @@
import { IEnumerable } from 'ts-linq-light'; import { Renderer, RenderContext } from '../../core/IGroupingRenderer';
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
import { NextFunction, RenderData } from '../../core/RenderBuilder';
import { DateService } from '../../core/DateService'; import { DateService } from '../../core/DateService';
export class DateRenderer implements IGroupingRenderer<string> { export class DateRenderer implements Renderer {
readonly type = 'date'; readonly type = 'date';
next: Renderer | null = null;
constructor(private dateService: DateService) {} constructor(private dateService: DateService) {}
render( render(context: RenderContext): void {
dates: IEnumerable<string>, const dates = context.filter['date'] || [];
_data: RenderData, const resourceCount = context.filter['resource']?.length || 1;
_next: NextFunction,
context: RenderContext // Render dates for HVER resource (resourceCount gange)
): void { for (let r = 0; r < resourceCount; r++) {
for (const dateStr of dates) { for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr); const date = this.dateService.parseISO(dateStr);
const headerCell = document.createElement('swp-day-header'); // Header
headerCell.dataset.date = dateStr; const header = document.createElement('swp-day-header');
headerCell.innerHTML = ` header.dataset.date = dateStr;
header.innerHTML = `
<swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name> <swp-day-name>${this.dateService.getDayName(date, 'short')}</swp-day-name>
<swp-day-date>${date.getDate()}</swp-day-date> <swp-day-date>${date.getDate()}</swp-day-date>
`; `;
context.headerContainer.appendChild(headerCell); context.headerContainer.appendChild(header);
// Column
const column = document.createElement('swp-day-column'); const column = document.createElement('swp-day-column');
column.dataset.date = dateStr; column.dataset.date = dateStr;
column.innerHTML = '<swp-events-layer></swp-events-layer>'; column.innerHTML = '<swp-events-layer></swp-events-layer>';
context.columnContainer.appendChild(column); context.columnContainer.appendChild(column);
}
}
// Leaf renderer - ingen next.render() kald // Leaf - ingen next
}
} }
} }

View file

@ -1,37 +1,37 @@
import { from, IEnumerable } from 'ts-linq-light'; import { Renderer, RenderContext } from '../../core/IGroupingRenderer';
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
import { NextFunction, RenderData } from '../../core/RenderBuilder';
interface Resource { interface Resource {
id: string; id: string;
name?: string; name: string;
} }
export class ResourceRenderer implements IGroupingRenderer<Resource> { export class ResourceRenderer implements Renderer {
readonly type = 'resource'; readonly type = 'resource';
next: Renderer | null = null;
render( // Hardcoded data
resources: IEnumerable<Resource>, private resources: Resource[] = [
data: RenderData, { id: 'res1', name: 'Anders' },
next: NextFunction, { id: 'res2', name: 'Bente' },
context: RenderContext { id: 'res3', name: 'Carsten' }
): void { ];
const dates = data.dates || from([]);
for (const resource of resources) { render(context: RenderContext): void {
const colspan = next.count(dates); const allowedIds = context.filter['resource'] || [];
const filteredResources = this.resources.filter(r => allowedIds.includes(r.id));
const cell = document.createElement('swp-resource-header'); const dateCount = context.filter['date']?.length || 1;
cell.dataset.resourceId = resource.id;
cell.textContent = resource.name || resource.id;
if (colspan > 1) { // Render ALLE resource headers
cell.style.gridColumn = `span ${colspan}`; for (const resource of filteredResources) {
const header = document.createElement('swp-resource-header');
header.dataset.resourceId = resource.id;
header.textContent = resource.name;
header.style.gridColumn = `span ${dateCount}`;
context.headerContainer.appendChild(header);
} }
context.headerContainer.appendChild(cell); // Derefter kald next ÉN gang
if (this.next) this.next.render(context);
next.render(dates);
}
} }
} }

View file

@ -1,47 +1,42 @@
import { from, IEnumerable } from 'ts-linq-light'; import { Renderer, RenderContext } from '../../core/IGroupingRenderer';
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
import { NextFunction, RenderData } from '../../core/RenderBuilder';
interface Team { interface Team {
id: string; id: string;
name?: string; name: string;
resourceIds: string[];
} }
interface Resource { export class TeamRenderer implements Renderer {
id: string;
teamId?: string;
}
export class TeamRenderer implements IGroupingRenderer<Team> {
readonly type = 'team'; readonly type = 'team';
next: Renderer | null = null;
render( // Hardcoded data
teams: IEnumerable<Team>, private teams: Team[] = [
data: RenderData, { id: 'team1', name: 'Team Alpha', resourceIds: ['res1', 'res2'] },
next: NextFunction, { id: 'team2', name: 'Team Beta', resourceIds: ['res3'] }
context: RenderContext ];
): void {
const resources = data.resources as IEnumerable<Resource> || from([]);
const resourcesByTeam = from(resources).groupBy((r: Resource) => r.teamId || '');
for (const team of teams) { render(context: RenderContext): void {
const teamResources = from(resourcesByTeam) const allowedIds = context.filter['team'] || [];
.firstOrDefault(g => g.key === team.id); const filteredTeams = this.teams.filter(t => allowedIds.includes(t.id));
const teamResourcesEnum = teamResources ? from(teamResources) : from([]); const dateCount = context.filter['date']?.length || 1;
const colspan = next.count(teamResourcesEnum); const resourceIds = context.filter['resource'] || [];
const cell = document.createElement('swp-team-header'); // Render ALLE team headers først
cell.dataset.teamId = team.id; for (const team of filteredTeams) {
cell.textContent = team.name || team.id; // Tæl resources der tilhører dette team OG er i filter
const teamResourceCount = team.resourceIds.filter(id => resourceIds.includes(id)).length;
const colspan = teamResourceCount * dateCount;
if (colspan > 1) { const header = document.createElement('swp-team-header');
cell.style.gridColumn = `span ${colspan}`; header.dataset.teamId = team.id;
header.textContent = team.name;
header.style.setProperty('--team-cols', String(colspan));
context.headerContainer.appendChild(header);
} }
context.headerContainer.appendChild(cell); // Derefter kald next ÉN gang
if (this.next) this.next.render(context);
next.render(teamResourcesEnum);
}
} }
} }

View file

@ -1,10 +1,10 @@
// Core exports // Core exports
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
export { IGroupingRenderer, RenderContext } from './core/IGroupingRenderer'; export { Renderer, RenderContext } from './core/IGroupingRenderer';
export { IGroupingStore } from './core/IGroupingStore'; export { IGroupingStore } from './core/IGroupingStore';
export { CalendarOrchestrator } from './core/CalendarOrchestrator'; export { CalendarOrchestrator } from './core/CalendarOrchestrator';
export { NavigationAnimator } from './core/NavigationAnimator'; export { NavigationAnimator } from './core/NavigationAnimator';
export { RenderBuilder, NextFunction, RenderData } from './core/RenderBuilder'; export { buildPipeline, Pipeline } from './core/RenderBuilder';
// Feature exports // Feature exports
export { DateRenderer } from './features/date'; export { DateRenderer } from './features/date';