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 230 additions and 155 deletions
Showing only changes of commit a3a1b9a421 - Show all commits

6
package-lock.json generated
View file

@ -4960,9 +4960,9 @@
}
},
"node_modules/ts-linq-light": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.0.tgz",
"integrity": "sha512-8GGyHkHlKuKFTbT/Xz/0y72OTKl1hVRMJ0MqXAaWaergCExKw3bd5M6DR3NSOA7UL0gkTMhWsql9kFYyAjkIdQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ts-linq-light/-/ts-linq-light-1.0.1.tgz",
"integrity": "sha512-Qk1TKZ8M/XYH6Vt+zUOtAyVOqezIMd3r7EDtgCPOnWgIs0Xdrj/miqUQAEoRl3LttbQQ/6gBMhM/84S/mTb/sg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"

View file

@ -1,23 +1,10 @@
import { from } from 'ts-linq-light';
import { ViewConfig, GroupingConfig } from './ViewConfig';
import { RenderContext } from './RenderContext';
import { IGroupingRenderer } from './IGroupingRenderer';
import { ViewConfig } from './ViewConfig';
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
import { IGroupingStore } from './IGroupingStore';
import { RenderBuilder, RenderData } from './RenderBuilder';
import { EventRenderer } from '../features/event/EventRenderer';
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 renderers: IGroupingRenderer[],
@ -40,115 +27,60 @@ export class CalendarOrchestrator {
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 = from(viewConfig.groupings).select(g => g.type).toArray();
headerContainer.dataset.levels = types.join(' ');
const context: RenderContext = { headerContainer, columnContainer };
// Clear containers
headerContainer.innerHTML = '';
columnContainer.innerHTML = '';
this.renderHierarchy(hierarchy, headerContainer, columnContainer);
// Set header levels
const types = from(viewConfig.groupings).select(g => g.type).toArray();
headerContainer.dataset.levels = types.join(' ');
// Render events from IndexedDB
// Hent alt data
const data: RenderData = {
teams: this.getItems('team', viewConfig),
resources: this.getItems('resource', viewConfig),
dates: this.getItems('date', viewConfig)
};
// Byg renderer chain
const builder = new RenderBuilder(context, data);
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));
builder.build();
// Render events
const visibleDates = this.extractVisibleDates(viewConfig);
await this.eventRenderer.render(container, visibleDates);
}
private getItems(type: string, viewConfig: ViewConfig): ReturnType<typeof from> {
const grouping = from(viewConfig.groupings).firstOrDefault(g => g.type === type);
if (!grouping) return from([]);
if (type === 'date') {
return from(grouping.values);
}
const store = this.getStore(type);
if (!store) return from([]);
return from(store.getByIds(grouping.values));
}
private extractVisibleDates(viewConfig: ViewConfig): string[] {
return from(viewConfig.groupings).firstOrDefault(g => g.type === 'date')?.values || [];
}
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: from(g.values).select(v => ({ id: v, data: { id: v } })).toArray(),
byParent: null
});
continue;
}
const store = this.getStore(g.type);
if (!store) continue;
const rawItems = store.getByIds(g.values);
const items = from(rawItems).select((item: any) => ({ id: item.id, data: item })).toArray();
let byParent: Map<string, { id: string; data: unknown }[]> | null = null;
if (g.parentKey) {
byParent = new Map();
const grouped = from(items)
.where(item => (item.data as any)[g.parentKey!] != null)
.groupBy(item => (item.data as any)[g.parentKey!] as string);
for (const group of grouped) {
byParent.set(group.key, group.toArray());
}
}
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 from(items).select(item => ({
type: g.type,
id: item.id,
data: item.data,
parentId,
children: this.buildHierarchy(groupings, data, level + 1, item.id)
})).toArray();
}
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.getRenderer(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

@ -1,6 +1,17 @@
import { RenderContext } from './RenderContext';
import { IEnumerable } from 'ts-linq-light';
import { NextFunction, RenderData } from './RenderBuilder';
export interface IGroupingRenderer {
readonly type: string;
render(context: RenderContext): void;
export interface RenderContext {
headerContainer: HTMLElement;
columnContainer: HTMLElement;
}
export interface IGroupingRenderer<T = unknown> {
readonly type: string;
render(
items: IEnumerable<T>,
data: RenderData,
next: NextFunction,
context: RenderContext
): void;
}

View file

@ -0,0 +1,82 @@
import { from, IEnumerable } from 'ts-linq-light';
import { IGroupingRenderer, RenderContext } from './IGroupingRenderer';
export interface NextFunction {
count(items: IEnumerable<unknown>): number;
render(items: IEnumerable<unknown>): void;
}
export interface RenderData {
teams?: IEnumerable<unknown>;
resources?: IEnumerable<unknown>;
dates?: IEnumerable<unknown>;
}
interface RenderLevel {
renderer: IGroupingRenderer;
items: IEnumerable<unknown>;
}
export class RenderBuilder {
private levels: RenderLevel[] = [];
constructor(
private context: RenderContext,
private data: RenderData
) {}
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 {
count: (items) => {
let total = 0;
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, this.data, 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

@ -1,9 +0,0 @@
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

@ -1,14 +1,20 @@
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
import { RenderContext } from '../../core/RenderContext';
import { IEnumerable } from 'ts-linq-light';
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
import { NextFunction, RenderData } from '../../core/RenderBuilder';
import { DateService } from '../../core/DateService';
export class DateRenderer implements IGroupingRenderer {
export class DateRenderer implements IGroupingRenderer<string> {
readonly type = 'date';
constructor(private dateService: DateService) {}
render(context: RenderContext): void {
for (const dateStr of context.values) {
render(
dates: IEnumerable<string>,
_data: RenderData,
_next: NextFunction,
context: RenderContext
): void {
for (const dateStr of dates) {
const date = this.dateService.parseISO(dateStr);
const headerCell = document.createElement('swp-day-header');
@ -21,9 +27,10 @@ export class DateRenderer implements IGroupingRenderer {
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);
// Leaf renderer - ingen next.render() kald
}
}
}

View file

@ -1,16 +1,37 @@
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
import { RenderContext } from '../../core/RenderContext';
import { from, IEnumerable } from 'ts-linq-light';
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
import { NextFunction, RenderData } from '../../core/RenderBuilder';
export class ResourceRenderer implements IGroupingRenderer {
interface Resource {
id: string;
name?: string;
}
export class ResourceRenderer implements IGroupingRenderer<Resource> {
readonly type = 'resource';
render(context: RenderContext): void {
for (const resourceId of context.values) {
render(
resources: IEnumerable<Resource>,
data: RenderData,
next: NextFunction,
context: RenderContext
): void {
const dates = data.dates || from([]);
for (const resource of resources) {
const colspan = next.count(dates);
const cell = document.createElement('swp-resource-header');
cell.dataset.resourceId = resourceId;
cell.textContent = resourceId;
if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`;
cell.dataset.resourceId = resource.id;
cell.textContent = resource.name || resource.id;
if (colspan > 1) {
cell.style.gridColumn = `span ${colspan}`;
}
context.headerContainer.appendChild(cell);
next.render(dates);
}
}
}

View file

@ -1,16 +1,47 @@
import { IGroupingRenderer } from '../../core/IGroupingRenderer';
import { RenderContext } from '../../core/RenderContext';
import { from, IEnumerable } from 'ts-linq-light';
import { IGroupingRenderer, RenderContext } from '../../core/IGroupingRenderer';
import { NextFunction, RenderData } from '../../core/RenderBuilder';
export class TeamRenderer implements IGroupingRenderer {
interface Team {
id: string;
name?: string;
}
interface Resource {
id: string;
teamId?: string;
}
export class TeamRenderer implements IGroupingRenderer<Team> {
readonly type = 'team';
render(context: RenderContext): void {
for (const teamId of context.values) {
render(
teams: IEnumerable<Team>,
data: RenderData,
next: NextFunction,
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) {
const teamResources = from(resourcesByTeam)
.firstOrDefault(g => g.key === team.id);
const teamResourcesEnum = teamResources ? from(teamResources) : from([]);
const colspan = next.count(teamResourcesEnum);
const cell = document.createElement('swp-team-header');
cell.dataset.teamId = teamId;
cell.textContent = teamId;
if (context.colspan > 1) cell.style.gridColumn = `span ${context.colspan}`;
cell.dataset.teamId = team.id;
cell.textContent = team.name || team.id;
if (colspan > 1) {
cell.style.gridColumn = `span ${colspan}`;
}
context.headerContainer.appendChild(cell);
next.render(teamResourcesEnum);
}
}
}

View file

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