Moving away from Azure Devops #1

Merged
Janus007 merged 113 commits from refac into master 2026-02-03 00:04:27 +01:00
17 changed files with 331 additions and 134 deletions
Showing only changes of commit a0c0ef9e8d - Show all commits

View file

@ -61,6 +61,22 @@ async function build() {
console.log('V2 bundle created: wwwroot/js/calendar-v2.js'); console.log('V2 bundle created: wwwroot/js/calendar-v2.js');
// V2 demo bundle (with DI transformer for autowiring)
await esbuild.build({
entryPoints: ['src/v2/demo/index.ts'],
bundle: true,
outfile: 'wwwroot/js/v2-demo.js',
format: 'esm',
sourcemap: 'inline',
target: 'es2020',
minify: false,
keepNames: true,
platform: 'browser',
plugins: [NovadiUnplugin.esbuild({ debug: false, enableAutowiring: true })]
});
console.log('V2 demo bundle created: wwwroot/js/v2-demo.js');
} catch (error) { } catch (error) {
console.error('Build failed:', error); console.error('Build failed:', error);
process.exit(1); process.exit(1);

View file

@ -0,0 +1,53 @@
import { Container } from '@novadi/core';
import { IGroupingRenderer } from './core/IGroupingRenderer';
import { IGroupingStore } from './core/IGroupingStore';
import { DateRenderer } from './features/date/DateRenderer';
import { DateService } from './core/DateService';
import { ITimeFormatConfig } from './core/ITimeFormatConfig';
import { ResourceRenderer } from './features/resource/ResourceRenderer';
import { TeamRenderer } from './features/team/TeamRenderer';
import { RendererRegistry } from './core/RendererRegistry';
import { CalendarOrchestrator } from './core/CalendarOrchestrator';
import { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';
import { MockTeamStore, MockResourceStore } from './demo/MockStores';
import { DemoApp } from './demo/DemoApp';
const defaultTimeFormatConfig: ITimeFormatConfig = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
use24HourFormat: true,
locale: 'da-DK',
dateFormat: 'locale',
showSeconds: false
};
export function createV2Container(): Container {
const container = new Container();
const builder = container.builder();
// Config
builder.registerInstance(defaultTimeFormatConfig).as<ITimeFormatConfig>();
// Services
builder.registerType(DateService).as<DateService>();
// Renderers - registreres som IGroupingRenderer
builder.registerType(DateRenderer).as<IGroupingRenderer>();
builder.registerType(ResourceRenderer).as<IGroupingRenderer>();
builder.registerType(TeamRenderer).as<IGroupingRenderer>();
// RendererRegistry modtager IGroupingRenderer[] automatisk (array injection)
builder.registerType(RendererRegistry).as<RendererRegistry>();
// Stores - registreres som IGroupingStore
builder.registerType(MockTeamStore).as<IGroupingStore>();
builder.registerType(MockResourceStore).as<IGroupingStore>();
// CalendarOrchestrator modtager IGroupingStore[] automatisk (array injection)
builder.registerType(CalendarOrchestrator).as<CalendarOrchestrator>();
builder.registerType(TimeAxisRenderer).as<TimeAxisRenderer>();
// Demo app
builder.registerType(DemoApp).as<DemoApp>();
return builder.build();
}

View file

@ -1,7 +1,7 @@
import { ViewConfig, GroupingConfig } from './ViewConfig'; import { ViewConfig, GroupingConfig } from './ViewConfig';
import { RenderContext } from './RenderContext'; import { RenderContext } from './RenderContext';
import { RendererRegistry } from './RendererRegistry'; import { RendererRegistry } from './RendererRegistry';
import { IStoreRegistry } from './IGroupingStore'; import { IGroupingStore } from './IGroupingStore';
interface HierarchyNode { interface HierarchyNode {
type: string; type: string;
@ -19,9 +19,13 @@ interface GroupingData {
export class CalendarOrchestrator { export class CalendarOrchestrator {
constructor( constructor(
private rendererRegistry: RendererRegistry, private rendererRegistry: RendererRegistry,
private storeRegistry: IStoreRegistry private stores: IGroupingStore[]
) {} ) {}
private getStore(type: string): IGroupingStore | undefined {
return this.stores.find(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;
@ -66,7 +70,9 @@ export class CalendarOrchestrator {
continue; continue;
} }
const rawItems = this.storeRegistry.get(g.type).getByIds(g.values); const store = this.getStore(g.type);
if (!store) continue;
const rawItems = store.getByIds(g.values);
const items = rawItems.map((item: any) => ({ id: item.id, data: item })); const items = rawItems.map((item: any) => ({ id: item.id, data: item }));
const byParent = g.parentKey const byParent = g.parentKey
? this.groupBy(items, item => (item.data as any)[g.parentKey!]) ? this.groupBy(items, item => (item.data as any)[g.parentKey!])

View file

@ -0,0 +1,21 @@
import dayjs from 'dayjs';
import { ITimeFormatConfig } from './ITimeFormatConfig';
export class DateService {
constructor(private config: ITimeFormatConfig) {}
parseISO(isoString: string): Date {
return dayjs(isoString).toDate();
}
getDayName(date: Date, format: 'short' | 'long' = 'short'): string {
return new Intl.DateTimeFormat(this.config.locale, { weekday: format }).format(date);
}
getWeekDates(offset = 0): string[] {
const monday = dayjs().startOf('week').add(1, 'day').add(offset, 'week');
return Array.from({ length: 5 }, (_, i) =>
monday.add(i, 'day').format('YYYY-MM-DD')
);
}
}

View file

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

View file

@ -0,0 +1,7 @@
export interface ITimeFormatConfig {
timezone: string;
use24HourFormat: boolean;
locale: string;
dateFormat: 'locale' | 'technical';
showSeconds: boolean;
}

View file

@ -0,0 +1,41 @@
export class NavigationAnimator {
constructor(
private headerTrack: HTMLElement,
private contentTrack: HTMLElement
) {}
async slide(direction: 'left' | 'right', renderFn: () => Promise<void>): Promise<void> {
const out = direction === 'left' ? '-100%' : '100%';
const into = direction === 'left' ? '100%' : '-100%';
await this.animateOut(out);
await renderFn();
await this.animateIn(into);
}
private async animateOut(translate: string): Promise<void> {
await Promise.all([
this.headerTrack.animate(
[{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
{ duration: 200, easing: 'ease-in' }
).finished,
this.contentTrack.animate(
[{ transform: 'translateX(0)' }, { transform: `translateX(${translate})` }],
{ duration: 200, easing: 'ease-in' }
).finished
]);
}
private async animateIn(translate: string): Promise<void> {
await Promise.all([
this.headerTrack.animate(
[{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
{ duration: 200, easing: 'ease-out' }
).finished,
this.contentTrack.animate(
[{ transform: `translateX(${translate})` }, { transform: 'translateX(0)' }],
{ duration: 200, easing: 'ease-out' }
).finished
]);
}
}

View file

@ -1,15 +0,0 @@
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;
}
}

84
src/v2/demo/DemoApp.ts Normal file
View file

@ -0,0 +1,84 @@
import { CalendarOrchestrator } from '../core/CalendarOrchestrator';
import { TimeAxisRenderer } from '../features/timeaxis/TimeAxisRenderer';
import { NavigationAnimator } from '../core/NavigationAnimator';
import { DateService } from '../core/DateService';
import { ViewConfig } from '../core/ViewConfig';
export class DemoApp {
private animator!: NavigationAnimator;
private container!: HTMLElement;
private weekOffset = 0;
private views!: Record<string, ViewConfig>;
constructor(
private orchestrator: CalendarOrchestrator,
private timeAxisRenderer: TimeAxisRenderer,
private dateService: DateService
) {}
init(): void {
this.container = document.querySelector('swp-calendar-container') as HTMLElement;
// NavigationAnimator har DOM-dependencies - tilladt med new
this.animator = new NavigationAnimator(
document.querySelector('swp-header-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
this.timeAxisRenderer.render(document.getElementById('time-axis') as HTMLElement);
// Setup event handlers
this.setupNavigation();
this.setupViewSwitchers();
// Initial render
this.orchestrator.render(this.views.simple, this.container);
}
private setupNavigation(): void {
document.getElementById('btn-prev')!.onclick = () => {
this.weekOffset--;
this.views.simple.groupings[0].values = this.dateService.getWeekDates(this.weekOffset);
this.animator.slide('right', () => this.orchestrator.render(this.views.simple, this.container));
};
document.getElementById('btn-next')!.onclick = () => {
this.weekOffset++;
this.views.simple.groupings[0].values = this.dateService.getWeekDates(this.weekOffset);
this.animator.slide('left', () => this.orchestrator.render(this.views.simple, this.container));
};
}
private setupViewSwitchers(): void {
document.getElementById('btn-simple')!.onclick = () =>
this.animator.slide('right', () => this.orchestrator.render(this.views.simple, this.container));
document.getElementById('btn-resource')!.onclick = () =>
this.animator.slide('left', () => this.orchestrator.render(this.views.resource, this.container));
document.getElementById('btn-team')!.onclick = () =>
this.animator.slide('left', () => this.orchestrator.render(this.views.team, this.container));
}
}

40
src/v2/demo/MockStores.ts Normal file
View file

@ -0,0 +1,40 @@
import { IGroupingStore } from '../core/IGroupingStore';
export interface Team {
id: string;
name: string;
}
export interface Resource {
id: string;
name: string;
teamId: string;
}
export class MockTeamStore implements IGroupingStore<Team> {
readonly type = 'team';
private teams: Team[] = [
{ id: 'alpha', name: 'Team Alpha' },
{ id: 'beta', name: 'Team Beta' }
];
getByIds(ids: string[]): Team[] {
return this.teams.filter(t => ids.includes(t.id));
}
}
export class MockResourceStore implements IGroupingStore<Resource> {
readonly type = 'resource';
private resources: Resource[] = [
{ id: 'alice', name: 'Alice', teamId: 'alpha' },
{ id: 'bob', name: 'Bob', teamId: 'alpha' },
{ id: 'carol', name: 'Carol', teamId: 'beta' },
{ id: 'dave', name: 'Dave', teamId: 'beta' }
];
getByIds(ids: string[]): Resource[] {
return this.resources.filter(r => ids.includes(r.id));
}
}

5
src/v2/demo/index.ts Normal file
View file

@ -0,0 +1,5 @@
import { createV2Container } from '../V2CompositionRoot';
import { DemoApp } from './DemoApp';
const app = createV2Container();
app.resolveType<DemoApp>().init();

View file

@ -1,20 +1,11 @@
import { IGroupingRenderer } from '../../core/IGroupingRenderer'; import { IGroupingRenderer } from '../../core/IGroupingRenderer';
import { RenderContext } from '../../core/RenderContext'; import { RenderContext } from '../../core/RenderContext';
import { DateService } from '../../core/DateService';
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 { export class DateRenderer implements IGroupingRenderer {
readonly type = 'date'; readonly type = 'date';
constructor(private dateService: IDateService = defaultDateService) {} constructor(private dateService: DateService) {}
render(context: RenderContext): void { render(context: RenderContext): void {
for (const dateStr of context.values) { for (const dateStr of context.values) {

View file

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

View file

@ -0,0 +1,10 @@
export class TimeAxisRenderer {
render(container: HTMLElement, startHour = 6, endHour = 20): void {
container.innerHTML = '';
for (let hour = startHour; hour <= endHour; hour++) {
const marker = document.createElement('swp-hour-marker');
marker.textContent = `${hour.toString().padStart(2, '0')}:00`;
container.appendChild(marker);
}
}
}

View file

@ -2,13 +2,17 @@
export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig';
export { RenderContext } from './core/RenderContext'; export { RenderContext } from './core/RenderContext';
export { IGroupingRenderer } from './core/IGroupingRenderer'; export { IGroupingRenderer } from './core/IGroupingRenderer';
export { IGroupingStore, IStoreRegistry } from './core/IGroupingStore'; export { IGroupingStore } from './core/IGroupingStore';
export { RendererRegistry } from './core/RendererRegistry'; export { RendererRegistry } from './core/RendererRegistry';
export { StoreRegistry } from './core/StoreRegistry';
export { CalendarOrchestrator } from './core/CalendarOrchestrator'; export { CalendarOrchestrator } from './core/CalendarOrchestrator';
export { NavigationAnimator } from './core/NavigationAnimator';
// Feature exports // Feature exports
export { DateRenderer, IDateService, defaultDateService } from './features/date'; export { DateRenderer } from './features/date';
export { DateService } from './core/DateService';
export { ITimeFormatConfig } from './core/ITimeFormatConfig';
export { EventRenderer, IEventData, IEventStore } from './features/event'; export { EventRenderer, IEventData, IEventStore } from './features/event';
export { ResourceRenderer } from './features/resource'; export { ResourceRenderer } from './features/resource';
export { TeamRenderer } from './features/team'; export { TeamRenderer } from './features/team';
export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer';

View file

@ -107,6 +107,22 @@ swp-grid-container {
overflow: hidden; overflow: hidden;
} }
/* Viewport/Track for slide animation */
swp-header-viewport,
swp-content-viewport {
overflow: hidden;
}
swp-header-track,
swp-content-track {
display: flex;
}
swp-header-track > swp-calendar-header,
swp-content-track > swp-scrollable-content {
flex: 0 0 100%;
}
/* Header */ /* Header */
swp-calendar-header { swp-calendar-header {
display: grid; display: grid;

View file

@ -17,6 +17,8 @@
<swp-week-number>V2</swp-week-number> <swp-week-number>V2</swp-week-number>
<swp-date-range id="view-info"></swp-date-range> <swp-date-range id="view-info"></swp-date-range>
</swp-week-info> </swp-week-info>
<swp-nav-button id="btn-prev"></swp-nav-button>
<swp-nav-button id="btn-next"></swp-nav-button>
</swp-calendar-nav> </swp-calendar-nav>
<swp-calendar-container> <swp-calendar-container>
@ -25,107 +27,26 @@
<swp-time-axis-content id="time-axis"></swp-time-axis-content> <swp-time-axis-content id="time-axis"></swp-time-axis-content>
</swp-time-axis> </swp-time-axis>
<swp-grid-container> <swp-grid-container>
<swp-header-viewport>
<swp-header-track>
<swp-calendar-header></swp-calendar-header> <swp-calendar-header></swp-calendar-header>
</swp-header-track>
</swp-header-viewport>
<swp-content-viewport>
<swp-content-track>
<swp-scrollable-content> <swp-scrollable-content>
<swp-time-grid> <swp-time-grid>
<swp-grid-lines></swp-grid-lines> <swp-grid-lines></swp-grid-lines>
<swp-day-columns></swp-day-columns> <swp-day-columns></swp-day-columns>
</swp-time-grid> </swp-time-grid>
</swp-scrollable-content> </swp-scrollable-content>
</swp-content-track>
</swp-content-viewport>
</swp-grid-container> </swp-grid-container>
</swp-calendar-container> </swp-calendar-container>
</swp-calendar> </swp-calendar>
</div> </div>
<script type="module"> <script type="module" src="js/v2-demo.js"></script>
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> </body>
</html> </html>