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 @@ - - - - - - - + + + + + + + + + + + + + + + - +