From a0c0ef9e8d61936ac97a5bd9749c57257c40233b Mon Sep 17 00:00:00 2001 From: "Janus C. H. Knudsen" Date: Sun, 7 Dec 2025 14:31:16 +0100 Subject: [PATCH] Refactor calendar V2 core with DI and new features Introduces dependency injection container and composition root Adds core services like DateService and NavigationAnimator Simplifies CalendarOrchestrator with improved store handling Implements mock stores and demo application for V2 calendar --- build.js | 16 +++ src/v2/V2CompositionRoot.ts | 53 +++++++++ src/v2/core/CalendarOrchestrator.ts | 12 +- src/v2/core/DateService.ts | 21 ++++ src/v2/core/IGroupingStore.ts | 5 +- src/v2/core/ITimeFormatConfig.ts | 7 ++ src/v2/core/NavigationAnimator.ts | 41 +++++++ src/v2/core/StoreRegistry.ts | 15 --- src/v2/demo/DemoApp.ts | 84 ++++++++++++++ src/v2/demo/MockStores.ts | 40 +++++++ src/v2/demo/index.ts | 5 + src/v2/features/date/DateRenderer.ts | 13 +-- src/v2/features/date/index.ts | 2 +- src/v2/features/timeaxis/TimeAxisRenderer.ts | 10 ++ src/v2/index.ts | 10 +- wwwroot/css/calendar-v2.css | 16 +++ wwwroot/v2.html | 115 +++---------------- 17 files changed, 331 insertions(+), 134 deletions(-) create mode 100644 src/v2/V2CompositionRoot.ts create mode 100644 src/v2/core/DateService.ts create mode 100644 src/v2/core/ITimeFormatConfig.ts create mode 100644 src/v2/core/NavigationAnimator.ts delete mode 100644 src/v2/core/StoreRegistry.ts create mode 100644 src/v2/demo/DemoApp.ts create mode 100644 src/v2/demo/MockStores.ts create mode 100644 src/v2/demo/index.ts create mode 100644 src/v2/features/timeaxis/TimeAxisRenderer.ts diff --git a/build.js b/build.js index b021cae..296a1ca 100644 --- a/build.js +++ b/build.js @@ -61,6 +61,22 @@ async function build() { 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) { console.error('Build failed:', error); process.exit(1); diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts new file mode 100644 index 0000000..7fc9102 --- /dev/null +++ b/src/v2/V2CompositionRoot.ts @@ -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(); + + // Services + builder.registerType(DateService).as(); + + // Renderers - registreres som IGroupingRenderer + builder.registerType(DateRenderer).as(); + builder.registerType(ResourceRenderer).as(); + builder.registerType(TeamRenderer).as(); + + // RendererRegistry modtager IGroupingRenderer[] automatisk (array injection) + builder.registerType(RendererRegistry).as(); + + // Stores - registreres som IGroupingStore + builder.registerType(MockTeamStore).as(); + builder.registerType(MockResourceStore).as(); + + // CalendarOrchestrator modtager IGroupingStore[] automatisk (array injection) + builder.registerType(CalendarOrchestrator).as(); + builder.registerType(TimeAxisRenderer).as(); + + // Demo app + builder.registerType(DemoApp).as(); + + return builder.build(); +} diff --git a/src/v2/core/CalendarOrchestrator.ts b/src/v2/core/CalendarOrchestrator.ts index b36aa6b..ff8e035 100644 --- a/src/v2/core/CalendarOrchestrator.ts +++ b/src/v2/core/CalendarOrchestrator.ts @@ -1,7 +1,7 @@ import { ViewConfig, GroupingConfig } from './ViewConfig'; import { RenderContext } from './RenderContext'; import { RendererRegistry } from './RendererRegistry'; -import { IStoreRegistry } from './IGroupingStore'; +import { IGroupingStore } from './IGroupingStore'; interface HierarchyNode { type: string; @@ -19,9 +19,13 @@ interface GroupingData { export class CalendarOrchestrator { constructor( 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 { const headerContainer = container.querySelector('swp-calendar-header') as HTMLElement; const columnContainer = container.querySelector('swp-day-columns') as HTMLElement; @@ -66,7 +70,9 @@ export class CalendarOrchestrator { 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 byParent = g.parentKey ? this.groupBy(items, item => (item.data as any)[g.parentKey!]) diff --git a/src/v2/core/DateService.ts b/src/v2/core/DateService.ts new file mode 100644 index 0000000..469fbdb --- /dev/null +++ b/src/v2/core/DateService.ts @@ -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') + ); + } +} diff --git a/src/v2/core/IGroupingStore.ts b/src/v2/core/IGroupingStore.ts index 0878905..8abc837 100644 --- a/src/v2/core/IGroupingStore.ts +++ b/src/v2/core/IGroupingStore.ts @@ -1,7 +1,4 @@ export interface IGroupingStore { + readonly type: string; getByIds(ids: string[]): T[]; } - -export interface IStoreRegistry { - get(type: string): IGroupingStore; -} diff --git a/src/v2/core/ITimeFormatConfig.ts b/src/v2/core/ITimeFormatConfig.ts new file mode 100644 index 0000000..1a401d5 --- /dev/null +++ b/src/v2/core/ITimeFormatConfig.ts @@ -0,0 +1,7 @@ +export interface ITimeFormatConfig { + timezone: string; + use24HourFormat: boolean; + locale: string; + dateFormat: 'locale' | 'technical'; + showSeconds: boolean; +} diff --git a/src/v2/core/NavigationAnimator.ts b/src/v2/core/NavigationAnimator.ts new file mode 100644 index 0000000..e44b08b --- /dev/null +++ b/src/v2/core/NavigationAnimator.ts @@ -0,0 +1,41 @@ +export class NavigationAnimator { + constructor( + private headerTrack: HTMLElement, + private contentTrack: HTMLElement + ) {} + + async slide(direction: 'left' | 'right', renderFn: () => Promise): Promise { + 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 { + 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 { + 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 + ]); + } +} diff --git a/src/v2/core/StoreRegistry.ts b/src/v2/core/StoreRegistry.ts deleted file mode 100644 index 1762057..0000000 --- a/src/v2/core/StoreRegistry.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IGroupingStore, IStoreRegistry } from './IGroupingStore'; - -export class StoreRegistry implements IStoreRegistry { - private stores = new Map(); - - 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; - } -} diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts new file mode 100644 index 0000000..5c85f1a --- /dev/null +++ b/src/v2/demo/DemoApp.ts @@ -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; + + 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)); + } +} diff --git a/src/v2/demo/MockStores.ts b/src/v2/demo/MockStores.ts new file mode 100644 index 0000000..84b7102 --- /dev/null +++ b/src/v2/demo/MockStores.ts @@ -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 { + 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 { + 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)); + } +} diff --git a/src/v2/demo/index.ts b/src/v2/demo/index.ts new file mode 100644 index 0000000..ca25d48 --- /dev/null +++ b/src/v2/demo/index.ts @@ -0,0 +1,5 @@ +import { createV2Container } from '../V2CompositionRoot'; +import { DemoApp } from './DemoApp'; + +const app = createV2Container(); +app.resolveType().init(); diff --git a/src/v2/features/date/DateRenderer.ts b/src/v2/features/date/DateRenderer.ts index d0d50af..667ab4a 100644 --- a/src/v2/features/date/DateRenderer.ts +++ b/src/v2/features/date/DateRenderer.ts @@ -1,20 +1,11 @@ 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 }) -}; +import { DateService } from '../../core/DateService'; export class DateRenderer implements IGroupingRenderer { readonly type = 'date'; - constructor(private dateService: IDateService = defaultDateService) {} + constructor(private dateService: DateService) {} render(context: RenderContext): void { for (const dateStr of context.values) { diff --git a/src/v2/features/date/index.ts b/src/v2/features/date/index.ts index 8db3850..7bf37b3 100644 --- a/src/v2/features/date/index.ts +++ b/src/v2/features/date/index.ts @@ -1 +1 @@ -export { DateRenderer, IDateService, defaultDateService } from './DateRenderer'; +export { DateRenderer } from './DateRenderer'; diff --git a/src/v2/features/timeaxis/TimeAxisRenderer.ts b/src/v2/features/timeaxis/TimeAxisRenderer.ts new file mode 100644 index 0000000..80279be --- /dev/null +++ b/src/v2/features/timeaxis/TimeAxisRenderer.ts @@ -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); + } + } +} diff --git a/src/v2/index.ts b/src/v2/index.ts index e835144..0027d47 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -2,13 +2,17 @@ export { ViewTemplate, ViewConfig, GroupingConfig } from './core/ViewConfig'; export { RenderContext } from './core/RenderContext'; export { IGroupingRenderer } from './core/IGroupingRenderer'; -export { IGroupingStore, IStoreRegistry } from './core/IGroupingStore'; +export { IGroupingStore } from './core/IGroupingStore'; export { RendererRegistry } from './core/RendererRegistry'; -export { StoreRegistry } from './core/StoreRegistry'; export { CalendarOrchestrator } from './core/CalendarOrchestrator'; +export { NavigationAnimator } from './core/NavigationAnimator'; // 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 { ResourceRenderer } from './features/resource'; export { TeamRenderer } from './features/team'; +export { TimeAxisRenderer } from './features/timeaxis/TimeAxisRenderer'; + diff --git a/wwwroot/css/calendar-v2.css b/wwwroot/css/calendar-v2.css index bc7472e..b207008 100644 --- a/wwwroot/css/calendar-v2.css +++ b/wwwroot/css/calendar-v2.css @@ -107,6 +107,22 @@ swp-grid-container { 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 */ swp-calendar-header { display: grid; diff --git a/wwwroot/v2.html b/wwwroot/v2.html index 5f80239..331334f 100644 --- a/wwwroot/v2.html +++ b/wwwroot/v2.html @@ -17,6 +17,8 @@ V2 + + @@ -25,107 +27,26 @@ - - - - - - - + + + + + + + + + + + + + + + - +