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
This commit is contained in:
Janus C. H. Knudsen 2025-12-07 14:31:16 +01:00
parent 1ad7d10266
commit a0c0ef9e8d
17 changed files with 331 additions and 134 deletions

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>