Adds workweek settings and dynamic view configuration

Introduces settings service for managing tenant-specific calendar configurations

Enables dynamic workweek presets with configurable work days
Improves view switching with enhanced UI components
Adds flexible calendar rendering based on tenant settings

Extends DateService to support workweek date generation
This commit is contained in:
Janus C. H. Knudsen 2025-12-15 22:24:32 +01:00
parent 58cedb9fad
commit ad2df353b5
13 changed files with 751 additions and 38 deletions

View file

@ -35,6 +35,9 @@ import { TeamStore } from './storage/teams/TeamStore';
import { TeamService } from './storage/teams/TeamService';
import { DepartmentStore } from './storage/departments/DepartmentStore';
import { DepartmentService } from './storage/departments/DepartmentService';
import { SettingsStore } from './storage/settings/SettingsStore';
import { SettingsService } from './storage/settings/SettingsService';
import { ITenantSettings } from './types/SettingsTypes';
// Audit
import { AuditStore } from './storage/audit/AuditStore';
@ -50,6 +53,7 @@ import { MockCustomerRepository } from './repositories/MockCustomerRepository';
import { MockAuditRepository } from './repositories/MockAuditRepository';
import { MockTeamRepository } from './repositories/MockTeamRepository';
import { MockDepartmentRepository } from './repositories/MockDepartmentRepository';
import { MockSettingsRepository } from './repositories/MockSettingsRepository';
// Workers
import { DataSeeder } from './workers/DataSeeder';
@ -113,6 +117,7 @@ export function createV2Container(): Container {
builder.registerType(DepartmentStore).as<IStore>();
builder.registerType(ScheduleOverrideStore).as<IStore>();
builder.registerType(AuditStore).as<IStore>();
builder.registerType(SettingsStore).as<IStore>();
// Entity services (for DataSeeder polymorphic array)
builder.registerType(EventService).as<IEntityService<ICalendarEvent>>();
@ -139,6 +144,10 @@ export function createV2Container(): Container {
builder.registerType(DepartmentService).as<IEntityService<ISync>>();
builder.registerType(DepartmentService).as<DepartmentService>();
builder.registerType(SettingsService).as<IEntityService<ITenantSettings>>();
builder.registerType(SettingsService).as<IEntityService<ISync>>();
builder.registerType(SettingsService).as<SettingsService>();
// Repositories (for DataSeeder polymorphic array)
builder.registerType(MockEventRepository).as<IApiRepository<ICalendarEvent>>();
builder.registerType(MockEventRepository).as<IApiRepository<ISync>>();
@ -161,6 +170,9 @@ export function createV2Container(): Container {
builder.registerType(MockDepartmentRepository).as<IApiRepository<IDepartment>>();
builder.registerType(MockDepartmentRepository).as<IApiRepository<ISync>>();
builder.registerType(MockSettingsRepository).as<IApiRepository<ITenantSettings>>();
builder.registerType(MockSettingsRepository).as<IApiRepository<ISync>>();
// Audit service (listens to ENTITY_SAVED/DELETED events automatically)
builder.registerType(AuditService).as<AuditService>();

View file

@ -48,6 +48,21 @@ export class DateService {
);
}
/**
* Get dates for specific weekdays within a week
* @param offset - Week offset from base date (0 = current week)
* @param workDays - Array of ISO weekday numbers (1=Monday, 7=Sunday)
* @returns Array of date strings in YYYY-MM-DD format
*/
getWorkWeekDates(offset: number, workDays: number[]): string[] {
const monday = this.baseDate.startOf('week').add(1, 'day').add(offset, 'week');
return workDays.map(isoDay => {
// ISO: 1=Monday, 7=Sunday → days from Monday: 0-6
const daysFromMonday = isoDay === 7 ? 6 : isoDay - 1;
return monday.add(daysFromMonday, 'day').format('YYYY-MM-DD');
});
}
// ============================================
// FORMATTING
// ============================================

View file

@ -13,12 +13,15 @@ import { ResizeManager } from '../managers/ResizeManager';
import { EventPersistenceManager } from '../managers/EventPersistenceManager';
import { HeaderDrawerRenderer } from '../features/headerdrawer/HeaderDrawerRenderer';
import { AuditService } from '../storage/audit/AuditService';
import { SettingsService } from '../storage/settings/SettingsService';
import { IWorkweekPreset } from '../types/SettingsTypes';
export class DemoApp {
private animator!: NavigationAnimator;
private container!: HTMLElement;
private weekOffset = 0;
private currentView: 'day' | 'simple' | 'resource' | 'team' | 'department' = 'simple';
private workweekPreset: IWorkweekPreset | null = null;
constructor(
private orchestrator: CalendarOrchestrator,
@ -33,7 +36,8 @@ export class DemoApp {
private resizeManager: ResizeManager,
private headerDrawerRenderer: HeaderDrawerRenderer,
private eventPersistenceManager: EventPersistenceManager,
private auditService: AuditService
private auditService: AuditService,
private settingsService: SettingsService
) {}
async init(): Promise<void> {
@ -48,6 +52,10 @@ export class DemoApp {
await this.dataSeeder.seedIfEmpty();
console.log('[DemoApp] Data seeding complete');
// Load default workweek preset from settings
this.workweekPreset = await this.settingsService.getDefaultWorkweekPreset();
console.log('[DemoApp] Workweek preset loaded:', this.workweekPreset?.id);
this.container = document.querySelector('swp-calendar-container') as HTMLElement;
// NavigationAnimator har DOM-dependencies - tilladt med new
@ -90,7 +98,9 @@ export class DemoApp {
}
private buildViewConfig(): ViewConfig {
const dates = this.dateService.getWeekDates(this.weekOffset, 3);
// Use workweek preset to determine which days to show
const workDays = this.workweekPreset?.workDays || [1, 2, 3, 4, 5]; // Fallback to Mon-Fri
const dates = this.dateService.getWorkWeekDates(this.weekOffset, workDays);
const today = this.dateService.getWeekDates(this.weekOffset, 1);
switch (this.currentView) {
@ -155,29 +165,32 @@ export class DemoApp {
}
private setupViewSwitching(): void {
document.getElementById('btn-day')?.addEventListener('click', () => {
this.currentView = 'day';
this.render();
// View chip buttons
const chips = document.querySelectorAll('.view-chip');
chips.forEach(chip => {
chip.addEventListener('click', () => {
// Update active state
chips.forEach(c => c.classList.remove('active'));
chip.classList.add('active');
// Switch view
const view = (chip as HTMLElement).dataset.view as typeof this.currentView;
if (view) {
this.currentView = view;
this.render();
}
});
});
document.getElementById('btn-simple')?.addEventListener('click', () => {
this.currentView = 'simple';
this.render();
});
document.getElementById('btn-resource')?.addEventListener('click', () => {
this.currentView = 'resource';
this.render();
});
document.getElementById('btn-team')?.addEventListener('click', () => {
this.currentView = 'team';
this.render();
});
document.getElementById('btn-department')?.addEventListener('click', () => {
this.currentView = 'department';
this.render();
// Workweek preset dropdown
const workweekSelect = document.getElementById('workweek-select') as HTMLSelectElement;
workweekSelect?.addEventListener('change', async () => {
const presetId = workweekSelect.value;
const preset = await this.settingsService.getWorkweekPreset(presetId);
if (preset) {
this.workweekPreset = preset;
this.render();
}
});
}

View file

@ -0,0 +1,47 @@
import { EntityType } from '../types/CalendarTypes';
import { ITenantSettings } from '../types/SettingsTypes';
import { IApiRepository } from './IApiRepository';
/**
* MockSettingsRepository - Loads tenant settings from local JSON file
*
* Settings is a single document, but we wrap it in an array to match
* the IApiRepository interface used by DataSeeder.
*/
export class MockSettingsRepository implements IApiRepository<ITenantSettings> {
public readonly entityType: EntityType = 'Settings';
private readonly dataUrl = 'data/tenant-settings.json';
public async fetchAll(): Promise<ITenantSettings[]> {
try {
const response = await fetch(this.dataUrl);
if (!response.ok) {
throw new Error(`Failed to load tenant settings: ${response.status} ${response.statusText}`);
}
const rawData = await response.json();
// Ensure syncStatus is set
const settings: ITenantSettings = {
...rawData,
syncStatus: rawData.syncStatus || 'synced'
};
return [settings];
} catch (error) {
console.error('Failed to load tenant settings:', error);
throw error;
}
}
public async sendCreate(_settings: ITenantSettings): Promise<ITenantSettings> {
throw new Error('MockSettingsRepository does not support sendCreate. Mock data is read-only.');
}
public async sendUpdate(_id: string, _updates: Partial<ITenantSettings>): Promise<ITenantSettings> {
throw new Error('MockSettingsRepository does not support sendUpdate. Mock data is read-only.');
}
public async sendDelete(_id: string): Promise<void> {
throw new Error('MockSettingsRepository does not support sendDelete. Mock data is read-only.');
}
}

View file

@ -10,7 +10,7 @@ import { IStore } from './IStore';
*/
export class IndexedDBContext {
private static readonly DB_NAME = 'CalendarV2DB';
private static readonly DB_VERSION = 3;
private static readonly DB_VERSION = 4;
private db: IDBDatabase | null = null;
private initialized: boolean = false;

View file

@ -0,0 +1,60 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { ITenantSettings, IWorkweekPreset } from '../../types/SettingsTypes';
import { SettingsStore } from './SettingsStore';
import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext';
/**
* Default settings ID - single document per tenant
*/
const TENANT_SETTINGS_ID = 'tenant-settings';
/**
* SettingsService - CRUD operations for tenant settings
*
* Settings are stored as a single document with sections.
* This service provides convenience methods for accessing specific sections.
*/
export class SettingsService extends BaseEntityService<ITenantSettings> {
readonly storeName = SettingsStore.STORE_NAME;
readonly entityType: EntityType = 'Settings';
constructor(context: IndexedDBContext, eventBus: IEventBus) {
super(context, eventBus);
}
/**
* Get the tenant settings document
* Returns null if not yet loaded from backend
*/
async getSettings(): Promise<ITenantSettings | null> {
return this.get(TENANT_SETTINGS_ID);
}
/**
* Get workweek preset by ID
*/
async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> {
const settings = await this.getSettings();
if (!settings) return null;
return settings.workweek.presets[presetId] || null;
}
/**
* Get the default workweek preset
*/
async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> {
const settings = await this.getSettings();
if (!settings) return null;
return settings.workweek.presets[settings.workweek.defaultPreset] || null;
}
/**
* Get all available workweek presets
*/
async getWorkweekPresets(): Promise<IWorkweekPreset[]> {
const settings = await this.getSettings();
if (!settings) return [];
return Object.values(settings.workweek.presets);
}
}

View file

@ -0,0 +1,16 @@
import { IStore } from '../IStore';
/**
* SettingsStore - IndexedDB ObjectStore definition for tenant settings
*
* Single store for all settings sections. Settings are stored as one document
* per tenant with id='tenant-settings'.
*/
export class SettingsStore implements IStore {
static readonly STORE_NAME = 'settings';
readonly storeName = SettingsStore.STORE_NAME;
create(db: IDBDatabase): void {
db.createObjectStore(SettingsStore.STORE_NAME, { keyPath: 'id' });
}
}

View file

@ -6,7 +6,7 @@ import { IWeekSchedule } from './ScheduleTypes';
export type SyncStatus = 'synced' | 'pending' | 'error';
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit';
export type EntityType = 'Event' | 'Booking' | 'Customer' | 'Resource' | 'Team' | 'Department' | 'Audit' | 'Settings';
/**
* CalendarEventType - Used by ICalendarEvent.type

View file

@ -0,0 +1,72 @@
/**
* Tenant Settings Type Definitions
*
* Settings are tenant-specific configuration that comes from the backend
* and is stored in IndexedDB for offline access.
*/
import { ISync } from './CalendarTypes';
/**
* Workweek preset - defines which ISO weekdays to display
* ISO: 1=Monday, 7=Sunday
*/
export interface IWorkweekPreset {
id: string;
workDays: number[];
label: string;
}
/**
* Workweek settings section
*/
export interface IWorkweekSettings {
presets: Record<string, IWorkweekPreset>;
defaultPreset: string;
firstDayOfWeek: number; // ISO: 1=Monday
}
/**
* Grid display settings section
*/
export interface IGridSettings {
dayStartHour: number;
dayEndHour: number;
workStartHour: number;
workEndHour: number;
hourHeight: number;
snapInterval: number;
}
/**
* Time format settings section
*/
export interface ITimeFormatSettings {
timezone: string;
locale: string;
use24HourFormat: boolean;
}
/**
* View settings section
*/
export interface IViewSettings {
availableViews: string[];
defaultView: string;
}
/**
* ITenantSettings - Complete tenant configuration
*
* Single document stored in IndexedDB 'settings' store.
* Sections can be extended as needed without schema changes.
*/
export interface ITenantSettings extends ISync {
id: string;
lastModified?: string;
workweek: IWorkweekSettings;
grid: IGridSettings;
timeFormat: ITimeFormatSettings;
views: IViewSettings;
}