Refactor settings model to separate record-based approach

Restructures tenant settings to use individual records instead of a single document

Decouples settings sections into separate typed interfaces with unique IDs
Modifies data loading and service methods to support new record-based settings
Updates mock data and repository to align with new settings structure
This commit is contained in:
Janus C. H. Knudsen 2025-12-17 20:53:47 +01:00
parent b2c81dc163
commit 9f360237cf
5 changed files with 97 additions and 69 deletions

View file

@ -38,7 +38,7 @@ import { DepartmentStore } from './storage/departments/DepartmentStore';
import { DepartmentService } from './storage/departments/DepartmentService'; import { DepartmentService } from './storage/departments/DepartmentService';
import { SettingsStore } from './storage/settings/SettingsStore'; import { SettingsStore } from './storage/settings/SettingsStore';
import { SettingsService } from './storage/settings/SettingsService'; import { SettingsService } from './storage/settings/SettingsService';
import { ITenantSettings } from './types/SettingsTypes'; import { TenantSetting } from './types/SettingsTypes';
import { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore'; import { ViewConfigStore } from './storage/viewconfigs/ViewConfigStore';
import { ViewConfigService } from './storage/viewconfigs/ViewConfigService'; import { ViewConfigService } from './storage/viewconfigs/ViewConfigService';
import { ViewConfig } from './core/ViewConfig'; import { ViewConfig } from './core/ViewConfig';
@ -150,7 +150,7 @@ export function createV2Container(): Container {
builder.registerType(DepartmentService).as<IEntityService<ISync>>(); builder.registerType(DepartmentService).as<IEntityService<ISync>>();
builder.registerType(DepartmentService).as<DepartmentService>(); builder.registerType(DepartmentService).as<DepartmentService>();
builder.registerType(SettingsService).as<IEntityService<ITenantSettings>>(); builder.registerType(SettingsService).as<IEntityService<TenantSetting>>();
builder.registerType(SettingsService).as<IEntityService<ISync>>(); builder.registerType(SettingsService).as<IEntityService<ISync>>();
builder.registerType(SettingsService).as<SettingsService>(); builder.registerType(SettingsService).as<SettingsService>();
@ -180,7 +180,7 @@ export function createV2Container(): Container {
builder.registerType(MockDepartmentRepository).as<IApiRepository<IDepartment>>(); builder.registerType(MockDepartmentRepository).as<IApiRepository<IDepartment>>();
builder.registerType(MockDepartmentRepository).as<IApiRepository<ISync>>(); builder.registerType(MockDepartmentRepository).as<IApiRepository<ISync>>();
builder.registerType(MockSettingsRepository).as<IApiRepository<ITenantSettings>>(); builder.registerType(MockSettingsRepository).as<IApiRepository<TenantSetting>>();
builder.registerType(MockSettingsRepository).as<IApiRepository<ISync>>(); builder.registerType(MockSettingsRepository).as<IApiRepository<ISync>>();
builder.registerType(MockViewConfigRepository).as<IApiRepository<ViewConfig>>(); builder.registerType(MockViewConfigRepository).as<IApiRepository<ViewConfig>>();

View file

@ -1,18 +1,18 @@
import { EntityType } from '../types/CalendarTypes'; import { EntityType } from '../types/CalendarTypes';
import { ITenantSettings } from '../types/SettingsTypes'; import { TenantSetting } from '../types/SettingsTypes';
import { IApiRepository } from './IApiRepository'; import { IApiRepository } from './IApiRepository';
/** /**
* MockSettingsRepository - Loads tenant settings from local JSON file * MockSettingsRepository - Loads tenant settings from local JSON file
* *
* Settings is a single document, but we wrap it in an array to match * Settings are stored as separate records per section (workweek, grid, etc.).
* the IApiRepository interface used by DataSeeder. * The JSON file is already an array of TenantSetting records.
*/ */
export class MockSettingsRepository implements IApiRepository<ITenantSettings> { export class MockSettingsRepository implements IApiRepository<TenantSetting> {
public readonly entityType: EntityType = 'Settings'; public readonly entityType: EntityType = 'Settings';
private readonly dataUrl = 'data/tenant-settings.json'; private readonly dataUrl = 'data/tenant-settings.json';
public async fetchAll(): Promise<ITenantSettings[]> { public async fetchAll(): Promise<TenantSetting[]> {
try { try {
const response = await fetch(this.dataUrl); const response = await fetch(this.dataUrl);
@ -20,24 +20,23 @@ export class MockSettingsRepository implements IApiRepository<ITenantSettings> {
throw new Error(`Failed to load tenant settings: ${response.status} ${response.statusText}`); throw new Error(`Failed to load tenant settings: ${response.status} ${response.statusText}`);
} }
const rawData = await response.json(); const settings: TenantSetting[] = await response.json();
// Ensure syncStatus is set // Ensure syncStatus is set on each record
const settings: ITenantSettings = { return settings.map(s => ({
...rawData, ...s,
syncStatus: rawData.syncStatus || 'synced' syncStatus: s.syncStatus || 'synced'
}; }));
return [settings];
} catch (error) { } catch (error) {
console.error('Failed to load tenant settings:', error); console.error('Failed to load tenant settings:', error);
throw error; throw error;
} }
} }
public async sendCreate(_settings: ITenantSettings): Promise<ITenantSettings> { public async sendCreate(_settings: TenantSetting): Promise<TenantSetting> {
throw new Error('MockSettingsRepository does not support sendCreate. Mock data is read-only.'); throw new Error('MockSettingsRepository does not support sendCreate. Mock data is read-only.');
} }
public async sendUpdate(_id: string, _updates: Partial<ITenantSettings>): Promise<ITenantSettings> { public async sendUpdate(_id: string, _updates: Partial<TenantSetting>): Promise<TenantSetting> {
throw new Error('MockSettingsRepository does not support sendUpdate. Mock data is read-only.'); throw new Error('MockSettingsRepository does not support sendUpdate. Mock data is read-only.');
} }

View file

@ -1,21 +1,24 @@
import { EntityType, IEventBus } from '../../types/CalendarTypes'; import { EntityType, IEventBus } from '../../types/CalendarTypes';
import { ITenantSettings, IWorkweekPreset } from '../../types/SettingsTypes'; import {
TenantSetting,
IWorkweekSettings,
IGridSettings,
ITimeFormatSettings,
IViewSettings,
IWorkweekPreset,
SettingsIds
} from '../../types/SettingsTypes';
import { SettingsStore } from './SettingsStore'; import { SettingsStore } from './SettingsStore';
import { BaseEntityService } from '../BaseEntityService'; import { BaseEntityService } from '../BaseEntityService';
import { IndexedDBContext } from '../IndexedDBContext'; import { IndexedDBContext } from '../IndexedDBContext';
/**
* Default settings ID - single document per tenant
*/
const TENANT_SETTINGS_ID = 'tenant-settings';
/** /**
* SettingsService - CRUD operations for tenant settings * SettingsService - CRUD operations for tenant settings
* *
* Settings are stored as a single document with sections. * Settings are stored as separate records per section.
* This service provides convenience methods for accessing specific sections. * This service provides typed methods for accessing specific settings.
*/ */
export class SettingsService extends BaseEntityService<ITenantSettings> { export class SettingsService extends BaseEntityService<TenantSetting> {
readonly storeName = SettingsStore.STORE_NAME; readonly storeName = SettingsStore.STORE_NAME;
readonly entityType: EntityType = 'Settings'; readonly entityType: EntityType = 'Settings';
@ -24,37 +27,57 @@ export class SettingsService extends BaseEntityService<ITenantSettings> {
} }
/** /**
* Get the tenant settings document * Get workweek settings
* Returns null if not yet loaded from backend
*/ */
async getSettings(): Promise<ITenantSettings | null> { async getWorkweekSettings(): Promise<IWorkweekSettings | null> {
return this.get(TENANT_SETTINGS_ID); return this.get(SettingsIds.WORKWEEK) as Promise<IWorkweekSettings | null>;
}
/**
* Get grid settings
*/
async getGridSettings(): Promise<IGridSettings | null> {
return this.get(SettingsIds.GRID) as Promise<IGridSettings | null>;
}
/**
* Get time format settings
*/
async getTimeFormatSettings(): Promise<ITimeFormatSettings | null> {
return this.get(SettingsIds.TIME_FORMAT) as Promise<ITimeFormatSettings | null>;
}
/**
* Get view settings
*/
async getViewSettings(): Promise<IViewSettings | null> {
return this.get(SettingsIds.VIEWS) as Promise<IViewSettings | null>;
} }
/** /**
* Get workweek preset by ID * Get workweek preset by ID
*/ */
async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> { async getWorkweekPreset(presetId: string): Promise<IWorkweekPreset | null> {
const settings = await this.getSettings(); const settings = await this.getWorkweekSettings();
if (!settings) return null; if (!settings) return null;
return settings.workweek.presets[presetId] || null; return settings.presets[presetId] || null;
} }
/** /**
* Get the default workweek preset * Get the default workweek preset
*/ */
async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> { async getDefaultWorkweekPreset(): Promise<IWorkweekPreset | null> {
const settings = await this.getSettings(); const settings = await this.getWorkweekSettings();
if (!settings) return null; if (!settings) return null;
return settings.workweek.presets[settings.workweek.defaultPreset] || null; return settings.presets[settings.defaultPreset] || null;
} }
/** /**
* Get all available workweek presets * Get all available workweek presets
*/ */
async getWorkweekPresets(): Promise<IWorkweekPreset[]> { async getWorkweekPresets(): Promise<IWorkweekPreset[]> {
const settings = await this.getSettings(); const settings = await this.getWorkweekSettings();
if (!settings) return []; if (!settings) return [];
return Object.values(settings.workweek.presets); return Object.values(settings.presets);
} }
} }

View file

@ -3,6 +3,8 @@
* *
* Settings are tenant-specific configuration that comes from the backend * Settings are tenant-specific configuration that comes from the backend
* and is stored in IndexedDB for offline access. * and is stored in IndexedDB for offline access.
*
* Each settings section is stored as a separate record with its own id.
*/ */
import { ISync } from './CalendarTypes'; import { ISync } from './CalendarTypes';
@ -18,18 +20,20 @@ export interface IWorkweekPreset {
} }
/** /**
* Workweek settings section * Workweek settings - stored as separate record
*/ */
export interface IWorkweekSettings { export interface IWorkweekSettings extends ISync {
id: 'workweek';
presets: Record<string, IWorkweekPreset>; presets: Record<string, IWorkweekPreset>;
defaultPreset: string; defaultPreset: string;
firstDayOfWeek: number; // ISO: 1=Monday firstDayOfWeek: number; // ISO: 1=Monday
} }
/** /**
* Grid display settings section * Grid display settings - stored as separate record
*/ */
export interface IGridSettings { export interface IGridSettings extends ISync {
id: 'grid';
dayStartHour: number; dayStartHour: number;
dayEndHour: number; dayEndHour: number;
workStartHour: number; workStartHour: number;
@ -39,34 +43,35 @@ export interface IGridSettings {
} }
/** /**
* Time format settings section * Time format settings - stored as separate record
*/ */
export interface ITimeFormatSettings { export interface ITimeFormatSettings extends ISync {
id: 'timeFormat';
timezone: string; timezone: string;
locale: string; locale: string;
use24HourFormat: boolean; use24HourFormat: boolean;
} }
/** /**
* View settings section * View settings - stored as separate record
*/ */
export interface IViewSettings { export interface IViewSettings extends ISync {
id: 'views';
availableViews: string[]; availableViews: string[];
defaultView: string; defaultView: string;
} }
/** /**
* ITenantSettings - Complete tenant configuration * Union type for all tenant settings records
*
* Single document stored in IndexedDB 'settings' store.
* Sections can be extended as needed without schema changes.
*/ */
export interface ITenantSettings extends ISync { export type TenantSetting = IWorkweekSettings | IGridSettings | ITimeFormatSettings | IViewSettings;
id: string;
lastModified?: string;
workweek: IWorkweekSettings; /**
grid: IGridSettings; * Settings IDs as const for type safety
timeFormat: ITimeFormatSettings; */
views: IViewSettings; export const SettingsIds = {
} WORKWEEK: 'workweek',
GRID: 'grid',
TIME_FORMAT: 'timeFormat',
VIEWS: 'views'
} as const;

View file

@ -1,9 +1,7 @@
{ [
"id": "tenant-settings", {
"id": "workweek",
"syncStatus": "synced", "syncStatus": "synced",
"lastModified": "2025-12-15T10:00:00Z",
"workweek": {
"presets": { "presets": {
"standard": { "standard": {
"id": "standard", "id": "standard",
@ -34,8 +32,9 @@
"defaultPreset": "standard", "defaultPreset": "standard",
"firstDayOfWeek": 1 "firstDayOfWeek": 1
}, },
{
"grid": { "id": "grid",
"syncStatus": "synced",
"dayStartHour": 6, "dayStartHour": 6,
"dayEndHour": 22, "dayEndHour": 22,
"workStartHour": 8, "workStartHour": 8,
@ -43,15 +42,17 @@
"hourHeight": 80, "hourHeight": 80,
"snapInterval": 15 "snapInterval": 15
}, },
{
"timeFormat": { "id": "timeFormat",
"syncStatus": "synced",
"timezone": "Europe/Copenhagen", "timezone": "Europe/Copenhagen",
"locale": "da-DK", "locale": "da-DK",
"use24HourFormat": true "use24HourFormat": true
}, },
{
"views": { "id": "views",
"syncStatus": "synced",
"availableViews": ["simple", "resource", "team", "department"], "availableViews": ["simple", "resource", "team", "department"],
"defaultView": "simple" "defaultView": "simple"
} }
} ]