Adds comprehensive mock data repositories and seeding infrastructure

Implements polymorphic data seeding mechanism for initial application setup

- Adds Mock repositories for Event, Booking, Customer, and Resource entities
- Creates DataSeeder to automatically populate IndexedDB from JSON sources
- Enhances index.ts initialization process with data seeding step
- Adds mock JSON data files for comprehensive test data

Improves offline-first and development testing capabilities
This commit is contained in:
Janus C. H. Knudsen 2025-11-20 15:25:38 +01:00
parent 871f5c5682
commit 5648c7c304
11 changed files with 1641 additions and 40 deletions

View file

@ -4,7 +4,7 @@ import { eventBus } from './core/EventBus';
import { ConfigManager } from './configurations/ConfigManager';
import { Configuration } from './configurations/CalendarConfig';
import { URLManager } from './utils/URLManager';
import { IEventBus } from './types/CalendarTypes';
import { ICalendarEvent, IEventBus } from './types/CalendarTypes';
// Import all managers
import { EventManager } from './managers/EventManager';
@ -25,6 +25,9 @@ import { WorkweekPresets } from './components/WorkweekPresets';
// Import repositories and storage
import { IEventRepository } from './repositories/IEventRepository';
import { MockEventRepository } from './repositories/MockEventRepository';
import { MockBookingRepository } from './repositories/MockBookingRepository';
import { MockCustomerRepository } from './repositories/MockCustomerRepository';
import { MockResourceRepository } from './repositories/MockResourceRepository';
import { IndexedDBEventRepository } from './repositories/IndexedDBEventRepository';
import { IApiRepository } from './repositories/IApiRepository';
import { ApiEventRepository } from './repositories/ApiEventRepository';
@ -46,6 +49,7 @@ import { ResourceService } from './storage/resources/ResourceService';
// Import workers
import { SyncManager } from './workers/SyncManager';
import { DataSeeder } from './workers/DataSeeder';
// Import renderers
import { DateHeaderRenderer, type IHeaderRenderer } from './renderers/DateHeaderRenderer';
@ -65,6 +69,9 @@ import { EventStackManager } from './managers/EventStackManager';
import { EventLayoutCoordinator } from './managers/EventLayoutCoordinator';
import { IColumnDataSource } from './types/ColumnDataSource';
import { DateColumnDataSource } from './datasources/DateColumnDataSource';
import { IBooking } from './types/BookingTypes';
import { ICustomer } from './types/CustomerTypes';
import { IResource } from './types/ResourceTypes';
/**
* Handle deep linking functionality after managers are initialized
@ -122,26 +129,27 @@ async function initializeCalendar(): Promise<void> {
builder.registerType(IndexedDBService).as<IndexedDBService>();
builder.registerType(OperationQueue).as<OperationQueue>();
// Register API repositories (backend sync)
// Each entity type has its own API repository implementing IApiRepository<T>
builder.registerType(ApiEventRepository).as<IApiRepository<any>>();
builder.registerType(ApiBookingRepository).as<IApiRepository<any>>();
builder.registerType(ApiCustomerRepository).as<IApiRepository<any>>();
builder.registerType(ApiResourceRepository).as<IApiRepository<any>>();
// Register Mock repositories (development/testing - load from JSON files)
// Each entity type has its own Mock repository implementing IApiRepository<T>
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
builder.registerType(MockBookingRepository).as<IApiRepository<IBooking>>();
builder.registerType(MockCustomerRepository).as<IApiRepository<ICustomer>>();
builder.registerType(MockResourceRepository).as<IApiRepository<IResource>>();
builder.registerType(DateColumnDataSource).as<IColumnDataSource>();
// Register entity services (sync status management)
// Register entity services (sync status management)
// Open/Closed Principle: Adding new entity only requires adding one line here
builder.registerType(EventService).as<IEntityService<any>>();
builder.registerType(BookingService).as<IEntityService<any>>();
builder.registerType(CustomerService).as<IEntityService<any>>();
builder.registerType(ResourceService).as<IEntityService<any>>();
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
builder.registerType(BookingService).as<IEntityService<IBooking>>();
builder.registerType(CustomerService).as<IEntityService<ICustomer>>();
builder.registerType(ResourceService).as<IEntityService<IResource>>();
// Register IndexedDB repositories (offline-first)
builder.registerType(IndexedDBEventRepository).as<IEventRepository>();
// Register workers
builder.registerType(SyncManager).as<SyncManager>();
builder.registerType(DataSeeder).as<DataSeeder>();
// Register renderers
builder.registerType(DateHeaderRenderer).as<IHeaderRenderer>();
@ -181,6 +189,13 @@ async function initializeCalendar(): Promise<void> {
// Build the container
const app = builder.build();
// Initialize database and seed data BEFORE initializing managers
const indexedDBService = app.resolveType<IndexedDBService>();
await indexedDBService.initialize();
const dataSeeder = app.resolveType<DataSeeder>();
await dataSeeder.seedIfEmpty();
// Get managers from container
const eb = app.resolveType<IEventBus>();
const calendarManager = app.resolveType<CalendarManager>();

View file

@ -0,0 +1,90 @@
import { IBooking, IBookingService, BookingStatus } from '../types/BookingTypes';
import { EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawBookingData {
id: string;
customerId: string;
status: string;
createdAt: string | Date;
services: RawBookingService[];
totalPrice?: number;
tags?: string[];
notes?: string;
[key: string]: unknown;
}
interface RawBookingService {
serviceId: string;
serviceName: string;
baseDuration: number;
basePrice: number;
customPrice?: number;
resourceId: string;
}
/**
* MockBookingRepository - Loads booking data from local JSON file
*
* This repository implementation fetches mock booking data from a static JSON file.
* Used for development and testing instead of API calls.
*
* Data Source: data/mock-bookings.json
*
* NOTE: Create/Update/Delete operations are not supported - throws errors.
* Only fetchAll() is implemented for loading initial mock data.
*/
export class MockBookingRepository implements IApiRepository<IBooking> {
public readonly entityType: EntityType = 'Booking';
private readonly dataUrl = 'data/mock-bookings.json';
/**
* Fetch all bookings from mock JSON file
*/
public async fetchAll(): Promise<IBooking[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load mock bookings: ${response.status} ${response.statusText}`);
}
const rawData: RawBookingData[] = await response.json();
return this.processBookingData(rawData);
} catch (error) {
console.error('Failed to load booking data:', error);
throw error;
}
}
/**
* NOT SUPPORTED - MockBookingRepository is read-only
*/
public async sendCreate(booking: IBooking): Promise<IBooking> {
throw new Error('MockBookingRepository does not support sendCreate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockBookingRepository is read-only
*/
public async sendUpdate(id: string, updates: Partial<IBooking>): Promise<IBooking> {
throw new Error('MockBookingRepository does not support sendUpdate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockBookingRepository is read-only
*/
public async sendDelete(id: string): Promise<void> {
throw new Error('MockBookingRepository does not support sendDelete. Mock data is read-only.');
}
private processBookingData(data: RawBookingData[]): IBooking[] {
return data.map((booking): IBooking => ({
...booking,
createdAt: new Date(booking.createdAt),
status: booking.status as BookingStatus,
syncStatus: 'synced' as const
}));
}
}

View file

@ -0,0 +1,76 @@
import { ICustomer } from '../types/CustomerTypes';
import { EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawCustomerData {
id: string;
name: string;
phone: string;
email?: string;
metadata?: Record<string, any>;
[key: string]: unknown;
}
/**
* MockCustomerRepository - Loads customer data from local JSON file
*
* This repository implementation fetches mock customer data from a static JSON file.
* Used for development and testing instead of API calls.
*
* Data Source: data/mock-customers.json
*
* NOTE: Create/Update/Delete operations are not supported - throws errors.
* Only fetchAll() is implemented for loading initial mock data.
*/
export class MockCustomerRepository implements IApiRepository<ICustomer> {
public readonly entityType: EntityType = 'Customer';
private readonly dataUrl = 'data/mock-customers.json';
/**
* Fetch all customers from mock JSON file
*/
public async fetchAll(): Promise<ICustomer[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load mock customers: ${response.status} ${response.statusText}`);
}
const rawData: RawCustomerData[] = await response.json();
return this.processCustomerData(rawData);
} catch (error) {
console.error('Failed to load customer data:', error);
throw error;
}
}
/**
* NOT SUPPORTED - MockCustomerRepository is read-only
*/
public async sendCreate(customer: ICustomer): Promise<ICustomer> {
throw new Error('MockCustomerRepository does not support sendCreate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockCustomerRepository is read-only
*/
public async sendUpdate(id: string, updates: Partial<ICustomer>): Promise<ICustomer> {
throw new Error('MockCustomerRepository does not support sendUpdate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockCustomerRepository is read-only
*/
public async sendDelete(id: string): Promise<void> {
throw new Error('MockCustomerRepository does not support sendDelete. Mock data is read-only.');
}
private processCustomerData(data: RawCustomerData[]): ICustomer[] {
return data.map((customer): ICustomer => ({
...customer,
syncStatus: 'synced' as const
}));
}
}

View file

@ -1,33 +1,50 @@
import { ICalendarEvent } from '../types/CalendarTypes';
import { ICalendarEvent, EntityType } from '../types/CalendarTypes';
import { CalendarEventType } from '../types/BookingTypes';
import { IEventRepository, UpdateSource } from './IEventRepository';
import { IApiRepository } from './IApiRepository';
interface RawEventData {
// Core fields (required)
id: string;
title: string;
start: string | Date;
end: string | Date;
type: string;
color?: string;
allDay?: boolean;
// Denormalized references (CRITICAL for booking architecture)
bookingId?: string; // Reference to booking (customer events only)
resourceId?: string; // Which resource owns this slot
customerId?: string; // Customer reference (denormalized from booking)
// Optional fields
description?: string; // Detailed event notes
recurringId?: string; // For recurring events
metadata?: Record<string, any>; // Flexible metadata
// Legacy (deprecated, keep for backward compatibility)
color?: string; // UI-specific field
[key: string]: unknown;
}
/**
* MockEventRepository - Loads event data from local JSON file (LEGACY)
* MockEventRepository - Loads event data from local JSON file
*
* This repository implementation fetches mock event data from a static JSON file.
* DEPRECATED: Use IndexedDBEventRepository for offline-first functionality.
* Used for development and testing instead of API calls.
*
* Data Source: data/mock-events.json
*
* NOTE: Create/Update/Delete operations are not supported - throws errors.
* This is intentional to encourage migration to IndexedDBEventRepository.
* Only fetchAll() is implemented for loading initial mock data.
*/
export class MockEventRepository implements IEventRepository {
export class MockEventRepository implements IApiRepository<ICalendarEvent> {
public readonly entityType: EntityType = 'Event';
private readonly dataUrl = 'data/mock-events.json';
public async loadEvents(): Promise<ICalendarEvent[]> {
/**
* Fetch all events from mock JSON file
*/
public async fetchAll(): Promise<ICalendarEvent[]> {
try {
const response = await fetch(this.dataUrl);
@ -46,36 +63,60 @@ export class MockEventRepository implements IEventRepository {
/**
* NOT SUPPORTED - MockEventRepository is read-only
* Use IndexedDBEventRepository instead
*/
public async createEvent(event: Omit<ICalendarEvent, 'id'>, source?: UpdateSource): Promise<ICalendarEvent> {
throw new Error('MockEventRepository does not support createEvent. Use IndexedDBEventRepository instead.');
public async sendCreate(event: ICalendarEvent): Promise<ICalendarEvent> {
throw new Error('MockEventRepository does not support sendCreate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockEventRepository is read-only
* Use IndexedDBEventRepository instead
*/
public async updateEvent(id: string, updates: Partial<ICalendarEvent>, source?: UpdateSource): Promise<ICalendarEvent> {
throw new Error('MockEventRepository does not support updateEvent. Use IndexedDBEventRepository instead.');
public async sendUpdate(id: string, updates: Partial<ICalendarEvent>): Promise<ICalendarEvent> {
throw new Error('MockEventRepository does not support sendUpdate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockEventRepository is read-only
* Use IndexedDBEventRepository instead
*/
public async deleteEvent(id: string, source?: UpdateSource): Promise<void> {
throw new Error('MockEventRepository does not support deleteEvent. Use IndexedDBEventRepository instead.');
public async sendDelete(id: string): Promise<void> {
throw new Error('MockEventRepository does not support sendDelete. Mock data is read-only.');
}
private processCalendarData(data: RawEventData[]): ICalendarEvent[] {
return data.map((event): ICalendarEvent => ({
...event,
start: new Date(event.start),
end: new Date(event.end),
type: event.type as CalendarEventType,
allDay: event.allDay || false,
syncStatus: 'synced' as const
}));
return data.map((event): ICalendarEvent => {
// Validate event type constraints
if (event.type === 'customer') {
if (!event.bookingId) {
console.warn(`Customer event ${event.id} missing bookingId`);
}
if (!event.resourceId) {
console.warn(`Customer event ${event.id} missing resourceId`);
}
if (!event.customerId) {
console.warn(`Customer event ${event.id} missing customerId`);
}
}
return {
id: event.id,
title: event.title,
description: event.description,
start: new Date(event.start),
end: new Date(event.end),
type: event.type as CalendarEventType,
allDay: event.allDay || false,
// Denormalized references (CRITICAL for booking architecture)
bookingId: event.bookingId,
resourceId: event.resourceId,
customerId: event.customerId,
// Optional fields
recurringId: event.recurringId,
metadata: event.metadata,
syncStatus: 'synced' as const
};
});
}
}

View file

@ -0,0 +1,80 @@
import { IResource, ResourceType } from '../types/ResourceTypes';
import { EntityType } from '../types/CalendarTypes';
import { IApiRepository } from './IApiRepository';
interface RawResourceData {
id: string;
name: string;
displayName: string;
type: string;
avatarUrl?: string;
color?: string;
isActive?: boolean;
metadata?: Record<string, any>;
[key: string]: unknown;
}
/**
* MockResourceRepository - Loads resource data from local JSON file
*
* This repository implementation fetches mock resource data from a static JSON file.
* Used for development and testing instead of API calls.
*
* Data Source: data/mock-resources.json
*
* NOTE: Create/Update/Delete operations are not supported - throws errors.
* Only fetchAll() is implemented for loading initial mock data.
*/
export class MockResourceRepository implements IApiRepository<IResource> {
public readonly entityType: EntityType = 'Resource';
private readonly dataUrl = 'data/mock-resources.json';
/**
* Fetch all resources from mock JSON file
*/
public async fetchAll(): Promise<IResource[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load mock resources: ${response.status} ${response.statusText}`);
}
const rawData: RawResourceData[] = await response.json();
return this.processResourceData(rawData);
} catch (error) {
console.error('Failed to load resource data:', error);
throw error;
}
}
/**
* NOT SUPPORTED - MockResourceRepository is read-only
*/
public async sendCreate(resource: IResource): Promise<IResource> {
throw new Error('MockResourceRepository does not support sendCreate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockResourceRepository is read-only
*/
public async sendUpdate(id: string, updates: Partial<IResource>): Promise<IResource> {
throw new Error('MockResourceRepository does not support sendUpdate. Mock data is read-only.');
}
/**
* NOT SUPPORTED - MockResourceRepository is read-only
*/
public async sendDelete(id: string): Promise<void> {
throw new Error('MockResourceRepository does not support sendDelete. Mock data is read-only.');
}
private processResourceData(data: RawResourceData[]): IResource[] {
return data.map((resource): IResource => ({
...resource,
type: resource.type as ResourceType,
syncStatus: 'synced' as const
}));
}
}

View file

@ -4,13 +4,13 @@ import { ISync, EntityType, SyncStatus } from '../types/CalendarTypes';
* IEntityService<T> - Generic interface for entity services with sync capabilities
*
* All entity services (Event, Booking, Customer, Resource) implement this interface
* to enable polymorphic sync status management in SyncManager.
* to enable polymorphic operations across different entity types.
*
* ENCAPSULATION: Services encapsulate sync status manipulation.
* SyncManager does NOT directly manipulate entity.syncStatus - it delegates to the service.
*
* POLYMORFI: SyncManager works with Array<IEntityService<any>> and uses
* entityType property for runtime routing, avoiding switch statements.
* POLYMORPHISM: Both SyncManager and DataSeeder work with Array<IEntityService<any>>
* and use entityType property for runtime routing, avoiding switch statements.
*/
export interface IEntityService<T extends ISync> {
/**
@ -19,6 +19,30 @@ export interface IEntityService<T extends ISync> {
*/
readonly entityType: EntityType;
// ============================================================================
// CRUD Operations (used by DataSeeder and other consumers)
// ============================================================================
/**
* Get all entities from IndexedDB
* Used by DataSeeder to check if store is empty before seeding
*
* @returns Promise<T[]> - Array of all entities
*/
getAll(): Promise<T[]>;
/**
* Save an entity (create or update) to IndexedDB
* Used by DataSeeder to persist fetched data
*
* @param entity - Entity to save
*/
save(entity: T): Promise<void>;
// ============================================================================
// SYNC Methods (used by SyncManager)
// ============================================================================
/**
* Mark entity as successfully synced with backend
* Sets syncStatus = 'synced' and persists to IndexedDB

103
src/workers/DataSeeder.ts Normal file
View file

@ -0,0 +1,103 @@
import { IApiRepository } from '../repositories/IApiRepository';
import { IEntityService } from '../storage/IEntityService';
/**
* DataSeeder - Orchestrates initial data loading from repositories into IndexedDB
*
* ARCHITECTURE:
* - Repository (Mock/Api): Fetches data from source (JSON file or backend API)
* - DataSeeder (this class): Orchestrates fetch + save operations
* - Service (EventService, etc.): Saves data to IndexedDB
*
* SEPARATION OF CONCERNS:
* - Repository does NOT know about IndexedDB or storage
* - Service does NOT know about where data comes from
* - DataSeeder connects them together
*
* POLYMORPHIC DESIGN:
* - Uses arrays of IEntityService<any>[] and IApiRepository<any>[]
* - Matches services with repositories using entityType property
* - Open/Closed Principle: Adding new entity requires no code changes here
*
* USAGE:
* Called once during app initialization in index.ts:
* 1. IndexedDBService.initialize() - open database
* 2. dataSeeder.seedIfEmpty() - load initial data if needed
* 3. CalendarManager.initialize() - start calendar
*
* NOTE: This is for INITIAL SEEDING only. Ongoing sync is handled by SyncManager.
*/
export class DataSeeder {
constructor(
// Arrays injected via DI - automatically includes all registered services/repositories
private services: IEntityService<any>[],
private repositories: IApiRepository<any>[]
) {}
/**
* Seed all entity stores if they are empty
* Runs on app initialization to load initial data from repositories
*
* Uses polymorphism: loops through all services and matches with repositories by entityType
*/
async seedIfEmpty(): Promise<void> {
console.log('[DataSeeder] Checking if database needs seeding...');
try {
// Loop through all entity services (Event, Booking, Customer, Resource, etc.)
for (const service of this.services) {
// Find matching repository for this service based on entityType
const repository = this.repositories.find(repo => repo.entityType === service.entityType);
if (!repository) {
console.warn(`[DataSeeder] No repository found for entity type: ${service.entityType}, skipping`);
continue;
}
// Seed this entity type
await this.seedEntity(service.entityType, service, repository);
}
console.log('[DataSeeder] Seeding complete');
} catch (error) {
console.error('[DataSeeder] Seeding failed:', error);
throw error;
}
}
/**
* Generic method to seed a single entity type
*
* @param entityType - Entity type ('Event', 'Booking', 'Customer', 'Resource')
* @param service - Entity service for IndexedDB operations
* @param repository - Repository for fetching data
*/
private async seedEntity<T>(
entityType: string,
service: IEntityService<any>,
repository: IApiRepository<T>
): Promise<void> {
// Check if store is empty
const existing = await service.getAll();
if (existing.length > 0) {
console.log(`[DataSeeder] ${entityType} store already has ${existing.length} items, skipping seed`);
return;
}
console.log(`[DataSeeder] ${entityType} store is empty, fetching from repository...`);
// Fetch from repository (Mock JSON or backend API)
const data = await repository.fetchAll();
console.log(`[DataSeeder] Fetched ${data.length} ${entityType} items, saving to IndexedDB...`);
// Save each entity to IndexedDB
// Note: Entities from repository should already have syncStatus='synced'
for (const entity of data) {
await service.save(entity);
}
console.log(`[DataSeeder] ${entityType} seeding complete (${data.length} items saved)`);
}
}